diff --git a/.idea/dataSources.local.xml b/.idea/dataSources.local.xml index b341a8a..707e2b3 100644 --- a/.idea/dataSources.local.xml +++ b/.idea/dataSources.local.xml @@ -1,6 +1,6 @@ - + " diff --git a/README.md b/README.md index 9cea362..428a9c9 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,9 @@ - JavaScript wird verwendet, um im erweitertem Beitragseditor clientseitig einzelne Content-Boxen erstellen und löschen zu können. - JavaScript wird ebenfalls verwendet, um in die Suchergebnisse clientseitig zu sortieren. +- Die Kommentarfunktion ist zusätzlich auch ohne JavaScript nutzbar. Kommentare und Antworten können auch ohne JavaScript erstellt werden. +- Eigene Kommentare und Antworten können bearbeitet und gelöscht werden. Die Bearbeitung und das Löschen funktionieren ebenfalls ohne JavaScript über normale Formulare und serverseitige Verarbeitung. + ## Sonstiges - Das Datenschema befindet sich unter /planung/Datenschema.pdf diff --git a/content/showArticle.php b/content/showArticle.php index aea506c..c5152c1 100644 --- a/content/showArticle.php +++ b/content/showArticle.php @@ -5,6 +5,34 @@ $repliesByParent = []; $articleObj = null; include_once 'php/controller/showArticle-controller.php'; +/* + * Ermittelt, ob ohne JavaScript auf einen Kommentar + * geantwortet werden soll. + */ +$replyTo = filter_input( + INPUT_GET, + "reply_to", + FILTER_VALIDATE_INT +); + +$replyAuthor = null; + +if ($replyTo !== false && $replyTo !== null) { + foreach ($mainComments as $mainComment) { + if ($mainComment->getId() === $replyTo) { + $replyAuthor = $mainComment->getAuthor(); + break; + } + } +} + +/* + * Eine Antwort darf nur auf einen existierenden + * Hauptkommentar geschrieben werden. + */ +if ($replyAuthor === null) { + $replyTo = null; +} ?>
-
-
@@ -106,39 +132,268 @@ include_once 'php/controller/showArticle-controller.php';
-
+

Kommentare

+ +
"> + +
+ + + +
-
-

- getAuthor()); ?> - getCreated()); ?> -

+
-

getContent())); ?>

+ getContent() + === "Dieser Kommentar wurde gelöscht."; + ?> + + + +

+ Dieser Kommentar wurde gelöscht. +

+ + + +

+ + getAuthor()); ?> + + + + getCreated()); ?> + +

+ +

+ getContent()) + ); + ?> +

+ + getAuthor() + ): ?> +
+ + Kommentar bearbeiten + + +
+ + + + + + + + + + +
+
+ +
+ + + + + + +
+ + + + + + &id=getArticleId()); + ?>&reply_to=getId()); + ?>#comment-form" + class="reply-button" + data-comment-id="getId() + ); ?>" + data-author="getAuthor() + ); ?>"> + + Antworten + + + - -
getId()])): ?> getId()] as $reply): ?>
-

- getAuthor()); ?> - getCreated()); ?> -

-

getContent())); ?>

+ getContent() + === "Dieser Kommentar wurde gelöscht."; + ?> + + + +

+ Dieser Kommentar wurde gelöscht. +

+ + + +

+ + getAuthor()); ?> + + + + getCreated()); ?> + +

+ +

+ getContent()) + ); + ?> +

+ + getAuthor() + ): ?> + +
+ + Antwort bearbeiten + + +
+ + + + + + + + + + +
+
+ +
+ + + + + + +
+ + + + +
@@ -153,17 +408,46 @@ include_once 'php/controller/showArticle-controller.php';
-
+ + "> + value=""> + value=""> - +

+ style="display: none;" + > + + + Antwort auf + + &id=#comment-form"> + Abbrechen + + +

+ + + + +
+ + +
+ + + + + +
+ `; + + const parentReplies = commentsList.querySelector( `.comment-item[data-comment-id="${parentCommentId}"] .comment-replies` ); if (parentReplies) { parentReplies.appendChild(commentElement); + } else { + commentsList.prepend(commentElement); } - } else { - commentElement.dataset.commentId = data.commentId; - commentElement.innerHTML = ` -

- ${escapeHtml(data.author)} - ${escapeHtml(data.created)} -

-

${escapeHtml(data.content).replace(/\n/g, "
")}

+

+ ${escapeHtml(data.author)} + ${escapeHtml(data.created)} +

- +

${escapeHtml(data.content).replace(/\n/g, "
")}

-
- `; +
+ + Kommentar bearbeiten + + +
+ + + + + + + +
+
+ +
+ + + + + +
+ + + Antworten + + +
+ `; commentsList.prepend(commentElement); - const newReplyButton = commentElement.querySelector(".reply-button"); + const newReplyButton = + commentElement.querySelector(".reply-button"); if (newReplyButton) { registerReplyButton(newReplyButton); @@ -115,10 +260,14 @@ document.addEventListener("DOMContentLoaded", function () { commentContent.value = ""; parentCommentInput.value = ""; - replyInfo.textContent = ""; - replyInfo.style.display = "none"; + + if (replyInfo) { + replyInfo.textContent = ""; + replyInfo.style.display = "none"; + } }) - .catch(() => { + .catch(function (error) { + console.error(error); alert("Kommentar konnte nicht gesendet werden."); }); }); @@ -126,12 +275,12 @@ document.addEventListener("DOMContentLoaded", function () { /** * Entfernt HTML-Sonderzeichen aus Nutzereingaben. * - * @param {string} text Zu bereinigender Text + * @param {*} text Zu bereinigender Text * @returns {string} Sicherer Text */ function escapeHtml(text) { const div = document.createElement("div"); - div.textContent = text; + div.textContent = String(text ?? ""); return div.innerHTML; } }); \ No newline at end of file diff --git a/php/ajax/add-comment.php b/php/ajax/add-comment.php index b2c14b9..a985aeb 100644 --- a/php/ajax/add-comment.php +++ b/php/ajax/add-comment.php @@ -3,32 +3,153 @@ if (session_status() === PHP_SESSION_NONE) { session_start(); } -header("Content-Type: application/json"); - require_once "../model/CommentManager.php"; -if (!isset($_SESSION["user_email"])) { - echo json_encode([ - "success" => false, - "message" => "Du musst angemeldet sein, um zu kommentieren." - ]); +/** + * Prüft, ob die Anfrage durch JavaScript per AJAX gesendet wurde. + */ +$isAjaxRequest = isset($_SERVER["HTTP_X_REQUESTED_WITH"]) + && strtolower($_SERVER["HTTP_X_REQUESTED_WITH"]) === "xmlhttprequest"; + +/** + * Gibt das Ergebnis entweder als JSON zurück oder leitet + * bei einem normalen Formularaufruf wieder zum Beitrag zurück. + * + * @param bool $success War das Speichern erfolgreich? + * @param string $message Rückmeldung für den Benutzer + * @param int|null $articleId ID des Beitrags + * @param array $additionalData Zusätzliche Daten für AJAX + */ +function sendCommentResponse( + $success, + $message, + $articleId, + $additionalData = [] +) { + global $isAjaxRequest; + + if ($isAjaxRequest) { + header("Content-Type: application/json; charset=utf-8"); + + echo json_encode( + array_merge( + [ + "success" => $success, + "message" => $message + ], + $additionalData + ) + ); + + exit(); + } + + /* + * Bei deaktiviertem JavaScript wird die Rückmeldung + * in der Session gespeichert und die Beitragsseite neu geladen. + */ + $_SESSION["comment_message"] = $message; + $_SESSION["comment_message_type"] = $success ? "success" : "error"; + + if ($articleId !== null) { + header( + "Location: ../../index.php?pfad=showArticle&id=" + . urlencode((string) $articleId) + . "#comments" + ); + } else { + header("Location: ../../index.php"); + } + exit(); } -$articleId = $_POST["article_id"] ?? null; -$content = trim($_POST["content"] ?? ""); -$parentCommentId = $_POST["parent_comment_id"] ?? null; +/* + * Nur POST-Anfragen dürfen Kommentare erstellen. + */ +if ($_SERVER["REQUEST_METHOD"] !== "POST") { + sendCommentResponse( + false, + "Ungültige Anfrage.", + null + ); +} -if ($parentCommentId === "" || $parentCommentId === "0") { +/* + * Die Beitrags-ID wird zuerst eingelesen, + * damit bei Fehlern wieder zum Beitrag zurückgeleitet werden kann. + */ +$articleId = filter_input( + INPUT_POST, + "article_id", + FILTER_VALIDATE_INT +); + +/* + * Ein Benutzer muss angemeldet sein. + */ +if (!isset($_SESSION["user_email"])) { + sendCommentResponse( + false, + "Du musst angemeldet sein, um zu kommentieren.", + $articleId !== false ? $articleId : null + ); +} + +/* + * Weitere Formulardaten einlesen. + */ + +$content = trim($_POST["content"] ?? ""); + +$parentCommentId = filter_input( + INPUT_POST, + "parent_comment_id", + FILTER_VALIDATE_INT +); + +/* + * Ein leerer Wert bedeutet, dass es sich um einen + * normalen Hauptkommentar handelt. + */ +if ( + !isset($_POST["parent_comment_id"]) + || $_POST["parent_comment_id"] === "" + || $_POST["parent_comment_id"] === "0" +) { $parentCommentId = null; } -if (empty($articleId) || empty($content)) { - echo json_encode([ - "success" => false, - "message" => "Kommentar darf nicht leer sein." - ]); - exit(); +if ($articleId === false || $articleId === null) { + sendCommentResponse( + false, + "Der zugehörige Beitrag ist ungültig.", + null + ); +} + +if ($content === "") { + sendCommentResponse( + false, + "Der Kommentar darf nicht leer sein.", + $articleId + ); +} + +/* + * Eine ungültige Eltern-ID darf nicht gespeichert werden. + */ +if ( + isset($_POST["parent_comment_id"]) + && $_POST["parent_comment_id"] !== "" + && $_POST["parent_comment_id"] !== "0" + && $parentCommentId === false +) { + sendCommentResponse( + false, + "Der ausgewählte Kommentar ist ungültig.", + $articleId + ); } try { @@ -41,18 +162,23 @@ try { $parentCommentId ); - echo json_encode([ - "success" => true, - "commentId" => $commentId, - "author" => $_SESSION["user_email"], - "content" => $content, - "created" => date("Y-m-d H:i:s"), - "parentCommentId" => $parentCommentId - ]); + sendCommentResponse( + true, + "Der Kommentar wurde erfolgreich gespeichert.", + $articleId, + [ + "commentId" => $commentId, + "author" => $_SESSION["user_email"], + "content" => $content, + "created" => date("Y-m-d H:i:s"), + "parentCommentId" => $parentCommentId + ] + ); -} catch (Exception $e) { - echo json_encode([ - "success" => false, - "message" => "Kommentar konnte nicht gespeichert werden." - ]); +} catch (Throwable $e) { + sendCommentResponse( + false, + "Der Kommentar konnte nicht gespeichert werden.", + $articleId + ); } \ No newline at end of file diff --git a/php/controller/deleteComment-controller.php b/php/controller/deleteComment-controller.php new file mode 100644 index 0000000..4eeaeca --- /dev/null +++ b/php/controller/deleteComment-controller.php @@ -0,0 +1,105 @@ +deleteComment( + $commentId, + $_SESSION["user_email"] + ); + + if ($deleted) { + $_SESSION["comment_message"] = + "Der Kommentar wurde gelöscht."; + + $_SESSION["comment_message_type"] = "success"; + } else { + $_SESSION["comment_message"] = + "Der Kommentar wurde nicht gefunden oder gehört nicht dir."; + + $_SESSION["comment_message_type"] = "error"; + } + +} catch (Throwable $e) { + $_SESSION["comment_message"] = + "Der Kommentar konnte nicht gelöscht werden."; + + $_SESSION["comment_message_type"] = "error"; +} + +/* + * Anschließend wird wieder zum Beitrag und zu den Kommentaren geleitet. + */ +header( + "Location: index.php?pfad=showArticle&id=" + . urlencode((string) $articleId) + . "#comments" +); + +exit(); diff --git a/php/controller/showArticle-controller.php b/php/controller/showArticle-controller.php index 676f812..dff5eae 100644 --- a/php/controller/showArticle-controller.php +++ b/php/controller/showArticle-controller.php @@ -49,8 +49,9 @@ if (isset($_GET["id"]) && !empty($_GET["id"])){ } } - } catch (Exception $e){ + } catch (Throwable $e) { $_SESSION["message"] = "internal_error"; + header("Location: index.php"); exit(); } }else{ diff --git a/php/controller/updateComment-controller.php b/php/controller/updateComment-controller.php new file mode 100644 index 0000000..e215911 --- /dev/null +++ b/php/controller/updateComment-controller.php @@ -0,0 +1,92 @@ +updateComment( + $commentId, + $_SESSION["user_email"], + $content + ); + + if ($updated) { + $_SESSION["comment_message"] = + "Der Kommentar wurde erfolgreich bearbeitet."; + + $_SESSION["comment_message_type"] = "success"; + } else { + $_SESSION["comment_message"] = + "Der Kommentar konnte nicht bearbeitet werden."; + + $_SESSION["comment_message_type"] = "error"; + } + +} catch (Throwable $e) { + $_SESSION["comment_message"] = + "Beim Bearbeiten des Kommentars ist ein Fehler aufgetreten."; + + $_SESSION["comment_message_type"] = "error"; +} + +header( + "Location: index.php?pfad=showArticle&id=" + . urlencode((string) $articleId) + . "#comments" +); +exit(); diff --git a/php/model/Comment.php b/php/model/Comment.php index b92543b..a4b241a 100644 --- a/php/model/Comment.php +++ b/php/model/Comment.php @@ -13,7 +13,7 @@ class Comment private int $id; private int $articleId; private ?int $parentCommentId; - private string $author; + private ?string $author; private string $content; private string $created; @@ -23,7 +23,7 @@ class Comment * @param int $id Eindeutige ID des Kommentars * @param int $articleId ID des zugehörigen Beitrags * @param int|null $parentCommentId ID des Eltern-Kommentars oder null - * @param string $author Autor des Kommentars + * @param string|null $author Autor des Kommentars oder null bei gelöschten Kommentaren * @param string $content Inhalt des Kommentars * @param string $created Erstellungsdatum des Kommentars */ @@ -31,7 +31,7 @@ class Comment int $id, int $articleId, ?int $parentCommentId, - string $author, + ?string $author, string $content, string $created ) { @@ -86,9 +86,9 @@ class Comment /** * Gibt den Autor des Kommentars zurück. * - * @return string Autor + * @return string|null Autor oder null bei gelöschten Kommentaren */ - public function getAuthor(): string + public function getAuthor(): ?string { return $this->author; } diff --git a/php/model/CommentManagerDAO.php b/php/model/CommentManagerDAO.php index 348126e..518d819 100644 --- a/php/model/CommentManagerDAO.php +++ b/php/model/CommentManagerDAO.php @@ -51,4 +51,40 @@ interface CommentManagerDAO * @return Comment[] Liste der Kommentare */ public function getCommentsByAuthor($author); + /** + * Löscht einen einzelnen Kommentar des angemeldeten Nutzers. + * + * Kommentare ohne Antworten werden vollständig entfernt. + * Kommentare mit Antworten bleiben als anonymer Platzhalter erhalten. + * + * @param int $commentId ID des Kommentars + * @param string $author E-Mail-Adresse des Autors + * @return bool true, wenn der Kommentar gefunden und gelöscht wurde + */ + public function deleteComment(int $commentId, string $author): bool; + + /** + * Löscht beziehungsweise anonymisiert alle Kommentare eines Nutzers. + * + * Diese Methode wird bei der Löschung eines Benutzerkontos verwendet. + * + * @param string $author E-Mail-Adresse des Nutzers + * @return void + */ + public function deleteCommentsByAuthor(string $author): void; + /** + * Bearbeitet einen Kommentar des angemeldeten Nutzers. + * + * Nur der Autor des Kommentars darf den Inhalt ändern. + * + * @param int $commentId ID des Kommentars + * @param string $author E-Mail-Adresse des Autors + * @param string $content Neuer Kommentarinhalt + * @return bool true, wenn der Kommentar bearbeitet wurde + */ + public function updateComment( + int $commentId, + string $author, + string $content + ): bool; } \ No newline at end of file diff --git a/php/model/DatabaseCommentManager.php b/php/model/DatabaseCommentManager.php index aebe73c..ab9e5aa 100644 --- a/php/model/DatabaseCommentManager.php +++ b/php/model/DatabaseCommentManager.php @@ -17,7 +17,7 @@ class DatabaseCommentManager implements CommentManagerDAO /** * Erstellt die Kommentartabelle, falls diese noch nicht existiert. */ - public function __construct() + private function __construct() { $this->dbPath = __DIR__ . '/../../db/eduforgeDB.db'; DatabaseInitializer::initialize($this->dbPath); @@ -223,4 +223,194 @@ class DatabaseCommentManager implements CommentManagerDAO return $comments; } + /** + * Bearbeitet einen eigenen Kommentar. + * + * Der Kommentar wird nur geändert, wenn er dem + * angemeldeten Nutzer gehört. + * + * @param int $commentId ID des Kommentars + * @param string $author E-Mail-Adresse des Autors + * @param string $content Neuer Kommentarinhalt + * @return bool true, wenn der Kommentar bearbeitet wurde + */ + public function updateComment( + int $commentId, + string $author, + string $content + ): bool { + try { + $db = $this->getConnection(); + + $command = $db->prepare(" + UPDATE comments + SET content = :content + WHERE id = :commentId + AND author = :author + "); + + $command->execute([ + ":content" => $content, + ":commentId" => $commentId, + ":author" => $author + ]); + + return $command->rowCount() > 0; + + } catch (PDOException $e) { + throw new RuntimeException("internal_error"); + } + } + /** + * Löscht einen eigenen Kommentar. + * + * Hat der Kommentar Antworten, wird er anonymisiert. + * Hat er keine Antworten, wird er vollständig gelöscht. + * + * @param int $commentId ID des Kommentars + * @param string $author E-Mail-Adresse des Autors + * @return bool true, wenn der Kommentar gelöscht wurde + */ + public function deleteComment(int $commentId, string $author): bool + { + try { + $db = $this->getConnection(); + + /* + * Zuerst wird geprüft, ob der Kommentar existiert + * und wirklich dem angemeldeten Nutzer gehört. + */ + $checkCommand = $db->prepare(" + SELECT id + FROM comments + WHERE id = :commentId + AND author = :author + "); + + $checkCommand->execute([ + ":commentId" => $commentId, + ":author" => $author + ]); + + if ($checkCommand->fetch() === false) { + return false; + } + + /* + * Danach wird geprüft, ob Antworten auf den Kommentar existieren. + */ + $replyCommand = $db->prepare(" + SELECT COUNT(*) + FROM comments + WHERE parent_comment_id = :commentId + "); + + $replyCommand->execute([ + ":commentId" => $commentId + ]); + + $hasReplies = (int) $replyCommand->fetchColumn() > 0; + + if ($hasReplies) { + /* + * Der Kommentar wird für den Kommentarbaum benötigt. + * Deshalb bleibt er als anonymer Platzhalter erhalten. + */ + $deleteCommand = $db->prepare(" + UPDATE comments + SET author = NULL, + content = 'Dieser Kommentar wurde gelöscht.' + WHERE id = :commentId + AND author = :author + "); + } else { + /* + * Ohne Antworten kann der Kommentar vollständig + * aus der Datenbank entfernt werden. + */ + $deleteCommand = $db->prepare(" + DELETE FROM comments + WHERE id = :commentId + AND author = :author + "); + } + + $deleteCommand->execute([ + ":commentId" => $commentId, + ":author" => $author + ]); + + return $deleteCommand->rowCount() > 0; + + } catch (PDOException $e) { + throw new RuntimeException("internal_error"); + } + } + /** + * Löscht beziehungsweise anonymisiert alle Kommentare eines Nutzers. + * + * Kommentare ohne Antworten werden vollständig gelöscht. + * Kommentare mit Antworten bleiben als anonyme Platzhalter erhalten. + * + * @param string $author E-Mail-Adresse des Nutzers + * @return void + */ + public function deleteCommentsByAuthor(string $author): void + { + $db = $this->getConnection(); + + try { + $db->beginTransaction(); + + /* + * Zuerst werden alle Kommentare ohne Antworten gelöscht. + * + * Die Schleife ist wichtig, weil durch das Löschen einer Antwort + * eventuell auch der darüberliegende Kommentar keine Antworten + * mehr besitzt und anschließend ebenfalls gelöscht werden kann. + */ + do { + $deleteCommand = $db->prepare(" + DELETE FROM comments + WHERE author = :author + AND NOT EXISTS ( + SELECT 1 + FROM comments AS replies + WHERE replies.parent_comment_id = comments.id + ) + "); + + $deleteCommand->execute([ + ":author" => $author + ]); + + $deletedRows = $deleteCommand->rowCount(); + + } while ($deletedRows > 0); + + /* + * Kommentare, auf die noch Antworten anderer Nutzer folgen, + * müssen für den Kommentarbaum erhalten bleiben. + */ + $placeholderCommand = $db->prepare(" + UPDATE comments + SET author = NULL, + content = 'Dieser Kommentar wurde gelöscht.' + WHERE author = :author + "); + + $placeholderCommand->execute([ + ":author" => $author + ]); + + $db->commit(); + + } catch (PDOException $e) { + if ($db->inTransaction()) { + $db->rollBack(); + } + + throw new RuntimeException("internal_error"); + } + } } \ No newline at end of file diff --git a/php/model/DatabaseInitializer.php b/php/model/DatabaseInitializer.php index 573d43e..f928be1 100644 --- a/php/model/DatabaseInitializer.php +++ b/php/model/DatabaseInitializer.php @@ -46,17 +46,17 @@ class DatabaseInitializer { $db->exec(" CREATE TABLE IF NOT EXISTS comments ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - article_id INTEGER NOT NULL, - parent_comment_id INTEGER NULL, - author TEXT NOT NULL, - content TEXT NOT NULL, - created TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (article_id) REFERENCES articles(id) ON DELETE CASCADE, - FOREIGN KEY (author) REFERENCES users(email) ON DELETE CASCADE, - FOREIGN KEY (parent_comment_id) REFERENCES comments(id) ON DELETE CASCADE - ); - "); + id INTEGER PRIMARY KEY AUTOINCREMENT, + article_id INTEGER NOT NULL, + parent_comment_id INTEGER NULL, + author TEXT NULL, + content TEXT NOT NULL, + created TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (article_id) REFERENCES articles(id) ON DELETE CASCADE, + FOREIGN KEY (author) REFERENCES users(email) ON DELETE SET NULL, + FOREIGN KEY (parent_comment_id) REFERENCES comments(id) ON DELETE CASCADE + ); + "); $initializer = new self(); $availableEmails = $initializer->seedDummyUsers($db);