Bearbeiten: POST /entry/{pid}/edit (Inhalt ändern)

- handleEditEntry: Eigentümerprüfung, nicht-leer, content-only Update
- Bild und last_activity bleiben unverändert; gelöschte Posts nicht editierbar
- Frontend: Bearbeiten-Button + Inline-Formular auf der Focus-Seite

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 13:33:22 +02:00
parent cb3da3bb24
commit ad469ab366
4 changed files with 115 additions and 5 deletions
+48
View File
@@ -318,6 +318,54 @@ func TestThreading(t *testing.T) {
}
}
func TestEditEntry(t *testing.T) {
srv := newTestServer(t)
alice := registerAndLogin(t, srv, "alice")
bob := registerAndLogin(t, srv, "bob")
pid := createEntry(t, alice, srv, "original")
editURL := fmt.Sprintf("%s/entry/%d/edit", srv.URL, pid)
// Eigentümer bearbeitet -> 200, neuer Inhalt sichtbar.
resp := postForm(t, alice, editURL, url.Values{"content": {"korrigiert"}})
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("edit: erwartet 200, bekam %d", resp.StatusCode)
}
if tv := getThread(t, srv, pid); tv.Entry.Content != "korrigiert" {
t.Fatalf("inhalt nach edit: %q", tv.Entry.Content)
}
// Leerer Inhalt -> 400.
resp = postForm(t, alice, editURL, url.Values{"content": {" "}})
resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("leerer edit: erwartet 400, bekam %d", resp.StatusCode)
}
// Fremder -> 403.
resp = postForm(t, bob, editURL, url.Values{"content": {"hijack"}})
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("fremd-edit: erwartet 403, bekam %d", resp.StatusCode)
}
// Unbekannte pid -> 404.
resp = postForm(t, alice, srv.URL+"/entry/999999/edit", url.Values{"content": {"x"}})
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("edit unbekannt: erwartet 404, bekam %d", resp.StatusCode)
}
// Nach Soft-Delete ist der Beitrag nicht mehr bearbeitbar (uid=0) -> 403.
postForm(t, alice, fmt.Sprintf("%s/entry/%d/delete", srv.URL, pid), url.Values{}).Body.Close()
resp = postForm(t, alice, editURL, url.Values{"content": {"wieder da"}})
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("edit nach delete: erwartet 403, bekam %d", resp.StatusCode)
}
}
func TestSoftDelete(t *testing.T) {
srv := newTestServer(t)
alice := registerAndLogin(t, srv, "alice")
+37
View File
@@ -264,6 +264,43 @@ func handleCreateEntry(w http.ResponseWriter, r *http.Request) {
})
}
// handleEditEntry ändert den Inhalt eines eigenen Beitrags. Das angehängte Bild
// bleibt unverändert; last_activity wird nicht angefasst (eine Korrektur soll den
// Thread nicht nach oben spülen).
func handleEditEntry(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
if err := db.QueryRow(`SELECT uid FROM entry WHERE pid = ?`, pid).Scan(&owner); err != nil {
writeError(w, http.StatusNotFound, "Beitrag nicht gefunden")
return
}
if owner != uid {
writeError(w, http.StatusForbidden, "Das ist nicht dein Beitrag.")
return
}
content := strings.TrimSpace(r.FormValue("content"))
if content == "" {
writeError(w, http.StatusBadRequest, "Inhalt darf nicht leer sein.")
return
}
if len(content) > 1000 {
content = content[:1000]
}
if _, err := db.Exec(`UPDATE entry SET content = ? WHERE pid = ?`, content, pid); err != nil {
writeError(w, http.StatusInternalServerError, "Bearbeiten fehlgeschlagen")
return
}
writeJSON(w, http.StatusOK, map[string]any{"pid": pid, "content": content})
}
// handleDeleteEntry löscht einen eigenen Beitrag "weich": die Zeile bleibt als
// [deleted]-Platzhalter erhalten (damit Antworten nicht verwaisen), Inhalt, Bild
// und Autor werden entfernt.
+1
View File
@@ -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}/edit", handleEditEntry)
r.Post("/entry/{pid}/delete", handleDeleteEntry)
r.Get("/user/info", handleUserInfo)
r.Post("/user/rename", handleUserRename)
+29 -5
View File
@@ -35,19 +35,43 @@ async function load() {
focus.innerHTML = "";
focus.appendChild(renderEntry(data.entry));
// Eingeloggt? Reply-Box zeigen; eigener, nicht gelöschter Beitrag -> Löschen-Button.
// Eingeloggt? Reply-Box zeigen; eigener, nicht gelöschter Beitrag -> Bearbeiten/Löschen.
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 () => {
const editBtn = document.createElement("button");
editBtn.textContent = "Bearbeiten";
editBtn.addEventListener("click", () => {
editBtn.disabled = true;
const form = document.createElement("form");
form.innerHTML = `
<textarea name="content" rows="4" maxlength="1000"></textarea>
<button type="submit">Speichern</button>
<button type="button" class="cancel">Abbrechen</button>
<p class="msg"></p>`;
form.querySelector("textarea").value = data.entry.content;
form.addEventListener("submit", async (ev) => {
ev.preventDefault();
const res = await api.post(`/entry/${pid}/edit`, new FormData(form));
if (res.ok) load();
else form.querySelector(".msg").textContent =
res.data.error || "Bearbeiten fehlgeschlagen.";
});
form.querySelector(".cancel").addEventListener("click", load);
focus.appendChild(form);
});
const delBtn = document.createElement("button");
delBtn.textContent = "Löschen";
delBtn.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);
focus.appendChild(editBtn);
focus.appendChild(delBtn);
}
// Direkte Antworten.