Soft-Delete: Beiträge als [deleted] erhalten statt löschen
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -36,7 +36,8 @@ CREATE TABLE IF NOT EXISTS entry (
|
|||||||
filepath TEXT,
|
filepath TEXT,
|
||||||
reply_to INTEGER NOT NULL DEFAULT 0,
|
reply_to INTEGER NOT NULL DEFAULT 0,
|
||||||
reply_count 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 (
|
CREATE TABLE IF NOT EXISTS vote (
|
||||||
|
|||||||
+69
-2
@@ -239,8 +239,11 @@ func createReply(t *testing.T, c *http.Client, srv *httptest.Server, content str
|
|||||||
}
|
}
|
||||||
|
|
||||||
type threadEntry struct {
|
type threadEntry struct {
|
||||||
PID int64 `json:"pid"`
|
PID int64 `json:"pid"`
|
||||||
ReplyCount int64 `json:"reply_count"`
|
ReplyCount int64 `json:"reply_count"`
|
||||||
|
Deleted int64 `json:"deleted"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Username string `json:"username"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type threadView struct {
|
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) {
|
func TestRenameUser(t *testing.T) {
|
||||||
srv := newTestServer(t)
|
srv := newTestServer(t)
|
||||||
alice := registerAndLogin(t, srv, "alice")
|
alice := registerAndLogin(t, srv, "alice")
|
||||||
@@ -364,6 +417,11 @@ func TestRenameUser(t *testing.T) {
|
|||||||
func TestDeleteUser(t *testing.T) {
|
func TestDeleteUser(t *testing.T) {
|
||||||
srv := newTestServer(t)
|
srv := newTestServer(t)
|
||||||
alice := registerAndLogin(t, srv, "alice")
|
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.
|
// Falsches Passwort -> 403.
|
||||||
resp := postForm(t, alice, srv.URL+"/user/delete", url.Values{"pass1": {"falsch"}})
|
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 {
|
if resp.StatusCode != http.StatusUnauthorized {
|
||||||
t.Fatalf("Login nach Löschen: erwartet 401, bekam %d", resp.StatusCode)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,13 +28,17 @@ type Entry struct {
|
|||||||
ReplyTo int64 `json:"reply_to"`
|
ReplyTo int64 `json:"reply_to"`
|
||||||
ReplyCount int64 `json:"reply_count"`
|
ReplyCount int64 `json:"reply_count"`
|
||||||
LastActivity int64 `json:"last_activity"`
|
LastActivity int64 `json:"last_activity"`
|
||||||
|
Deleted int64 `json:"deleted"`
|
||||||
Username string `json:"username"`
|
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 = `
|
const entrySelect = `
|
||||||
SELECT e.pid, e.uid, e.created_at, e.content, e.filepath,
|
SELECT e.pid, e.uid, e.created_at, e.content, e.filepath,
|
||||||
e.reply_to, e.reply_count, e.last_activity, u.username
|
e.reply_to, e.reply_count, e.last_activity, e.deleted,
|
||||||
FROM entry e JOIN user u ON u.uid = e.uid`
|
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.
|
// scanEntries liest Entry-Zeilen aus einem Query mit entrySelect-Spalten.
|
||||||
func scanEntries(rows *sql.Rows) ([]Entry, error) {
|
func scanEntries(rows *sql.Rows) ([]Entry, error) {
|
||||||
@@ -43,7 +47,7 @@ func scanEntries(rows *sql.Rows) ([]Entry, error) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var e Entry
|
var e Entry
|
||||||
if err := rows.Scan(&e.PID, &e.UID, &e.CreatedAt, &e.Content, &e.Filepath,
|
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
|
continue
|
||||||
}
|
}
|
||||||
entries = append(entries, e)
|
entries = append(entries, e)
|
||||||
@@ -56,7 +60,7 @@ func entryByPID(pid int64) (Entry, error) {
|
|||||||
var e Entry
|
var e Entry
|
||||||
err := db.QueryRow(entrySelect+` WHERE e.pid = ?`, pid).Scan(
|
err := db.QueryRow(entrySelect+` WHERE e.pid = ?`, pid).Scan(
|
||||||
&e.PID, &e.UID, &e.CreatedAt, &e.Content, &e.Filepath,
|
&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
|
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.
|
// voteTally liefert die Zähler und die eigene Auswahl (selected) für einen Beitrag.
|
||||||
// Ist uid == 0 (nicht eingeloggt), ist selected immer "none".
|
// Ist uid == 0 (nicht eingeloggt), ist selected immer "none".
|
||||||
func voteTally(pid, uid int64) (left, right int, selected string) {
|
func voteTally(pid, uid int64) (left, right int, selected string) {
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ func routes() http.Handler {
|
|||||||
r.Use(requireAuth)
|
r.Use(requireAuth)
|
||||||
r.Post("/entry/create", handleCreateEntry)
|
r.Post("/entry/create", handleCreateEntry)
|
||||||
r.Post("/entry/{pid}/vote", handleVote)
|
r.Post("/entry/{pid}/vote", handleVote)
|
||||||
|
r.Post("/entry/{pid}/delete", handleDeleteEntry)
|
||||||
r.Get("/user/info", handleUserInfo)
|
r.Get("/user/info", handleUserInfo)
|
||||||
r.Post("/user/rename", handleUserRename)
|
r.Post("/user/rename", handleUserRename)
|
||||||
r.Post("/user/delete", handleUserDelete)
|
r.Post("/user/delete", handleUserDelete)
|
||||||
|
|||||||
@@ -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`)
|
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
|
mit `last_activity = created_at`. Nicht idempotent — die `ALTER TABLE`-Zeilen nur
|
||||||
einmalig ausführen.
|
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.
|
||||||
|
|||||||
@@ -111,8 +111,10 @@ func handleUserRename(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// handleUserDelete löscht den eingeloggten Account dauerhaft, nach Bestätigung
|
// handleUserDelete löscht den eingeloggten Account dauerhaft, nach Bestätigung
|
||||||
// durch das Passwort (Formularfeld pass1). Beiträge, Votes und Sessions werden
|
// durch das Passwort (Formularfeld pass1). Sessions und Votes werden in einer
|
||||||
// in einer Transaktion mitgelöscht; zugehörige Mediendateien best effort entfernt.
|
// 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) {
|
func handleUserDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
uid := uidFromContext(r.Context())
|
uid := uidFromContext(r.Context())
|
||||||
|
|
||||||
@@ -127,7 +129,7 @@ func handleUserDelete(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mediendateien der Beiträge einsammeln, bevor die Zeilen verschwinden.
|
// Mediendateien der Beiträge einsammeln, bevor die Pfade geleert werden.
|
||||||
var media []string
|
var media []string
|
||||||
if rows, err := db.Query(`SELECT filepath FROM entry WHERE uid = ?`, uid); err == nil {
|
if rows, err := db.Query(`SELECT filepath FROM entry WHERE uid = ?`, uid); err == nil {
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
@@ -147,7 +149,7 @@ func handleUserDelete(w http.ResponseWriter, r *http.Request) {
|
|||||||
for _, q := range []string{
|
for _, q := range []string{
|
||||||
`DELETE FROM session WHERE uid = ?`,
|
`DELETE FROM session WHERE uid = ?`,
|
||||||
`DELETE FROM vote 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 = ?`,
|
`DELETE FROM user WHERE uid = ?`,
|
||||||
} {
|
} {
|
||||||
if _, err := tx.Exec(q, uid); err != nil {
|
if _, err := tx.Exec(q, uid); err != nil {
|
||||||
|
|||||||
+14
-3
@@ -35,9 +35,20 @@ async function load() {
|
|||||||
focus.innerHTML = "";
|
focus.innerHTML = "";
|
||||||
focus.appendChild(renderEntry(data.entry));
|
focus.appendChild(renderEntry(data.entry));
|
||||||
|
|
||||||
// Reply-Box nur eingeloggt zeigen.
|
// Eingeloggt? Reply-Box zeigen; eigener, nicht gelöschter Beitrag -> Löschen-Button.
|
||||||
const h = await api.get("/auth/headerbar");
|
const me = await api.get("/user/info");
|
||||||
document.getElementById("reply-box").hidden = !h.loggedin;
|
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.
|
// Direkte Antworten.
|
||||||
const replies = document.getElementById("replies");
|
const replies = document.getElementById("replies");
|
||||||
|
|||||||
+13
-3
@@ -34,15 +34,25 @@ function renderEntry(e) {
|
|||||||
el.className = "card";
|
el.className = "card";
|
||||||
|
|
||||||
const when = new Date(e.created_at * 1000).toLocaleString("de-DE");
|
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 = `
|
||||||
|
<div class="muted">[deleted] · ${when}</div>
|
||||||
|
<div class="content">[deleted]</div>
|
||||||
|
<div class="muted"><a href="/e/${e.pid}">${replyLabel}</a></div>
|
||||||
|
`;
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
const user = encodeURIComponent(e.username);
|
const user = encodeURIComponent(e.username);
|
||||||
let media = "";
|
let media = "";
|
||||||
if (e.filepath) {
|
if (e.filepath) {
|
||||||
media = `<img src="/${e.filepath}" alt="" loading="lazy">`;
|
media = `<img src="/${e.filepath}" alt="" loading="lazy">`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const replyLabel =
|
|
||||||
e.reply_count === 1 ? "1 Antwort" : `${e.reply_count} Antworten`;
|
|
||||||
|
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
<div class="muted"><a href="/u/${user}">${escapeHtml(e.username)}</a> · ${when}</div>
|
<div class="muted"><a href="/u/${user}">${escapeHtml(e.username)}</a> · ${when}</div>
|
||||||
<div class="content">${linkify(e.content)}</div>
|
<div class="content">${linkify(e.content)}</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user