From cb3da3bb249131c98f967328d6c14de8f3dd8ed6 Mon Sep 17 00:00:00 2001 From: irrlicht Date: Wed, 3 Jun 2026 13:21:42 +0200 Subject: [PATCH] =?UTF-8?q?Soft-Delete:=20Beitr=C3=A4ge=20als=20[deleted]?= =?UTF-8?q?=20erhalten=20statt=20l=C3=B6schen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - entry.deleted; entrySelect LEFT JOIN + COALESCE(username) - POST /entry/{pid}/delete: deleted=1, Inhalt/Bild/Autor geleert, Zeile bleibt - Konto-Löschung soft-deletet die eigenen Beiträge (statt hartem DELETE), damit fremde Antworten nicht verwaisen - Frontend: [deleted]-Platzhalter im Feed, Löschen-Button auf Focus-Seite - notes/migrations.md: ALTER TABLE deleted Co-Authored-By: Claude Opus 4.8 --- db.go | 3 +- endpoints_test.go | 71 +++++++++++++++++++++++++++++++++++++++++++-- entry.go | 46 ++++++++++++++++++++++++++--- main.go | 1 + notes/migrations.md | 17 +++++++++++ user.go | 10 ++++--- web/js/entry.js | 17 +++++++++-- web/js/feed.js | 16 ++++++++-- 8 files changed, 164 insertions(+), 17 deletions(-) diff --git a/db.go b/db.go index 2067d79..dcf8b88 100644 --- a/db.go +++ b/db.go @@ -36,7 +36,8 @@ CREATE TABLE IF NOT EXISTS entry ( filepath TEXT, reply_to INTEGER NOT NULL DEFAULT 0, reply_count INTEGER NOT NULL DEFAULT 0, - last_activity INTEGER + last_activity INTEGER, + deleted INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS vote ( diff --git a/endpoints_test.go b/endpoints_test.go index ffef53e..e2c37d8 100644 --- a/endpoints_test.go +++ b/endpoints_test.go @@ -239,8 +239,11 @@ func createReply(t *testing.T, c *http.Client, srv *httptest.Server, content str } type threadEntry struct { - PID int64 `json:"pid"` - ReplyCount int64 `json:"reply_count"` + PID int64 `json:"pid"` + ReplyCount int64 `json:"reply_count"` + Deleted int64 `json:"deleted"` + Content string `json:"content"` + Username string `json:"username"` } type threadView struct { @@ -315,6 +318,56 @@ func TestThreading(t *testing.T) { } } +func TestSoftDelete(t *testing.T) { + srv := newTestServer(t) + alice := registerAndLogin(t, srv, "alice") + bob := registerAndLogin(t, srv, "bob") + + root := createEntry(t, alice, srv, "wurzel") + reply := createReply(t, alice, srv, "antwort", root) + + delURL := fmt.Sprintf("%s/entry/%d/delete", srv.URL, root) + + // Fremder darf nicht löschen. + resp := postForm(t, bob, delURL, url.Values{}) + resp.Body.Close() + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("fremd-delete: erwartet 403, bekam %d", resp.StatusCode) + } + + // Eigentümer löscht den Root weich. + resp = postForm(t, alice, delURL, url.Values{}) + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("delete: erwartet 200, bekam %d", resp.StatusCode) + } + + // Root bleibt als [deleted]-Platzhalter; die Antwort verwaist nicht. + tv := getThread(t, srv, root) + if tv.Entry.Deleted != 1 || tv.Entry.Content != "" || tv.Entry.Username != "" { + t.Fatalf("deleted entry nicht anonymisiert: %+v", tv.Entry) + } + if len(tv.Replies) != 1 || tv.Replies[0].PID != reply { + t.Fatalf("antwort verwaist: %+v", tv.Replies) + } + + // Der gelöschte Root erscheint weiterhin im Hauptfeed (Thread bleibt erreichbar). + feedResp, _ := http.Get(srv.URL + "/entry/feed/0") + var feed []threadEntry + json.NewDecoder(feedResp.Body).Decode(&feed) + feedResp.Body.Close() + if len(feed) != 1 || feed[0].PID != root || feed[0].Deleted != 1 { + t.Fatalf("hauptfeed nach delete: %+v", feed) + } + + // Nicht existierender Beitrag -> 404. + resp = postForm(t, alice, srv.URL+"/entry/999999/delete", url.Values{}) + resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("delete unbekannt: erwartet 404, bekam %d", resp.StatusCode) + } +} + func TestRenameUser(t *testing.T) { srv := newTestServer(t) alice := registerAndLogin(t, srv, "alice") @@ -364,6 +417,11 @@ func TestRenameUser(t *testing.T) { func TestDeleteUser(t *testing.T) { srv := newTestServer(t) alice := registerAndLogin(t, srv, "alice") + bob := registerAndLogin(t, srv, "bob") + + // alice schreibt einen Beitrag, bob antwortet darauf. + root := createEntry(t, alice, srv, "alices beitrag") + reply := createReply(t, bob, srv, "bobs antwort", root) // Falsches Passwort -> 403. resp := postForm(t, alice, srv.URL+"/user/delete", url.Values{"pass1": {"falsch"}}) @@ -388,4 +446,13 @@ func TestDeleteUser(t *testing.T) { if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("Login nach Löschen: erwartet 401, bekam %d", resp.StatusCode) } + + // alices Beitrag bleibt als [deleted]-Platzhalter; bobs Antwort verwaist nicht. + tv := getThread(t, srv, root) + if tv.Entry.Deleted != 1 || tv.Entry.Content != "" || tv.Entry.Username != "" { + t.Fatalf("beitrag nach konto-löschung nicht soft-deleted: %+v", tv.Entry) + } + if len(tv.Replies) != 1 || tv.Replies[0].PID != reply { + t.Fatalf("bobs antwort verwaist: %+v", tv.Replies) + } } diff --git a/entry.go b/entry.go index 5063b98..76b3098 100644 --- a/entry.go +++ b/entry.go @@ -28,13 +28,17 @@ type Entry struct { ReplyTo int64 `json:"reply_to"` ReplyCount int64 `json:"reply_count"` LastActivity int64 `json:"last_activity"` + Deleted int64 `json:"deleted"` Username string `json:"username"` } +// LEFT JOIN + COALESCE, damit soft-gelöschte Beiträge (uid auf 0 gesetzt) als +// [deleted]-Platzhalter erhalten bleiben und Threads nicht verwaisen. const entrySelect = ` SELECT e.pid, e.uid, e.created_at, e.content, e.filepath, - e.reply_to, e.reply_count, e.last_activity, u.username - FROM entry e JOIN user u ON u.uid = e.uid` + e.reply_to, e.reply_count, e.last_activity, e.deleted, + COALESCE(u.username, '') + FROM entry e LEFT JOIN user u ON u.uid = e.uid` // scanEntries liest Entry-Zeilen aus einem Query mit entrySelect-Spalten. func scanEntries(rows *sql.Rows) ([]Entry, error) { @@ -43,7 +47,7 @@ func scanEntries(rows *sql.Rows) ([]Entry, error) { for rows.Next() { var e Entry if err := rows.Scan(&e.PID, &e.UID, &e.CreatedAt, &e.Content, &e.Filepath, - &e.ReplyTo, &e.ReplyCount, &e.LastActivity, &e.Username); err != nil { + &e.ReplyTo, &e.ReplyCount, &e.LastActivity, &e.Deleted, &e.Username); err != nil { continue } entries = append(entries, e) @@ -56,7 +60,7 @@ func entryByPID(pid int64) (Entry, error) { var e Entry err := db.QueryRow(entrySelect+` WHERE e.pid = ?`, pid).Scan( &e.PID, &e.UID, &e.CreatedAt, &e.Content, &e.Filepath, - &e.ReplyTo, &e.ReplyCount, &e.LastActivity, &e.Username) + &e.ReplyTo, &e.ReplyCount, &e.LastActivity, &e.Deleted, &e.Username) return e, err } @@ -260,6 +264,40 @@ func handleCreateEntry(w http.ResponseWriter, r *http.Request) { }) } +// handleDeleteEntry löscht einen eigenen Beitrag "weich": die Zeile bleibt als +// [deleted]-Platzhalter erhalten (damit Antworten nicht verwaisen), Inhalt, Bild +// und Autor werden entfernt. +func handleDeleteEntry(w http.ResponseWriter, r *http.Request) { + pid, err := strconv.ParseInt(chi.URLParam(r, "pid"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "Ungültige pid") + return + } + uid := uidFromContext(r.Context()) + + var owner int64 + var filepath string + if err := db.QueryRow(`SELECT uid, filepath FROM entry WHERE pid = ?`, pid).Scan(&owner, &filepath); err != nil { + writeError(w, http.StatusNotFound, "Beitrag nicht gefunden") + return + } + if owner != uid { + writeError(w, http.StatusForbidden, "Das ist nicht dein Beitrag.") + return + } + + if _, err := db.Exec( + `UPDATE entry SET deleted = 1, content = '', filepath = '', uid = 0 WHERE pid = ?`, pid, + ); err != nil { + writeError(w, http.StatusInternalServerError, "Löschen fehlgeschlagen") + return + } + if filepath != "" { + os.Remove(filepath) + } + writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) +} + // voteTally liefert die Zähler und die eigene Auswahl (selected) für einen Beitrag. // Ist uid == 0 (nicht eingeloggt), ist selected immer "none". func voteTally(pid, uid int64) (left, right int, selected string) { diff --git a/main.go b/main.go index b4de427..55142d8 100644 --- a/main.go +++ b/main.go @@ -50,6 +50,7 @@ func routes() http.Handler { r.Use(requireAuth) r.Post("/entry/create", handleCreateEntry) r.Post("/entry/{pid}/vote", handleVote) + r.Post("/entry/{pid}/delete", handleDeleteEntry) r.Get("/user/info", handleUserInfo) r.Post("/user/rename", handleUserRename) r.Post("/user/delete", handleUserDelete) diff --git a/notes/migrations.md b/notes/migrations.md index b32d425..936eb16 100644 --- a/notes/migrations.md +++ b/notes/migrations.md @@ -46,3 +46,20 @@ UPDATE entry SET last_activity = created_at WHERE last_activity IS NULL; Bestehende Zeilen werden so zu Root-Beiträgen (`reply_to = 0`, `reply_count = 0`) mit `last_activity = created_at`. Nicht idempotent — die `ALTER TABLE`-Zeilen nur einmalig ausführen. + +--- + +## Soft-Delete-Spalte in `entry` + +**Wann:** beim Deploy der Soft-Delete-Version gegen eine bestehende `kver.db`. + +**Hintergrund:** Statt Beiträge hart zu löschen werden sie als `[deleted]`-Platzhalter +markiert (`deleted = 1`, Inhalt/Bild/Autor geleert), damit Antworten nicht verwaisen. +Das Frontend und die Feeds erkennen gelöschte Beiträge an dieser Spalte. + +**Migration:** +```sql +ALTER TABLE entry ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0; +``` + +Bestehende Beiträge bleiben damit sichtbar (`deleted = 0`). Nur einmalig ausführen. diff --git a/user.go b/user.go index 568349f..8542341 100644 --- a/user.go +++ b/user.go @@ -111,8 +111,10 @@ func handleUserRename(w http.ResponseWriter, r *http.Request) { } // handleUserDelete löscht den eingeloggten Account dauerhaft, nach Bestätigung -// durch das Passwort (Formularfeld pass1). Beiträge, Votes und Sessions werden -// in einer Transaktion mitgelöscht; zugehörige Mediendateien best effort entfernt. +// durch das Passwort (Formularfeld pass1). Sessions und Votes werden in einer +// Transaktion mitgelöscht; die eigenen Beiträge werden soft-gelöscht (als +// [deleted]-Platzhalter erhalten), damit fremde Antworten nicht verwaisen. +// Zugehörige Mediendateien werden best effort entfernt. func handleUserDelete(w http.ResponseWriter, r *http.Request) { uid := uidFromContext(r.Context()) @@ -127,7 +129,7 @@ func handleUserDelete(w http.ResponseWriter, r *http.Request) { return } - // Mediendateien der Beiträge einsammeln, bevor die Zeilen verschwinden. + // Mediendateien der Beiträge einsammeln, bevor die Pfade geleert werden. var media []string if rows, err := db.Query(`SELECT filepath FROM entry WHERE uid = ?`, uid); err == nil { for rows.Next() { @@ -147,7 +149,7 @@ func handleUserDelete(w http.ResponseWriter, r *http.Request) { for _, q := range []string{ `DELETE FROM session WHERE uid = ?`, `DELETE FROM vote WHERE uid = ?`, - `DELETE FROM entry WHERE uid = ?`, + `UPDATE entry SET deleted = 1, content = '', filepath = '', uid = 0 WHERE uid = ?`, `DELETE FROM user WHERE uid = ?`, } { if _, err := tx.Exec(q, uid); err != nil { diff --git a/web/js/entry.js b/web/js/entry.js index 728d21c..018c021 100644 --- a/web/js/entry.js +++ b/web/js/entry.js @@ -35,9 +35,20 @@ async function load() { focus.innerHTML = ""; focus.appendChild(renderEntry(data.entry)); - // Reply-Box nur eingeloggt zeigen. - const h = await api.get("/auth/headerbar"); - document.getElementById("reply-box").hidden = !h.loggedin; + // Eingeloggt? Reply-Box zeigen; eigener, nicht gelöschter Beitrag -> Löschen-Button. + const me = await api.get("/user/info"); + document.getElementById("reply-box").hidden = !me.uid; + if (me.uid === data.entry.uid && !data.entry.deleted) { + const btn = document.createElement("button"); + btn.textContent = "Löschen"; + btn.addEventListener("click", async () => { + if (!confirm("Beitrag wirklich löschen?")) return; + const res = await api.post(`/entry/${pid}/delete`, new FormData()); + if (res.ok) load(); + else alert(res.data.error || "Löschen fehlgeschlagen."); + }); + focus.appendChild(btn); + } // Direkte Antworten. const replies = document.getElementById("replies"); diff --git a/web/js/feed.js b/web/js/feed.js index 47b68c9..080311f 100644 --- a/web/js/feed.js +++ b/web/js/feed.js @@ -34,15 +34,25 @@ function renderEntry(e) { el.className = "card"; const when = new Date(e.created_at * 1000).toLocaleString("de-DE"); + const replyLabel = + e.reply_count === 1 ? "1 Antwort" : `${e.reply_count} Antworten`; + + // Soft-gelöschter Beitrag: nur Platzhalter, damit der Thread navigierbar bleibt. + if (e.deleted) { + el.innerHTML = ` +
[deleted] · ${when}
+
[deleted]
+ + `; + return el; + } + const user = encodeURIComponent(e.username); let media = ""; if (e.filepath) { media = ``; } - const replyLabel = - e.reply_count === 1 ? "1 Antwort" : `${e.reply_count} Antworten`; - el.innerHTML = `
${linkify(e.content)}