From 030a5bd943c6e4a89f961bc6b245ceee38094d24 Mon Sep 17 00:00:00 2001 From: irrlicht Date: Sat, 26 Sep 2026 23:10:02 +0200 Subject: [PATCH] Moderation entfernt, JSON-API unter /api MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Moderationsseite und Melde-Funktion (report) komplett ausgebaut; folgt als eigenständiges Projekt - alle API-Endpunkte unter /api, dort ausschließlich JSON: auch 404, 405, Rate-Limit (429) und Panics (500) - Fehler nur über HTTP-Status; stille DB-Fehler in stats, logout und Vote-Zählern liefern jetzt 500 statt Nullen - /auth/headerbar entfernt (Frontend nutzt /api/user/info) - Frontend auf /api und statusbasierte Auswertung umgestellt - notes/api.md neu als Referenz der aktuellen API Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q --- auth.go | 14 +-- db.go | 15 --- endpoints_test.go | 176 +++++++++++++++++------------ entry.go | 81 +++----------- errors.go | 41 ++++--- importsqlite.go | 1 - kver.env.example | 6 - main.go | 140 ++++++++++++++--------- middleware.go | 2 +- moderation.go | 147 ------------------------- notes/api.md | 275 +++++++++++++++------------------------------- notes/deploy.md | 2 +- notes/todo.md | 3 +- stats.go | 69 ++++++++---- user.go | 10 +- web/css/app.css | 46 -------- web/js/app.js | 7 +- web/js/entry.js | 11 +- web/js/feed.js | 88 ++++++--------- web/js/stats.js | 12 +- web/js/user.js | 10 +- 21 files changed, 430 insertions(+), 726 deletions(-) delete mode 100644 moderation.go diff --git a/auth.go b/auth.go index 43ad3f7..0516759 100644 --- a/auth.go +++ b/auth.go @@ -133,9 +133,13 @@ func startSession(w http.ResponseWriter, r *http.Request, uid int64) error { return nil } -func handleLogout(w http.ResponseWriter, r *http.Request) { +func handleLogout(w http.ResponseWriter, r *http.Request) error { + // Scheitert das Löschen, bliebe die Session serverseitig gültig -- das + // muss der Client erfahren, statt ein "ok" zu sehen. if s, ok := getSession(r); ok { - db.Exec(`DELETE FROM session WHERE value = $1`, s.Value) + if _, err := db.Exec(`DELETE FROM session WHERE value = $1`, s.Value); err != nil { + return Internal(err) + } } http.SetCookie(w, &http.Cookie{ Name: "session", @@ -148,6 +152,7 @@ func handleLogout(w http.ResponseWriter, r *http.Request) { SameSite: http.SameSiteLaxMode, }) writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) + return nil } // handleNewUser sammelt alle Validierungsfehler ein, statt beim ersten @@ -210,11 +215,6 @@ func handleSessionInfo(w http.ResponseWriter, r *http.Request) error { return nil } -func handleHeaderbar(w http.ResponseWriter, r *http.Request) { - _, ok := getSession(r) - writeJSON(w, http.StatusOK, map[string]bool{"loggedin": ok}) -} - func sanitizeUsername(u string) string { u = strings.TrimSpace(u) // Auf 32 Zeichen (Runen) kürzen, nicht 32 Bytes -- sonst kann der Schnitt diff --git a/db.go b/db.go index ad06445..f6097d6 100644 --- a/db.go +++ b/db.go @@ -71,20 +71,6 @@ CREATE TABLE IF NOT EXISTS impression ( PRIMARY KEY (day, anon_ip, path) ); --- Meldungen zu Beiträgen (DSA-Melde-/Abhilfeverfahren). Bewusst ohne Auth: --- jeder darf melden, weil es eine Moderationshilfe ist. uid ist der Melder, --- vorerst immer 0 (anonym) -- die Spalte existiert nur, um bei Spam später den --- Absender nachziehen zu können, ohne das Schema zu ändern. handled: 0 offen, --- 1 von der Moderation erledigt. reason ist ein optionaler Freitext. -CREATE TABLE IF NOT EXISTS report ( - id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - pid BIGINT NOT NULL, - uid BIGINT NOT NULL DEFAULT 0, - reason TEXT NOT NULL DEFAULT '', - created_at BIGINT NOT NULL, - handled BIGINT NOT NULL DEFAULT 0 -); - -- Indizes für die häufigen Zugriffspfade; ohne sie werden Feed (ORDER BY -- last_activity), Thread (WHERE reply_to) und Vote-Zähler (WHERE pid) mit -- wachsender Tabelle zu Full-Table-Scans. username/session.value/vote(uid,pid) @@ -94,7 +80,6 @@ CREATE INDEX IF NOT EXISTS idx_entry_reply_to ON entry(reply_to); CREATE INDEX IF NOT EXISTS idx_entry_uid ON entry(uid); CREATE INDEX IF NOT EXISTS idx_vote_pid ON vote(pid); CREATE INDEX IF NOT EXISTS idx_session_uid ON session(uid); -CREATE INDEX IF NOT EXISTS idx_report_open ON report(handled, created_at); ` func initDB(dsn string) error { diff --git a/endpoints_test.go b/endpoints_test.go index 4a90c1b..3764dba 100644 --- a/endpoints_test.go +++ b/endpoints_test.go @@ -84,7 +84,7 @@ func registerAndLogin(t *testing.T, srv *httptest.Server, username string) *http t.Helper() c := newClient(t) - resp := postForm(t, c, srv.URL+"/auth/newuser", url.Values{ + resp := postForm(t, c, srv.URL+"/api/auth/newuser", url.Values{ "user": {username}, "pass1": {"supersecret1"}, "pass2": {"supersecret1"}, }) resp.Body.Close() @@ -92,7 +92,7 @@ func registerAndLogin(t *testing.T, srv *httptest.Server, username string) *http t.Fatalf("register %s: status %d", username, resp.StatusCode) } - resp = postForm(t, c, srv.URL+"/auth/login", url.Values{ + resp = postForm(t, c, srv.URL+"/api/auth/login", url.Values{ "user": {username}, "pass": {"supersecret1"}, "timeout": {"86400"}, }) resp.Body.Close() @@ -105,7 +105,7 @@ func registerAndLogin(t *testing.T, srv *httptest.Server, username string) *http // createEntry erstellt einen Beitrag und liefert dessen pid. func createEntry(t *testing.T, c *http.Client, srv *httptest.Server, content string) int64 { t.Helper() - resp := postForm(t, c, srv.URL+"/entry/create", url.Values{"content": {content}}) + resp := postForm(t, c, srv.URL+"/api/entry/create", url.Values{"content": {content}}) defer resp.Body.Close() if resp.StatusCode != http.StatusCreated { t.Fatalf("create entry: status %d", resp.StatusCode) @@ -119,44 +119,34 @@ func createEntry(t *testing.T, c *http.Client, srv *httptest.Server, content str return out.PID } -func TestRegisterLoginHeaderbar(t *testing.T) { +func TestRegisterLoginSession(t *testing.T) { srv := newTestServer(t) c := registerAndLogin(t, srv, "alice") - resp, err := c.Get(srv.URL + "/auth/headerbar") + if got := getStatus(t, c, srv.URL+"/api/auth/sessioninfo"); got != http.StatusOK { + t.Fatalf("sessioninfo nach Login: erwartet 200, bekam %d", got) + } + // Anonymer Client hat keine Session. + if got := getStatus(t, newClient(t), srv.URL+"/api/auth/sessioninfo"); got != http.StatusUnauthorized { + t.Fatalf("sessioninfo anonym: erwartet 401, bekam %d", got) + } +} + +// getStatus macht einen GET und liefert nur den Statuscode. +func getStatus(t *testing.T, c *http.Client, urlStr string) int { + t.Helper() + resp, err := c.Get(urlStr) if err != nil { t.Fatal(err) } - defer resp.Body.Close() - - var h struct { - LoggedIn bool `json:"loggedin"` - } - json.NewDecoder(resp.Body).Decode(&h) - if !h.LoggedIn { - t.Fatal("erwartete loggedin=true nach Login") - } - - // Anonymer Client ist nicht eingeloggt. - anon := newClient(t) - resp2, err := anon.Get(srv.URL + "/auth/headerbar") - if err != nil { - t.Fatal(err) - } - defer resp2.Body.Close() - var h2 struct { - LoggedIn bool `json:"loggedin"` - } - json.NewDecoder(resp2.Body).Decode(&h2) - if h2.LoggedIn { - t.Fatal("anonymer Client sollte nicht eingeloggt sein") - } + resp.Body.Close() + return resp.StatusCode } func TestFeedEmptyThenPopulated(t *testing.T) { srv := newTestServer(t) - resp, err := http.Get(srv.URL + "/entry/feed/0") + resp, err := http.Get(srv.URL + "/api/entry/feed/0") if err != nil { t.Fatal(err) } @@ -170,7 +160,7 @@ func TestFeedEmptyThenPopulated(t *testing.T) { alice := registerAndLogin(t, srv, "alice") createEntry(t, alice, srv, "hallo welt") - resp, err = http.Get(srv.URL + "/entry/feed/0") + resp, err = http.Get(srv.URL + "/api/entry/feed/0") if err != nil { t.Fatal(err) } @@ -189,7 +179,7 @@ func TestCreateEntryRequiresAuth(t *testing.T) { srv := newTestServer(t) anon := newClient(t) - resp := postForm(t, anon, srv.URL+"/entry/create", url.Values{"content": {"x"}}) + resp := postForm(t, anon, srv.URL+"/api/entry/create", url.Values{"content": {"x"}}) resp.Body.Close() if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("create ohne Login: erwartet 401, bekam %d", resp.StatusCode) @@ -209,7 +199,7 @@ func TestVoting(t *testing.T) { pid := createEntry(t, alice, srv, "vote me") vote := func(c *http.Client, mode string) tally { - resp := postForm(t, c, fmt.Sprintf("%s/entry/%d/vote", srv.URL, pid), url.Values{"mode": {mode}}) + resp := postForm(t, c, fmt.Sprintf("%s/api/entry/%d/vote", srv.URL, pid), url.Values{"mode": {mode}}) defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("vote %s: status %d", mode, resp.StatusCode) @@ -237,7 +227,7 @@ func TestVoteRequiresAuthAndValidMode(t *testing.T) { srv := newTestServer(t) alice := registerAndLogin(t, srv, "alice") pid := createEntry(t, alice, srv, "x") - voteURL := fmt.Sprintf("%s/entry/%d/vote", srv.URL, pid) + voteURL := fmt.Sprintf("%s/api/entry/%d/vote", srv.URL, pid) anon := newClient(t) resp := postForm(t, anon, voteURL, url.Values{"mode": {"left"}}) @@ -258,7 +248,7 @@ func TestBump(t *testing.T) { alice := registerAndLogin(t, srv, "alice") bob := registerAndLogin(t, srv, "bob") pid := createEntry(t, alice, srv, "bump me") - bumpURL := fmt.Sprintf("%s/entry/%d/bump", srv.URL, pid) + bumpURL := fmt.Sprintf("%s/api/entry/%d/bump", srv.URL, pid) // Anonym: 401. resp := postForm(t, newClient(t), bumpURL, url.Values{}) @@ -292,9 +282,9 @@ func TestBump(t *testing.T) { // Gelöschter Beitrag lässt sich nicht bumpen. del := createEntry(t, alice, srv, "weg gleich") - resp = postForm(t, alice, fmt.Sprintf("%s/entry/%d/delete", srv.URL, del), url.Values{}) + resp = postForm(t, alice, fmt.Sprintf("%s/api/entry/%d/delete", srv.URL, del), url.Values{}) resp.Body.Close() - resp = postForm(t, alice, fmt.Sprintf("%s/entry/%d/bump", srv.URL, del), url.Values{}) + resp = postForm(t, alice, fmt.Sprintf("%s/api/entry/%d/bump", srv.URL, del), url.Values{}) resp.Body.Close() if resp.StatusCode != http.StatusNotFound { t.Fatalf("bump gelöscht: erwartet 404, bekam %d", resp.StatusCode) @@ -304,7 +294,7 @@ func TestBump(t *testing.T) { // createReply erstellt eine Antwort auf parentPID und liefert deren pid. func createReply(t *testing.T, c *http.Client, srv *httptest.Server, content string, parentPID int64) int64 { t.Helper() - resp := postForm(t, c, srv.URL+"/entry/create", url.Values{ + resp := postForm(t, c, srv.URL+"/api/entry/create", url.Values{ "content": {content}, "reply_to": {fmt.Sprint(parentPID)}, }) defer resp.Body.Close() @@ -336,7 +326,7 @@ type threadJSON struct { func getThread(t *testing.T, srv *httptest.Server, pid int64) threadJSON { t.Helper() - resp, err := http.Get(fmt.Sprintf("%s/entry/%d/thread", srv.URL, pid)) + resp, err := http.Get(fmt.Sprintf("%s/api/entry/%d/thread", srv.URL, pid)) if err != nil { t.Fatal(err) } @@ -378,7 +368,7 @@ func TestThreading(t *testing.T) { } // Hauptfeed: nur der Root, keine Antworten. - resp, _ := http.Get(srv.URL + "/entry/feed/0") + resp, _ := http.Get(srv.URL + "/api/entry/feed/0") var feed []struct { PID int64 `json:"pid"` } @@ -389,7 +379,7 @@ func TestThreading(t *testing.T) { } // Profil-Feed: alle drei Beiträge (Root + Antworten). - resp, _ = http.Get(srv.URL + "/u/alice/feed/0") + resp, _ = http.Get(srv.URL + "/api/u/alice/feed/0") var profile []struct { PID int64 `json:"pid"` } @@ -406,7 +396,7 @@ func TestEditEntry(t *testing.T) { bob := registerAndLogin(t, srv, "bob") pid := createEntry(t, alice, srv, "original") - editURL := fmt.Sprintf("%s/entry/%d/edit", srv.URL, pid) + editURL := fmt.Sprintf("%s/api/entry/%d/edit", srv.URL, pid) // Eigentümer bearbeitet -> 200, neuer Inhalt sichtbar. resp := postForm(t, alice, editURL, url.Values{"content": {"korrigiert"}}) @@ -433,14 +423,14 @@ func TestEditEntry(t *testing.T) { } // Unbekannte pid -> 404. - resp = postForm(t, alice, srv.URL+"/entry/999999/edit", url.Values{"content": {"x"}}) + resp = postForm(t, alice, srv.URL+"/api/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() + postForm(t, alice, fmt.Sprintf("%s/api/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 { @@ -456,7 +446,7 @@ func TestSoftDelete(t *testing.T) { root := createEntry(t, alice, srv, "wurzel") reply := createReply(t, alice, srv, "antwort", root) - delURL := fmt.Sprintf("%s/entry/%d/delete", srv.URL, root) + delURL := fmt.Sprintf("%s/api/entry/%d/delete", srv.URL, root) // Fremder darf nicht löschen. resp := postForm(t, bob, delURL, url.Values{}) @@ -482,7 +472,7 @@ func TestSoftDelete(t *testing.T) { } // Der gelöschte Root erscheint weiterhin im Hauptfeed (Thread bleibt erreichbar). - feedResp, _ := http.Get(srv.URL + "/entry/feed/0") + feedResp, _ := http.Get(srv.URL + "/api/entry/feed/0") var feed []threadEntry json.NewDecoder(feedResp.Body).Decode(&feed) feedResp.Body.Close() @@ -491,7 +481,7 @@ func TestSoftDelete(t *testing.T) { } // Nicht existierender Beitrag -> 404. - resp = postForm(t, alice, srv.URL+"/entry/999999/delete", url.Values{}) + resp = postForm(t, alice, srv.URL+"/api/entry/999999/delete", url.Values{}) resp.Body.Close() if resp.StatusCode != http.StatusNotFound { t.Fatalf("delete unbekannt: erwartet 404, bekam %d", resp.StatusCode) @@ -504,21 +494,21 @@ func TestRenameUser(t *testing.T) { registerAndLogin(t, srv, "bob") // Zu kurz -> 400. - resp := postForm(t, alice, srv.URL+"/user/rename", url.Values{"user": {"ab"}}) + resp := postForm(t, alice, srv.URL+"/api/user/rename", url.Values{"user": {"ab"}}) resp.Body.Close() if resp.StatusCode != http.StatusBadRequest { t.Fatalf("zu kurz: erwartet 400, bekam %d", resp.StatusCode) } // Bereits vergeben -> 409. - resp = postForm(t, alice, srv.URL+"/user/rename", url.Values{"user": {"bob"}}) + resp = postForm(t, alice, srv.URL+"/api/user/rename", url.Values{"user": {"bob"}}) resp.Body.Close() if resp.StatusCode != http.StatusConflict { t.Fatalf("vergeben: erwartet 409, bekam %d", resp.StatusCode) } // Gültig -> 200 + neuer Name. - resp = postForm(t, alice, srv.URL+"/user/rename", url.Values{"user": {"alice2"}}) + resp = postForm(t, alice, srv.URL+"/api/user/rename", url.Values{"user": {"alice2"}}) defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("rename: erwartet 200, bekam %d", resp.StatusCode) @@ -532,12 +522,12 @@ func TestRenameUser(t *testing.T) { } // Profil unter dem neuen Namen erreichbar, alter Name weg. - r2, _ := http.Get(srv.URL + "/u/alice2/info") + r2, _ := http.Get(srv.URL + "/api/u/alice2/info") r2.Body.Close() if r2.StatusCode != http.StatusOK { t.Fatalf("/u/alice2/info: erwartet 200, bekam %d", r2.StatusCode) } - r3, _ := http.Get(srv.URL + "/u/alice/info") + r3, _ := http.Get(srv.URL + "/api/u/alice/info") r3.Body.Close() if r3.StatusCode != http.StatusNotFound { t.Fatalf("/u/alice/info: erwartet 404, bekam %d", r3.StatusCode) @@ -554,14 +544,14 @@ func TestDeleteUser(t *testing.T) { reply := createReply(t, bob, srv, "bobs antwort", root) // Falsches Passwort -> 403. - resp := postForm(t, alice, srv.URL+"/user/delete", url.Values{"pass1": {"falsch"}}) + resp := postForm(t, alice, srv.URL+"/api/user/delete", url.Values{"pass1": {"falsch"}}) resp.Body.Close() if resp.StatusCode != http.StatusForbidden { t.Fatalf("falsches Passwort: erwartet 403, bekam %d", resp.StatusCode) } // Richtiges Passwort -> 200. - resp = postForm(t, alice, srv.URL+"/user/delete", url.Values{"pass1": {"supersecret1"}}) + resp = postForm(t, alice, srv.URL+"/api/user/delete", url.Values{"pass1": {"supersecret1"}}) resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("delete: erwartet 200, bekam %d", resp.StatusCode) @@ -569,7 +559,7 @@ func TestDeleteUser(t *testing.T) { // Login danach schlägt fehl. fresh := newClient(t) - resp = postForm(t, fresh, srv.URL+"/auth/login", url.Values{ + resp = postForm(t, fresh, srv.URL+"/api/auth/login", url.Values{ "user": {"alice"}, "pass": {"supersecret1"}, "timeout": {"86400"}, }) resp.Body.Close() @@ -613,34 +603,74 @@ func TestLogoutPost(t *testing.T) { srv := newTestServer(t) c := registerAndLogin(t, srv, "alice") - resp, err := c.Get(srv.URL + "/auth/logout") - if err != nil { - t.Fatal(err) + if got := getStatus(t, c, srv.URL+"/api/auth/logout"); got != http.StatusMethodNotAllowed { + t.Fatalf("GET logout: erwartet 405, bekam %d", got) } - resp.Body.Close() - // GET fällt in den FileServer-Catch-All -> 404. Hauptsache: kein Logout. - if resp.StatusCode != http.StatusNotFound { - t.Fatalf("GET logout: erwartet 404, bekam %d", resp.StatusCode) + if got := getStatus(t, c, srv.URL+"/api/auth/sessioninfo"); got != http.StatusOK { + t.Fatalf("GET logout darf nicht ausloggen, sessioninfo: %d", got) } - resp = postForm(t, c, srv.URL+"/auth/logout", url.Values{}) + resp := postForm(t, c, srv.URL+"/api/auth/logout", url.Values{}) resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("POST logout: erwartet 200, bekam %d", resp.StatusCode) } + if got := getStatus(t, c, srv.URL+"/api/auth/sessioninfo"); got != http.StatusUnauthorized { + t.Fatalf("nach Logout: erwartet 401, bekam %d", got) + } +} - resp, err = c.Get(srv.URL + "/auth/headerbar") +// TestAPIErrorsAreJSON prüft, dass auch Fehler außerhalb der Handler (unbekannter +// Pfad, falsche Methode, Rate-Limit, Panic) als JSON mit passendem Status +// hinausgehen. +func TestAPIErrorsAreJSON(t *testing.T) { + srv := newTestServer(t) + + check := func(name string, resp *http.Response, wantStatus int, wantCode string) { + t.Helper() + defer resp.Body.Close() + if resp.StatusCode != wantStatus { + t.Fatalf("%s: erwartet %d, bekam %d", name, wantStatus, resp.StatusCode) + } + if ct := resp.Header.Get("Content-Type"); ct != "application/json" { + t.Fatalf("%s: Content-Type %q", name, ct) + } + var body struct { + Error apiErrorBody `json:"error"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil || body.Error.Code != wantCode { + t.Fatalf("%s: erwartet code %q, bekam %+v (%v)", name, wantCode, body, err) + } + } + + resp, err := http.Get(srv.URL + "/api/gibtsnicht") if err != nil { t.Fatal(err) } - defer resp.Body.Close() - var h struct { - LoggedIn bool `json:"loggedin"` + check("unbekannter Pfad", resp, http.StatusNotFound, "api.not_found") + + resp, err = http.Get(srv.URL + "/api/entry/create") + if err != nil { + t.Fatal(err) } - json.NewDecoder(resp.Body).Decode(&h) - if h.LoggedIn { - t.Fatal("nach Logout sollte loggedin=false sein") + check("falsche Methode", resp, http.StatusMethodNotAllowed, "api.method_not_allowed") + + limited := limitByIP(1, time.Minute)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {})) + for i, want := range []int{http.StatusOK, http.StatusTooManyRequests} { + rec := httptest.NewRecorder() + limited.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/", nil)) + if rec.Code != want { + t.Fatalf("rate-limit Anfrage %d: erwartet %d, bekam %d", i+1, want, rec.Code) + } + if want == http.StatusTooManyRequests { + check("rate-limit", rec.Result(), want, "rate.limited") + } } + + rec := httptest.NewRecorder() + recoverJSON(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { panic("kaputt") })). + ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + check("panic", rec.Result(), http.StatusInternalServerError, "internal") } // TestCrossOriginPostRejected prüft den CSRF-Schutz: POSTs mit fremdem @@ -650,7 +680,7 @@ func TestCrossOriginPostRejected(t *testing.T) { c := registerAndLogin(t, srv, "alice") post := func(origin string) int { - req, err := http.NewRequest(http.MethodPost, srv.URL+"/entry/create", + req, err := http.NewRequest(http.MethodPost, srv.URL+"/api/entry/create", strings.NewReader(url.Values{"content": {"x"}}.Encode())) if err != nil { t.Fatal(err) @@ -706,13 +736,13 @@ func TestBlockTOREntry(t *testing.T) { srv := newTestServer(t) c := registerAndLogin(t, srv, "toruser") - resp := postFormFrom(t, c, srv.URL+"/entry/create", "10.89.1.2", url.Values{"content": {"aus tor"}}) + resp := postFormFrom(t, c, srv.URL+"/api/entry/create", "10.89.1.2", url.Values{"content": {"aus tor"}}) resp.Body.Close() if resp.StatusCode != http.StatusForbidden { t.Fatalf("TOR-Beitrag: erwartet 403, bekam %d", resp.StatusCode) } - resp = postFormFrom(t, c, srv.URL+"/entry/create", "203.0.113.7", url.Values{"content": {"normal"}}) + resp = postFormFrom(t, c, srv.URL+"/api/entry/create", "203.0.113.7", url.Values{"content": {"normal"}}) resp.Body.Close() if resp.StatusCode != http.StatusCreated { t.Fatalf("Nicht-TOR-Beitrag: erwartet 201, bekam %d", resp.StatusCode) diff --git a/entry.go b/entry.go index 805c895..2e530fe 100644 --- a/entry.go +++ b/entry.go @@ -9,7 +9,6 @@ import ( "image/jpeg" _ "image/png" "io" - "log" "math/rand/v2" "mime/multipart" "net/http" @@ -480,48 +479,26 @@ func handleBump(w http.ResponseWriter, r *http.Request) error { return nil } -// 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) { - selected = "none" - if uid != 0 { - var cur string - if err := db.QueryRow(`SELECT mode FROM vote WHERE uid = $1 AND pid = $2`, uid, pid).Scan(&cur); err == nil { - selected = cur - } +// writeTally schreibt die Zähler und die eigene Auswahl (selected) für einen +// Beitrag. Ist uid == 0 (nicht eingeloggt), ist selected immer "none". +func writeTally(w http.ResponseWriter, pid, uid int64) error { + var left, right int64 + var selected string + if err := db.QueryRow( + `SELECT COUNT(*) FILTER (WHERE mode = 'left'), + COUNT(*) FILTER (WHERE mode = 'right'), + COALESCE(MAX(mode) FILTER (WHERE uid = $2), 'none') + FROM vote WHERE pid = $1`, pid, uid). + Scan(&left, &right, &selected); err != nil { + return Internal(err) } - // Beide Zähler in einem Query statt zwei COUNTs. - rows, err := db.Query(`SELECT mode, COUNT(*) FROM vote WHERE pid = $1 GROUP BY mode`, pid) - if err != nil { - log.Printf("voteTally pid=%d: %v", pid, err) - return - } - defer rows.Close() - for rows.Next() { - var mode string - var n int - if err := rows.Scan(&mode, &n); err != nil { - log.Printf("voteTally pid=%d: %v", pid, err) - return - } - switch mode { - case "left": - left = n - case "right": - right = n - } - } - return -} - -func writeTally(w http.ResponseWriter, pid, uid int64) { - left, right, selected := voteTally(pid, uid) writeJSON(w, http.StatusOK, map[string]any{ "pid": pid, "left": left, "right": right, "selected": selected, }) + return nil } // handleVotes liest den Abstimmungsstand eines Beitrags (read-only, öffentlich). @@ -535,34 +512,7 @@ func handleVotes(w http.ResponseWriter, r *http.Request) error { if s, ok := getSession(r); ok { uid = s.UID } - writeTally(w, pid, uid) - return nil -} - -// handleReport nimmt eine Meldung zu einem Beitrag entgegen (DSA-Melde- -// verfahren). Bewusst ohne Auth -- jeder darf melden, weil es eine -// Moderationshilfe ist. Die Melder-uid wird vorerst nicht gespeichert (immer -// 0); nur der optionale Grund landet in der DB. Die Route ist per IP -// rate-limited (siehe main.go), damit der offene Endpunkt kein Spam-Kanal wird. -func handleReport(w http.ResponseWriter, r *http.Request) error { - pid, err := pidParam(r) - if err != nil { - return err - } - // Nur existierende Beiträge melden, sonst entstehen Waisen-Meldungen. - if _, err := entryByPID(pid); err != nil { - return errEntryNotFound - } - reason := truncateRunes(strings.TrimSpace(r.FormValue("reason")), 500) - if _, err := db.Exec( - `INSERT INTO report (pid, uid, reason, created_at, handled) VALUES ($1, 0, $2, $3, 0)`, - pid, reason, time.Now().Unix(), - ); err != nil { - return Internal(err) - } - - writeJSON(w, http.StatusOK, map[string]any{"ok": true}) - return nil + return writeTally(w, pid, uid) } // handleVote gibt die Stimme des angemeldeten Nutzers ab oder schaltet sie um: @@ -604,8 +554,7 @@ func handleVote(w http.ResponseWriter, r *http.Request) error { } } - writeTally(w, pid, uid) - return nil + return writeTally(w, pid, uid) } const ( diff --git a/errors.go b/errors.go index 7941bfb..5a08c3b 100644 --- a/errors.go +++ b/errors.go @@ -10,23 +10,22 @@ import ( // Fehler-Aufrufstelle verklebt waren -- wie schwer der Fehler ist (Kind), // was genau passiert ist (Code) und was der Nutzer lesen soll (Msg). // -// Der Sinn der Trennung: derselbe Fehler muss künftig von zwei Adaptern -// unterschiedlich dargestellt werden. Der HTML-Pfad braucht Msg und Field, um -// ein Formular mit Fehlermarkierung neu zu rendern; ein API-Client braucht -// Code, um ohne Parsen deutscher Strings reagieren zu können. +// Der Sinn der Trennung: das Frontend zeigt Msg an und markiert Field, ein +// Client braucht Code, um ohne Parsen deutscher Strings reagieren zu können, +// und der HTTP-Status ergibt sich allein aus Kind. type Kind uint8 const ( - KindInternal Kind = iota // Bug oder Infrastruktur -- Ursache nie nach außen - KindInvalid // Eingabe passt nicht - KindUnauth // nicht angemeldet - KindForbidden // angemeldet, aber nicht erlaubt - KindNotFound // Objekt existiert nicht (oder soll nicht sichtbar sein) - KindConflict // Name vergeben, Doppel-Post - KindTooLarge // Upload über dem Limit - KindRateLimited // zu viele Anfragen - KindUnavailable // Feature nicht konfiguriert + KindInternal Kind = iota // Bug oder Infrastruktur -- Ursache nie nach außen + KindInvalid // Eingabe passt nicht + KindUnauth // nicht angemeldet + KindForbidden // angemeldet, aber nicht erlaubt + KindNotFound // Objekt existiert nicht (oder soll nicht sichtbar sein) + KindConflict // Name vergeben, Doppel-Post + KindTooLarge // Upload über dem Limit + KindRateLimited // zu viele Anfragen + KindMethodNotAllowed // Route existiert, aber nicht mit dieser Methode ) // Error ist der Fehlertyp, den die Service-Schicht nach oben gibt. Kind ist @@ -56,7 +55,9 @@ func NotFound(code, msg string) *Error { return &Error{Kind: KindNotFound, Co func Conflict(code, msg string) *Error { return &Error{Kind: KindConflict, Code: code, Msg: msg} } func TooLarge(code, msg string) *Error { return &Error{Kind: KindTooLarge, Code: code, Msg: msg} } func RateLimited(code, msg string) *Error { return &Error{Kind: KindRateLimited, Code: code, Msg: msg} } -func Unavailable(code, msg string) *Error { return &Error{Kind: KindUnavailable, Code: code, Msg: msg} } +func MethodNotAllowed(code, msg string) *Error { + return &Error{Kind: KindMethodNotAllowed, Code: code, Msg: msg} +} // Internal verschluckt die Ursache absichtlich: der Nutzer sieht nie einen // DB- oder Dateisystemfehler (der Interna und Pfade verraten würde), das Log @@ -102,9 +103,7 @@ func asError(err error) *Error { return Internal(err) } -// statusOf bildet Kind auf HTTP ab. Beide HTTP-Adapter (HTML und JSON) teilen -// sich diese Zuordnung -- unterschiedliche Statuscodes für denselben Fehler -// wären genau die Divergenz, die die Taxonomie verhindern soll. +// statusOf bildet Kind auf HTTP ab -- die einzige Stelle, an der das passiert. func statusOf(k Kind) int { switch k { case KindInvalid: @@ -121,17 +120,15 @@ func statusOf(k Kind) int { return http.StatusRequestEntityTooLarge case KindRateLimited: return http.StatusTooManyRequests - case KindUnavailable: - return http.StatusServiceUnavailable + case KindMethodNotAllowed: + return http.StatusMethodNotAllowed default: return http.StatusInternalServerError } } // logInternal protokolliert nur echte Serverfehler -- Validierungs- und -// Rechtefehler sind normaler Betrieb und würden das Log zumüllen. Beide -// Adapter rufen das auf, damit ein Fehler unabhängig vom Ausgabeformat -// genau einmal im Log landet. +// Rechtefehler sind normaler Betrieb und würden das Log zumüllen. func logInternal(e *Error) { if e.Kind != KindInternal { return diff --git a/importsqlite.go b/importsqlite.go index 97ae337..87bbab9 100644 --- a/importsqlite.go +++ b/importsqlite.go @@ -23,7 +23,6 @@ var importTables = []importTable{ "reply_count", "last_activity", "deleted", "bump_count", "last_bump"}, true}, {"vote", "vote", []string{"id", "uid", "pid", "mode"}, true}, {"impression", "impression", []string{"day", "anon_ip", "path", "asn", "hits"}, false}, - {"report", "report", []string{"id", "pid", "uid", "reason", "created_at", "handled"}, true}, } // importSQLite kopiert alle Daten aus der SQLite-Datei path in die (per initDB diff --git a/kver.env.example b/kver.env.example index 15f1910..b61075e 100644 --- a/kver.env.example +++ b/kver.env.example @@ -12,12 +12,6 @@ # Postgres-Verbindung. PFLICHT. Sonderzeichen im Passwort URL-kodieren. KVER_DSN=postgres://kver:passwort@host:5432/kontrollverlust?sslmode=disable -# Basic-Auth für die Moderationsseite /moderation. PFLICHT: ohne diese beiden -# Werte bleibt /moderation gesperrt (HTTP 503, fail-closed). Langes Zufalls- -# passwort verwenden. -KVER_MOD_USER=moderator -KVER_MOD_PASS=hier-ein-langes-zufallspasswort - # Optional: Pfad zur GeoLite2-ASN-DB im Container (leer = ASN-Auflösung aus). #KVER_GEOIP_ASN=/app/data/GeoLite2-ASN.mmdb diff --git a/main.go b/main.go index a0883af..72d3583 100644 --- a/main.go +++ b/main.go @@ -3,11 +3,13 @@ package main import ( "context" "encoding/json" + "fmt" "log" "net/http" "net/url" "os" "os/signal" + "runtime/debug" "syscall" "time" @@ -98,6 +100,10 @@ func envOr(key, fallback string) string { // routes baut den HTTP-Handler mit allen Endpunkten. In main() und in den // Tests identisch verwendet. +// +// Aufteilung: alles unter /api spricht ausschließlich JSON -- auch 404, 405, +// Rate-Limit und Panics. Der Rest liefert die HTML-Seiten und Dateien des +// Frontends. func routes() http.Handler { r := chi.NewRouter() // RealIP übernimmt X-Forwarded-For/X-Real-IP vom Reverse-Proxy, damit @@ -107,65 +113,65 @@ func routes() http.Handler { // spoofbar. r.Use(middleware.RealIP) r.Use(middleware.Logger) - r.Use(middleware.Recoverer) + r.Use(recoverJSON) r.Use(secHeaders) r.Use(checkOrigin) - // --- Auth (öffentlich) --- - // Login und Registrierung pro IP drosseln (Brute-Force-Bremse). bcrypt bremst - // zusätzlich, aber das Limit kappt automatisiertes Durchprobieren früh. - r.Group(func(r chi.Router) { - r.Use(httprate.LimitByIP(20, time.Minute)) - r.Post("/auth/login", api(handleLogin)) - r.Post("/auth/newuser", api(handleNewUser)) + r.Route("/api", func(r chi.Router) { + r.NotFound(api(func(http.ResponseWriter, *http.Request) error { + return NotFound("api.not_found", "Unbekannter Endpunkt.") + })) + r.MethodNotAllowed(api(func(http.ResponseWriter, *http.Request) error { + return MethodNotAllowed("api.method_not_allowed", "Methode nicht erlaubt.") + })) + + // --- Auth (öffentlich) --- + // Login und Registrierung pro IP drosseln (Brute-Force-Bremse). bcrypt + // bremst zusätzlich, aber das Limit kappt automatisiertes Durchprobieren + // früh. + r.Group(func(r chi.Router) { + r.Use(limitByIP(20, time.Minute)) + r.Post("/auth/login", api(handleLogin)) + r.Post("/auth/newuser", api(handleNewUser)) + }) + // Logout als POST: SameSite=Lax schickt Cookies bei Top-Level-GET- + // Navigationen mit -- als GET könnte eine fremde Seite Nutzer per Link + // ausloggen (CSRF). POST ist durch Lax + checkOrigin gedeckt. + r.Post("/auth/logout", api(handleLogout)) + r.Get("/auth/sessioninfo", api(handleSessionInfo)) + r.Get("/stats", api(handleStats)) + r.Get("/stats/detail", api(handleStatsDetail)) + + // --- Entries (öffentlich) --- + r.Get("/entry/feed/{page}", api(handleFeed)) + r.Get("/entry/{pid}/votes", api(handleVotes)) + r.Get("/entry/{pid}/thread", api(handleThread)) + + // --- User (öffentlich) --- + r.Get("/u/{username}/info", api(handleUserPage)) + r.Get("/u/{username}/feed/{page}", api(handleUserFeed)) + + // --- Geschützt (Session erforderlich) --- + r.Group(func(r chi.Router) { + r.Use(requireAuth) + r.With(blockTOR).Post("/entry/create", api(handleCreateEntry)) + r.Post("/entry/{pid}/vote", api(handleVote)) + r.Post("/entry/{pid}/bump", api(handleBump)) + r.Post("/entry/{pid}/edit", api(handleEditEntry)) + r.Post("/entry/{pid}/delete", api(handleDeleteEntry)) + r.Get("/user/info", api(handleUserInfo)) + r.Post("/user/rename", api(handleUserRename)) + r.Post("/user/avatar", api(handleSetAvatar)) + r.Post("/user/delete", api(handleUserDelete)) + }) }) - // Logout als POST: SameSite=Lax schickt Cookies bei Top-Level-GET- - // Navigationen mit -- als GET könnte eine fremde Seite Nutzer per Link - // ausloggen (CSRF). POST ist durch Lax + checkOrigin gedeckt. - r.Post("/auth/logout", handleLogout) - r.Get("/auth/sessioninfo", api(handleSessionInfo)) - r.Get("/auth/headerbar", handleHeaderbar) - r.Get("/stats", handleStats) - r.Get("/stats/detail", handleStatsDetail) - // Moderations-Queue (Meldungen). Basic-Auth direkt in der App (modBasicAuth), - // weil TOR-Zugriffe die Go-App direkt erreichen und NICHT über Caddy laufen -- - // ein rein proxy-seitiger Schutz hätte diese Lücke offen gelassen. Caddy - // erzwingt zusätzlich TLS für den Clearnet-Weg. - r.With(modBasicAuth).Get("/moderation", api(handleModeration)) - - // HTML-Seiten: mit Impression-Tracking (anonymisiert, siehe stats.go). Nur - // echte Seitenaufrufe, nicht API/Assets. + // --- HTML-Seiten --- + // Mit Impression-Tracking (anonymisiert, siehe stats.go). Nur echte + // Seitenaufrufe, nicht API/Assets. r.With(trackImpression).Get("/", serveIndex) - - // --- Entries --- - r.Get("/entry/feed/{page}", api(handleFeed)) - r.Get("/entry/{pid}/votes", api(handleVotes)) - r.Get("/entry/{pid}/thread", api(handleThread)) r.With(trackImpression).Get("/e/{pid}", serveEntryPage) - - // Melden: öffentlich (jeder darf melden, Moderationshilfe), aber pro IP - // gedrosselt, damit der auth-lose Endpunkt nicht zum Spam-Kanal wird. - r.With(httprate.LimitByIP(10, time.Minute)).Post("/entry/{pid}/report", api(handleReport)) - - // --- User (öffentlich) --- r.With(trackImpression).Get("/u/{username}", serveUserPage) - r.Get("/u/{username}/info", api(handleUserPage)) - r.Get("/u/{username}/feed/{page}", api(handleUserFeed)) - - // --- Geschützt (Session erforderlich) --- - r.Group(func(r chi.Router) { - r.Use(requireAuth) - r.With(blockTOR).Post("/entry/create", api(handleCreateEntry)) - r.Post("/entry/{pid}/vote", api(handleVote)) - r.Post("/entry/{pid}/bump", api(handleBump)) - r.Post("/entry/{pid}/edit", api(handleEditEntry)) - r.Post("/entry/{pid}/delete", api(handleDeleteEntry)) - r.Get("/user/info", api(handleUserInfo)) - r.Post("/user/rename", api(handleUserRename)) - r.Post("/user/avatar", api(handleSetAvatar)) - r.Post("/user/delete", api(handleUserDelete)) - }) // Hochgeladene Medien + alte statische Assets. nosniff (global via // secHeaders) verhindert, dass der Browser eine durchgereichte (nicht @@ -173,12 +179,42 @@ func routes() http.Handler { // Polyglot/Stored-XSS über Bild-Uploads. r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) - // Neues JS-Frontend + // Frontend-Dateien (JS, CSS, weitere Seiten) r.Handle("/*", http.FileServer(http.Dir("web"))) return r } +// limitByIP ist httprate.LimitByIP mit JSON-Antwort statt des Klartext-429. +func limitByIP(n int, window time.Duration) func(http.Handler) http.Handler { + return httprate.Limit(n, window, + httprate.WithKeyFuncs(httprate.KeyByIP), + httprate.WithLimitHandler(api(func(http.ResponseWriter, *http.Request) error { + return RateLimited("rate.limited", "Zu viele Anfragen. Bitte kurz warten.") + })), + ) +} + +// recoverJSON ersetzt middleware.Recoverer: eine Panic wird geloggt (mit Stack) +// und als normaler 500 im JSON-Fehlerformat beantwortet. http.ErrAbortHandler +// ist ein gewollter Verbindungsabbruch und wird durchgereicht. +func recoverJSON(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + rec := recover() + if rec == nil { + return + } + if rec == http.ErrAbortHandler { + panic(rec) + } + log.Printf("panic: %v\n%s", rec, debug.Stack()) + writeAPIError(w, Internal(fmt.Errorf("panic: %v", rec))) + }() + next.ServeHTTP(w, r) + }) +} + // secHeaders setzt Standard-Security-Header auf alle Antworten: // - nosniff: Browser darf den deklarierten Content-Type nicht überstimmen // (wichtig für durchgereichte GIF-Uploads, siehe storeImage). diff --git a/middleware.go b/middleware.go index 9b844df..5e43f8e 100644 --- a/middleware.go +++ b/middleware.go @@ -16,7 +16,7 @@ const uidKey ctxKey = "uid" // der Onion-Hidden-Service reicht über einen lokalen Proxy weiter, eine echte // Client-IP gibt es dabei nicht -> die Anfragen erscheinen mit einer IP aus // diesem Netz (siehe Impression-Log: 10.89.0.0). Beiträge von dort werden -// vorerst komplett abgewiesen; eine Moderations-/Freigabeseite kommt später. +// vorerst komplett abgewiesen. // Per KVER_TOR_NET (CIDR) konfigurierbar; leer schaltet die Sperre ab. var torNet *net.IPNet diff --git a/moderation.go b/moderation.go deleted file mode 100644 index f4bbf00..0000000 --- a/moderation.go +++ /dev/null @@ -1,147 +0,0 @@ -package main - -import ( - "crypto/sha256" - "crypto/subtle" - "html/template" - "log" - "net/http" - "time" -) - -// modBasicAuth schützt die Moderationsseite per HTTP Basic Auth direkt in der -// App. Nötig, weil TOR-Zugriffe direkt an die Go-App gehen und NICHT über Caddy -// laufen -- der proxy-seitige Schutz greift dort also nicht. Credentials kommen -// aus KVER_MOD_USER/KVER_MOD_PASS und werden einmalig beim Routing-Aufbau -// gelesen. Fail-closed: sind sie nicht gesetzt, bleibt die Seite gesperrt (503), -// damit sie nie versehentlich offen ist. Fehler gehen wie überall als JSON -// hinaus; der Browser zeigt den Login-Dialog trotzdem (WWW-Authenticate). Vergleich über SHA-256 + ConstantTime, -// damit weder Inhalt noch Länge der Credentials über Timing durchsickern. -func modBasicAuth(next http.Handler) http.Handler { - wantUser := envOr("KVER_MOD_USER", "") - wantPass := envOr("KVER_MOD_PASS", "") - // Einmalig beim Routing-Aufbau warnen statt bei jedem Request: der Hinweis - // richtet sich an den Betreiber, nicht an den Besucher. Die Namen der - // Umgebungsvariablen bleiben deshalb auch aus der Fehlerseite heraus -- - // die Seite ist öffentlich erreichbar. - if wantUser == "" || wantPass == "" { - log.Printf("moderation gesperrt: KVER_MOD_USER/KVER_MOD_PASS nicht gesetzt") - } - return guard(func(w http.ResponseWriter, r *http.Request) error { - if wantUser == "" || wantPass == "" { - return Unavailable("mod.not_configured", "Die Moderationsseite ist nicht konfiguriert.") - } - u, p, ok := r.BasicAuth() - uSum, wuSum := sha256.Sum256([]byte(u)), sha256.Sum256([]byte(wantUser)) - pSum, wpSum := sha256.Sum256([]byte(p)), sha256.Sum256([]byte(wantPass)) - userOK := subtle.ConstantTimeCompare(uSum[:], wuSum[:]) == 1 - passOK := subtle.ConstantTimeCompare(pSum[:], wpSum[:]) == 1 - if !ok || !userOK || !passOK { - // Der Header muss vor dem Rendern stehen, sonst fragt der Browser - // nicht nach Zugangsdaten -- deshalb bekommt der Prüfschritt hier - // den ResponseWriter. - w.Header().Set("WWW-Authenticate", `Basic realm="Moderation", charset="UTF-8"`) - return Unauth("mod.auth_required", "Zugang erforderlich.") - } - return nil - })(next) -} - -// Moderationsseite: spiegelt die report-Tabelle als serverseitig gerenderte, -// JS-freie HTML-Tabelle (offene Meldungen zuerst). Bewusst OHNE App-seitige -// Auth -- der Zugriff wird ausschließlich vom vorgelagerten Reverse-Proxy -// (Caddy: erzwingt TLS + Basic-Auth) geschützt. Die Route darf daher niemals -// direkt, am Proxy vorbei, erreichbar sein. -// -// html/template escaped reason/content automatisch -> der attacker-kontrollierte -// Meldegrund kann kein Markup einschleusen. Kein Inline-CSS/JS wegen der CSP; -// das gemeinsame Stylesheet wird als externe (same-origin) Datei verlinkt. - -type reportRow struct { - PID int64 - Reason string - When string - Handled bool - Username string - Content string -} - -var moderationTmpl = template.Must(template.New("moderation").Parse(` - - - - - - Moderation · Meldungen - - - -
-

Meldungen

-

{{len .}} Meldung(en), offene zuerst.

- - - - - - {{range .}} - - - - - - - - - {{else}} - - {{end}} - -
StatusZeitBeitragAutorInhalt (Auszug)Grund
{{if .Handled}}erledigt{{else}}offen{{end}}{{.When}}{{.PID}}{{if .Username}}{{.Username}}{{else}}[gelöscht/unbekannt]{{end}}{{.Content}}{{if .Reason}}{{.Reason}}{{else}}–{{end}}
Keine Meldungen.
-
- -`)) - -func handleModeration(w http.ResponseWriter, r *http.Request) error { - // Offene Meldungen zuerst (handled ASC), innerhalb dessen die jüngsten oben. - rows, err := db.Query( - `SELECT r.pid, r.reason, r.created_at, r.handled, - COALESCE(u.username, ''), COALESCE(e.content, '') - FROM report r - LEFT JOIN entry e ON e.pid = r.pid - LEFT JOIN account u ON u.uid = e.uid - ORDER BY r.handled ASC, r.created_at DESC`) - if err != nil { - return Internal(err) - } - defer rows.Close() - - reports := []reportRow{} - for rows.Next() { - var rr reportRow - var created int64 - var handled int - if err := rows.Scan(&rr.PID, &rr.Reason, &created, &handled, &rr.Username, &rr.Content); err != nil { - return Internal(err) - } - rr.Handled = handled != 0 - rr.When = time.Unix(created, 0).UTC().Format("2006-01-02 15:04 UTC") - // Inhalt nur als kurzer Auszug -- die Volansicht hängt am Beitrags-Link. - // Runenweise kürzen, damit der Schnitt kein Multibyte-Zeichen zerreißt. - if excerpt := truncateRunes(rr.Content, 120); excerpt != rr.Content { - rr.Content = excerpt + "…" - } - reports = append(reports, rr) - } - - // rows.Err() nach der Schleife: ein Fehler mitten im Iterieren würde sonst - // als "einfach zu Ende" durchgehen und eine unvollständige Liste als - // vollständige Moderationsansicht ausliefern. - if err := rows.Err(); err != nil { - return Internal(err) - } - - w.Header().Set("Content-Type", "text/html; charset=utf-8") - moderationTmpl.Execute(w, reports) - return nil -} diff --git a/notes/api.md b/notes/api.md index c279c2b..e053acc 100644 --- a/notes/api.md +++ b/notes/api.md @@ -1,228 +1,125 @@ -# kver API Definition +# kver API -Abgeleitet aus dem bestehenden Flask-Code. Basis für die Go-Reimplementierung. +Alle Endpunkte liegen unter `/api` und antworten ausschließlich mit JSON. +Außerhalb von `/api` liefert das Backend nur Seiten und Dateien des Frontends. --- -## Authentifizierung +## Konventionen -Sessions werden als Cookie (`session`) übertragen. Alle geschützten Endpunkte erwarten diesen Cookie. +**Erfolg oder Fehler steht im HTTP-Status.** `2xx` heißt Erfolg, alles andere +Fehler. Einen `200` mit Fehler im Body gibt es nicht. Fehler haben immer diese +Form: + +```json +{ "error": { "code": "entry.not_found", "message": "Beitrag nicht gefunden", + "field": "content", "fields": { "user": "…" } } } +``` + +- `code`: stabil, maschinenlesbar (`.`), für Logik +- `message`: deutsche Meldung für Menschen +- `field` / `fields`: optional, betroffene Formularfelder (`fields` nur bei der + Registrierung, die alle Feldfehler auf einmal meldet) + +| Status | Bedeutung | +|--------|-----------| +| `400` | Eingabe ungültig | +| `401` | nicht angemeldet / falsche Zugangsdaten | +| `403` | angemeldet, aber nicht erlaubt (fremder Beitrag, falsches Passwort, TOR, fremder Origin) | +| `404` | Objekt oder Endpunkt existiert nicht (`api.not_found`) | +| `405` | Endpunkt existiert, aber nicht mit dieser Methode (`api.method_not_allowed`) | +| `409` | Konflikt (Nutzername vergeben) | +| `413` | Upload zu groß | +| `429` | zu viele Anfragen (`rate.limited`) oder Repost-Cooldown | +| `500` | Serverfehler (`internal`, Ursache nur im Log) | + +**Requests** sind `application/x-www-form-urlencoded` bzw. `multipart/form-data` +(Uploads). Zeitstempel sind Unix-Sekunden. + +**Auth** läuft über das Cookie `session` (HttpOnly, SameSite=Lax). Geschützte +Endpunkte antworten ohne gültige Session mit `401 auth.required`. + +**CSRF:** Nicht-GET-Requests mit fremdem `Origin`-Header werden mit +`403 csrf.bad_origin` abgewiesen. + +**Rate-Limits** (pro IP): Login und Registrierung 20/min. --- ## Auth -### `POST /auth/login` +| Methode | Pfad | Auth | Request | Erfolg | +|---------|------|------|---------|--------| +| POST | `/api/auth/login` | – | `user`, `pass`, `timeout` (s, Default 1 Tag, max. 30 Tage) | `200 {"status":"ok"}` + Cookie; bereits angemeldet: `200 {"status":"already_logged_in"}` | +| POST | `/api/auth/newuser` | – | `user` (3–32 Zeichen), `pass1` (min. 10), `pass2` | `201 {"username"}` | +| POST | `/api/auth/logout` | – | – | `200 {"status":"ok"}`, löscht Cookie | +| GET | `/api/auth/sessioninfo` | ✓ | – | `200 {"uid","created_at","expires","description"}`, ohne Session `401` | -Einloggen. - -**Request (form-encoded):** -| Feld | Typ | Beschreibung | -|------|-----|--------------| -| `user` | string | Nutzername | -| `pass` | string | Passwort | -| `timeout` | int | Session-Dauer in Sekunden | - -**Response:** -- `200` + setzt `session`-Cookie → Redirect auf `/` -- `200` + Fehlertext bei falschen Credentials +Login-Fehler: `401 auth.bad_credentials` (gleich für unbekannten Nutzer und +falsches Passwort). --- -### `GET /auth/login` +## Beiträge -Login-Seite anzeigen (wird in der Go-API nicht benötigt, Frontend übernimmt das). +Ein Beitrag (`Entry`): ---- - -### `POST /auth/newuser` - -Neuen Account registrieren. - -**Request (form-encoded):** -| Feld | Typ | Beschreibung | -|------|-----|--------------| -| `user` | string | Nutzername (3–32 Zeichen, `[a-zA-Z0-9._\s-]`) | -| `pass1` | string | Passwort (min. 10 Zeichen) | -| `pass2` | string | Passwort-Bestätigung | - -**Response:** -- `200` bei Erfolg -- `200` + Fehlerliste bei Validierungsfehler - ---- - -### `POST /auth/logout` - -Session beenden und Cookie löschen. POST statt GET: SameSite=Lax schützt -GET-Navigationen nicht, ein fremder Link könnte Nutzer sonst ausloggen (CSRF). - -**Response:** `200` `{"status": "ok"}` - ---- - -### `GET /auth/headerbar` - -Gibt zurück ob der Nutzer eingeloggt ist (für die UI-Headerleiste). - -**Response:** HTML-Partial (in Go-API: JSON) - ---- - -### `GET /auth/sessioninfo` - -Infos zur aktuellen Session. - -**Auth:** erforderlich - -**Response:** ```json -{ - "uid": 123456, - "created_at": 1700000000, - "expires": 1700086400, - "description": "" -} +{ "pid": 123456789, "uid": 42, "created_at": 1700000000, "content": "…", + "filepath": "static/media/…", "reply_to": 0, "reply_count": 3, + "last_activity": 1700000500, "deleted": 0, "bump_count": 1, + "last_bump": 1700000400, "username": "max", "avatar": "static/media/…" } ``` ---- +| Methode | Pfad | Auth | Request | Erfolg | +|---------|------|------|---------|--------| +| GET | `/api/entry/feed/{page}` | – | – | `200 [Entry]`: Threads nach letzter Aktivität, 20 pro Seite, Seite 0–100, `[]` am Ende | +| GET | `/api/entry/{pid}/thread` | – | – | `200 {"entry", "ancestors": [Entry], "replies": [Entry]}` | +| GET | `/api/entry/{pid}/votes` | – | – | `200 {"pid","left","right","selected"}` (`selected`: `left`/`right`/`none`) | +| POST | `/api/entry/create` | ✓ | multipart: `content` (max. 1000), `file` (optional), `reply_to` (optional) | `201 {"pid","content","filepath","reply_to"}` | +| POST | `/api/entry/{pid}/edit` | ✓ Autor | `content` | `200 {"pid","content"}` | +| POST | `/api/entry/{pid}/delete` | ✓ Autor | – | `200 {"status":"deleted"}` (Soft-Delete, Thread bleibt) | +| POST | `/api/entry/{pid}/vote` | ✓ | `mode` = `left`/`right` | `200` wie `votes`. Gleiche Stimme erneut zieht sie zurück | +| POST | `/api/entry/{pid}/bump` | ✓ | – | `200 {"pid","bump_count","last_bump","last_activity","retry_after"}` | -## Entries (Beiträge) +Beim Bump wächst der Cooldown mit jedem Repost um einen Tag. Im Cooldown +antwortet der Endpunkt mit `429 entry.bump_cooldown`. Dazu kommen der Header +`Retry-After` und im Body `retry_after` und `bump_count` (neben `error`). -### `GET /entry/feed/:page` - -Paginierter Feed aller Beiträge, neueste zuerst. - -**Parameter:** -| Name | Typ | Beschreibung | -|------|-----|--------------| -| `page` | int | Seite (0–100), 20 Einträge pro Seite | - -**Response:** -```json -[ - { - "pid": 123456789, - "created_at": 1700000000, - "uid": 42, - "content": "...", - "filepath": "static/media/...", - "username": "max" - } -] -``` -Leeres Array `[]` wenn keine weiteren Einträge vorhanden. +TOR-Anfragen auf `create` werden mit `403 tor.blocked` abgewiesen. --- -### `POST /entry/create` +## Nutzer -Neuen Beitrag erstellen. - -**Auth:** erforderlich - -**Request (multipart/form-data):** -| Feld | Typ | Beschreibung | -|------|-----|--------------| -| `content` | string | Text (max. 1000 Zeichen) | -| `file` | file (optional) | Bild (JPG oder GIF, wird auf max. 1024px skaliert) | - -Mindestens `content` oder `file` muss vorhanden sein. - -**Response:** -- `201` bei Erfolg -- `400` bei leerem Beitrag -- `401` wenn nicht eingeloggt +| Methode | Pfad | Auth | Request | Erfolg | +|---------|------|------|---------|--------| +| GET | `/api/u/{username}/info` | – | – | `200 {"uid","username","created_at","avatar"}` | +| GET | `/api/u/{username}/feed/{page}` | – | – | `200 [Entry]`: alle Beiträge inkl. Antworten, chronologisch | +| GET | `/api/user/info` | ✓ | – | `200 {"uid","username","created_at","last_login","avatar"}` | +| POST | `/api/user/rename` | ✓ | `user` | `200 {"username"}`; vergeben: `409 user.name_taken` | +| POST | `/api/user/avatar` | ✓ | multipart: `avatar` | `200 {"avatar"}` | +| POST | `/api/user/delete` | ✓ | `pass1` | `200 {"status":"deleted"}`, löscht Konto, Beiträge, Votes, Sessions; falsches Passwort: `403 auth.bad_password` | --- -### `GET /entry/:pid/votes` +## Statistik -Abstimmungsstand eines Beitrags lesen (read-only). - -**Response:** -```json -{ "pid": 123, "left": 4, "right": 2, "selected": "left" } -``` -`selected` ist `none`, wenn nicht eingeloggt oder keine Stimme abgegeben. +| Methode | Pfad | Erfolg | +|---------|------|--------| +| GET | `/api/stats` | `200 {"users","entries"}` | +| GET | `/api/stats/detail` | `200 {"users","entries","toplevel","replies","votes":{"left","right"},"impressions_total","visitors_total","daily":[…],"top_asns":[…]}` | --- -### `POST /entry/:pid/vote` +## Seiten (kein JSON) -Eigene Stimme abgeben oder umschalten. - -**Auth:** erforderlich - -**Request (form-encoded):** -| Feld | Typ | Beschreibung | -|------|-----|--------------| -| `mode` | string | `left` oder `right` | - -**Voting-Logik:** -- keine bisherige Stimme → neue Stimme -- gleiche Stimme erneut → Stimme zurückziehen (Toggle) -- andere Stimme → auf neuen Modus wechseln - -**Response:** wie `GET /entry/:pid/votes` (aktualisierter Stand) -- `400` bei ungültigem Modus -- `401` ohne Login - ---- - -## User - -### `GET /u/:username` - -Öffentliche Profilseite eines Nutzers. - -**Response:** Nutzerprofil + Beiträge (Details noch offen) - ---- - -### `GET /user/info` - -Eigene Account-Informationen. - -**Auth:** erforderlich - -**Response:** -```json -{ - "uid": 42, - "username": "max", - "created_at": 1700000000 -} -``` - ---- - -### `POST /user/delete` - -Eigenen Account dauerhaft löschen. Beiträge, Votes und Sessions werden -mitgelöscht, zugehörige Mediendateien best effort entfernt. - -**Auth:** erforderlich - -**Request (form-encoded):** -| Feld | Typ | Beschreibung | -|------|-----|--------------| -| `pass1` | string | Passwort zur Bestätigung | - -**Response:** -- `200` `{"status": "deleted"}` + löscht den `session`-Cookie -- `403` bei falschem Passwort +`/`, `/e/{pid}`, `/u/{username}` liefern die HTML-Seiten des Frontends und +zählen anonymisierte Impressions. `/static/*` enthält Uploads und Dokumente, +alles andere kommt aus `web/`. --- ## Datenbankschema Postgres; maßgeblich ist die Konstante `schema` in `db.go`. - ---- - -## Offene Punkte - -- Kein Rate-Limiting außer dem zufälligen `sleep` beim Login -- `session`-Token ist nur `randbelow(999999999) + timestamp` — sollte auf `crypto/rand` umgestellt werden -- Fehler-Responses sind aktuell Plaintext/HTML — in der Go-API einheitlich JSON diff --git a/notes/deploy.md b/notes/deploy.md index 0105c42..f55c19f 100644 --- a/notes/deploy.md +++ b/notes/deploy.md @@ -196,7 +196,7 @@ Volume=kver-media:/app/static/media Environment=KVER_GEOIP_ASN=/app/data/GeoLite2-ASN.mmdb # Healthcheck hier statt im Containerfile: Podman baut OCI-Images, # die kennen kein eingebettetes HEALTHCHECK. -HealthCmd=wget -qO /dev/null http://localhost:8080/entry/feed/0 +HealthCmd=wget -qO /dev/null http://localhost:8080/api/stats HealthInterval=30s [Service] diff --git a/notes/todo.md b/notes/todo.md index 3f4b869..ed71911 100644 --- a/notes/todo.md +++ b/notes/todo.md @@ -1,7 +1,6 @@ # TODO -- Moderation: handled setzen -- Moderation: Löschen aus Queue +- Moderation (Melden, Queue, Abhilfe) als eigenständiges Projekt - Passwort-Reset - Suche - Hashtags / Erwähnungen diff --git a/stats.go b/stats.go index dfffd3b..c96c20d 100644 --- a/stats.go +++ b/stats.go @@ -223,53 +223,73 @@ func recordImpression(path, anonIP, asn string) { // handleStatsDetail liefert die Kennzahlen für die Statistik-Seite. Anders als // /stats (Tagline, bei jedem Aufruf) darf das hier ruhig mehrere Aggregate // rechnen. Öffentlich, kein Auth. -func handleStatsDetail(w http.ResponseWriter, r *http.Request) { +func handleStatsDetail(w http.ResponseWriter, r *http.Request) error { var users, entries, toplevel, votesLeft, votesRight int64 - db.QueryRow(`SELECT COUNT(*) FROM account`).Scan(&users) - db.QueryRow(`SELECT COUNT(*) FROM entry WHERE deleted = 0`).Scan(&entries) - db.QueryRow(`SELECT COUNT(*) FROM entry WHERE deleted = 0 AND reply_to = 0`).Scan(&toplevel) - db.QueryRow(`SELECT COUNT(*) FROM vote WHERE mode = 'left'`).Scan(&votesLeft) - db.QueryRow(`SELECT COUNT(*) FROM vote WHERE mode = 'right'`).Scan(&votesRight) + if err := db.QueryRow( + `SELECT (SELECT COUNT(*) FROM account), + (SELECT COUNT(*) FROM entry WHERE deleted = 0), + (SELECT COUNT(*) FROM entry WHERE deleted = 0 AND reply_to = 0), + (SELECT COUNT(*) FROM vote WHERE mode = 'left'), + (SELECT COUNT(*) FROM vote WHERE mode = 'right')`). + Scan(&users, &entries, &toplevel, &votesLeft, &votesRight); err != nil { + return Internal(err) + } var imprTotal, visitorsTotal int64 - db.QueryRow(`SELECT COALESCE(SUM(hits), 0)::bigint, COUNT(DISTINCT anon_ip) FROM impression`). - Scan(&imprTotal, &visitorsTotal) + if err := db.QueryRow(`SELECT COALESCE(SUM(hits), 0)::bigint, COUNT(DISTINCT anon_ip) FROM impression`). + Scan(&imprTotal, &visitorsTotal); err != nil { + return Internal(err) + } // Tägliche Impressions + grobe Unique-Netze, jüngste 30 Tage zuerst. Das // Frontend dreht für die Achse auf aufsteigend. daily := []map[string]any{} - if rows, err := db.Query( + rows, err := db.Query( `SELECT day, SUM(hits)::bigint, COUNT(DISTINCT anon_ip) FROM impression GROUP BY day ORDER BY day DESC - LIMIT 30`); err == nil { - defer rows.Close() - for rows.Next() { - var day string - var hits, visitors int64 - rows.Scan(&day, &hits, &visitors) - daily = append(daily, map[string]any{"day": day, "impressions": hits, "visitors": visitors}) + LIMIT 30`) + if err != nil { + return Internal(err) + } + defer rows.Close() + for rows.Next() { + var day string + var hits, visitors int64 + if err := rows.Scan(&day, &hits, &visitors); err != nil { + return Internal(err) } + daily = append(daily, map[string]any{"day": day, "impressions": hits, "visitors": visitors}) + } + if err := rows.Err(); err != nil { + return Internal(err) } // Netze nach Betreiber (autonomes System). Leere asn ('') -> "(unbekannt)". topASNs := []map[string]any{} - if rows, err := db.Query( + rows, err = db.Query( `SELECT CASE WHEN asn = '' THEN '(unbekannt)' ELSE asn END AS a, SUM(hits)::bigint AS h, COUNT(DISTINCT anon_ip) FROM impression GROUP BY a ORDER BY h DESC - LIMIT 10`); err == nil { - defer rows.Close() - for rows.Next() { - var asn string - var hits, networks int64 - rows.Scan(&asn, &hits, &networks) - topASNs = append(topASNs, map[string]any{"asn": asn, "hits": hits, "networks": networks}) + LIMIT 10`) + if err != nil { + return Internal(err) + } + defer rows.Close() + for rows.Next() { + var asn string + var hits, networks int64 + if err := rows.Scan(&asn, &hits, &networks); err != nil { + return Internal(err) } + topASNs = append(topASNs, map[string]any{"asn": asn, "hits": hits, "networks": networks}) + } + if err := rows.Err(); err != nil { + return Internal(err) } writeJSON(w, http.StatusOK, map[string]any{ @@ -283,6 +303,7 @@ func handleStatsDetail(w http.ResponseWriter, r *http.Request) { "daily": daily, "top_asns": topASNs, }) + return nil } // serveIndex liefert die Startseite. Eigene Route (statt FileServer-Catch-All), diff --git a/user.go b/user.go index 193a258..dcaf5d7 100644 --- a/user.go +++ b/user.go @@ -89,14 +89,18 @@ func handleUserPage(w http.ResponseWriter, r *http.Request) error { // handleStats liefert die Kennzahlen für die Statuszeile unter dem Titel: // registrierte Nutzer und nicht-gelöschte Beiträge. -func handleStats(w http.ResponseWriter, r *http.Request) { +func handleStats(w http.ResponseWriter, r *http.Request) error { var users, entries int64 - db.QueryRow(`SELECT COUNT(*) FROM account`).Scan(&users) - db.QueryRow(`SELECT COUNT(*) FROM entry WHERE deleted = 0`).Scan(&entries) + if err := db.QueryRow( + `SELECT (SELECT COUNT(*) FROM account), (SELECT COUNT(*) FROM entry WHERE deleted = 0)`). + Scan(&users, &entries); err != nil { + return Internal(err) + } writeJSON(w, http.StatusOK, map[string]any{ "users": users, "entries": entries, }) + return nil } func handleUserInfo(w http.ResponseWriter, r *http.Request) error { diff --git a/web/css/app.css b/web/css/app.css index fe40f75..8f661cb 100644 --- a/web/css/app.css +++ b/web/css/app.css @@ -302,52 +302,6 @@ img.avatar-lg { width: 72px; height: 72px; } .ghost.selected { font-weight: bold; } .msg { color: red; } -/* Melden sitzt rechtsbündig in der Meta-Zeile (weit weg von den Interaktionen). - margin-left:auto schiebt es im Byline-Flex ganz nach rechts; position:relative - verankert das aufklappende Grund-Feld. Der
-Zweischritt (erst öffnen, - dann absenden) verhindert versehentliche Meldungen. */ -details.report { - margin-left: auto; - position: relative; - font-size: 0.9rem; -} -/* summary wie ein Button, aber zurückhaltend (grau), da Moderationshilfe. */ -details.report > summary { - list-style: none; - user-select: none; - cursor: pointer; - padding: 2px 8px; - border: 1px solid #ccc; - color: gray; - background-color: white; -} -details.report > summary::-webkit-details-marker { display: none; } -details.report > summary:hover { text-decoration: underline; } -details.report[open] > summary { color: #a00; border-color: #a00; } -details.report.reported > summary { - color: green; - border-color: green; - cursor: default; - text-decoration: none; -} -/* Aufgeklapptes Grund-Feld schwebt unter dem Button, damit die Meta-Zeile nicht - springt. Über dem Inhalt (z-index) und rechtsbündig ausgerichtet. */ -.report-form { - position: absolute; - right: 0; - z-index: 2; - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 0.25rem; - margin-top: 0.25rem; - padding: 0.5rem; - background-color: white; - border: 1px solid #ccc; - white-space: nowrap; -} -.report-form input { font-family: inherit; font-size: 0.9rem; } - p.warning { background-color: #faa; padding: 10px 15px; diff --git a/web/js/app.js b/web/js/app.js index c9ca587..45ba8ae 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -4,16 +4,15 @@ let loggedIn = false; async function refreshHeader() { - const h = await api.get("/auth/headerbar"); - loggedIn = !!h.loggedin; + const me = await api.get("/user/info"); + loggedIn = me.ok; document.getElementById("auth-box").hidden = loggedIn; document.getElementById("compose").hidden = !loggedIn; const bar = document.getElementById("headerbar"); if (loggedIn) { - const me = await api.get("/user/info"); - const user = encodeURIComponent(me.username); + const user = encodeURIComponent(me.data.username); bar.innerHTML = `mein Profil `; document.getElementById("logout-btn").addEventListener("click", async () => { // POST statt GET: als GET wäre Logout per fremdem Link auslösbar (CSRF). diff --git a/web/js/entry.js b/web/js/entry.js index e06cdeb..77f729d 100644 --- a/web/js/entry.js +++ b/web/js/entry.js @@ -23,13 +23,14 @@ replyForm.addEventListener("submit", async (e) => { }); async function load() { - const data = await api.get(`/entry/${pid}/thread`); + const res = await api.get(`/entry/${pid}/thread`); const focus = document.getElementById("focus"); - if (data.error) { - focus.textContent = "Beitrag nicht gefunden"; + if (!res.ok) { + focus.textContent = errMsg(res.data, "Beitrag konnte nicht geladen werden."); return; } + const data = res.data; // Ahnenkette (Root zuerst), dann der fokussierte Beitrag. const ancestors = document.getElementById("ancestors"); @@ -40,7 +41,9 @@ async function load() { focus.appendChild(renderEntry(data.entry)); // Eingeloggt? Reply-Box zeigen; eigener, nicht gelöschter Beitrag -> Bearbeiten/Löschen. - const me = await api.get("/user/info"); + // 401 heißt nicht angemeldet -> me bleibt leer. + const meRes = await api.get("/user/info"); + const me = meRes.ok ? meRes.data : {}; document.getElementById("reply-box").hidden = !me.uid; if (me.uid === data.entry.uid && !data.entry.deleted) { const editBtn = document.createElement("button"); diff --git a/web/js/feed.js b/web/js/feed.js index c84864f..c3db940 100644 --- a/web/js/feed.js +++ b/web/js/feed.js @@ -1,24 +1,28 @@ "use strict"; -// Die API antwortet ausschließlich mit JSON; der Accept-Header sagt das explizit. +// Die API liegt unter /api und antwortet ausschließlich mit JSON. Ob ein Aufruf +// geklappt hat, sagt der HTTP-Status (ok/status); data ist bei Fehlern das +// Fehlerobjekt {"error": {...}}. Pfade werden ohne /api übergeben. const JSON_HEADERS = { Accept: "application/json" }; +async function apiResult(r) { + return { ok: r.ok, status: r.status, data: await r.json().catch(() => ({})) }; +} + const api = { - async get(url) { - const r = await fetch(url, { + async get(path) { + return apiResult(await fetch(`/api${path}`, { credentials: "same-origin", headers: JSON_HEADERS, - }); - return r.json(); + })); }, - async post(url, formData) { - const r = await fetch(url, { + async post(path, formData) { + return apiResult(await fetch(`/api${path}`, { method: "POST", body: formData, credentials: "same-origin", headers: JSON_HEADERS, - }); - return { ok: r.ok, status: r.status, data: await r.json().catch(() => ({})) }; + })); }, }; @@ -125,15 +129,6 @@ function renderEntry(e) {
@@ -154,7 +149,6 @@ function renderEntry(e) { `; setupVoting(el, e.pid); setupBump(el, e); - setupReport(el, e.pid); el.addEventListener("click", (ev) => { if (!ev.target.closest("a, button")) location.href = `/e/${e.pid}`; }); @@ -166,11 +160,12 @@ function setupVoting(el, pid) { const counts = box.querySelector("span"); const buttons = box.querySelectorAll(".ghost"); - function render(state) { - if (state.error) { - counts.textContent = errMsg(state, "Fehler"); + function render(res) { + if (!res.ok) { + counts.textContent = errMsg(res.data, "Fehler"); return; } + const state = res.data; counts.textContent = `${state.left} / ${state.right}`; buttons.forEach((b) => b.classList.toggle("selected", b.dataset.mode === state.selected) @@ -181,8 +176,7 @@ function setupVoting(el, pid) { b.addEventListener("click", async () => { const fd = new FormData(); fd.append("mode", b.dataset.mode); - const res = await api.post(`/entry/${pid}/vote`, fd); - render(res.data); + render(await api.post(`/entry/${pid}/vote`, fd)); }) ); @@ -226,38 +220,6 @@ function setupBump(el, e) { }); } -// setupReport verdrahtet den Melden-Aufklapper. Der
-Zweischritt -// (öffnen -> absenden) verhindert versehentliche Meldungen und hält das Grund- -// Feld eingeklappt. Der Submit wird abgefangen, um Inline-Feedback zu geben -// statt die Seite neu zu laden. stopPropagation, weil -// die Karte sonst bei jedem Klick nach /e/{pid} navigieren würde. -function setupReport(el, pid) { - const details = el.querySelector("details.report"); - if (!details) return; - details.addEventListener("click", (ev) => ev.stopPropagation()); - - const form = details.querySelector(".report-form"); - const summary = details.querySelector("summary"); - form.addEventListener("submit", async (ev) => { - ev.preventDefault(); - const res = await api.post(`/entry/${pid}/report`, new FormData(form)); - if (res.ok) { - form.remove(); - details.open = false; - details.classList.add("reported"); - summary.textContent = "Gemeldet – danke"; - return; - } - let msg = form.querySelector(".msg"); - if (!msg) { - msg = document.createElement("span"); - msg.className = "msg"; - form.append(msg); - } - msg.textContent = errMsg(res.data, "Fehler beim Melden"); - }); -} - // createFeed hängt Infinite-Scroll an feedEl/sentinelEl und lädt Seiten über // urlFor(page). Gibt { reset } zurück, um den Feed neu zu laden. function createFeed(feedEl, sentinelEl, urlFor) { @@ -269,7 +231,19 @@ function createFeed(feedEl, sentinelEl, urlFor) { if (loading || done) return; loading = true; - const entries = await api.get(urlFor(page)); + const res = await api.get(urlFor(page)); + if (!res.ok) { + // Fehler nicht als "Ende" verbuchen: Meldung zeigen und weiteres + // Nachladen stoppen, statt in einer Schleife neu anzufragen. + done = true; + const err = document.createElement("p"); + err.className = "msg"; + err.textContent = errMsg(res.data, "Feed konnte nicht geladen werden."); + feedEl.appendChild(err); + loading = false; + return; + } + const entries = res.data; if (!entries.length) { done = true; const end = document.createElement("p"); diff --git a/web/js/stats.js b/web/js/stats.js index 69a8ba6..630b5a9 100644 --- a/web/js/stats.js +++ b/web/js/stats.js @@ -1,6 +1,6 @@ "use strict"; -// Statistik-Seite: holt die Aggregate von /stats/detail und rendert Zahlen + +// Statistik-Seite: holt die Aggregate von /api/stats/detail und rendert Zahlen + // einfache CSS-Balken (keine Chart-Library, CSP-konform). Balkenbreiten werden // per CSSOM (el.style.width) gesetzt -> kein Inline-style-Attribut, daher von // der strikten CSP nicht betroffen. @@ -63,7 +63,15 @@ function renderBars(container, items, formatLabel) { } async function render() { - const s = await api.get("/stats/detail"); + const res = await api.get("/stats/detail"); + if (!res.ok) { + const p = document.createElement("p"); + p.className = "msg"; + p.textContent = errMsg(res.data, "Statistik konnte nicht geladen werden."); + document.getElementById("stat-overview").append(p); + return; + } + const s = res.data; // --- Auf einen Blick --- const overview = document.getElementById("stat-overview"); diff --git a/web/js/user.js b/web/js/user.js index 2103512..7fea2ec 100644 --- a/web/js/user.js +++ b/web/js/user.js @@ -41,11 +41,13 @@ document.getElementById("delete-form").addEventListener("submit", async (e) => { }); async function init() { - const info = await api.get(`/u/${encodeURIComponent(username)}/info`); - if (info.error) { - document.getElementById("profile-name").textContent = "Nutzer nicht gefunden"; + const res = await api.get(`/u/${encodeURIComponent(username)}/info`); + if (!res.ok) { + document.getElementById("profile-name").textContent = + errMsg(res.data, "Profil konnte nicht geladen werden."); return; } + const info = res.data; document.getElementById("profile-name").textContent = info.username; document.getElementById("profile-avatar").src = avatarUrl(info.avatar); @@ -54,7 +56,7 @@ async function init() { // Eigene Seite? -> Einstellungen einblenden. const me = await api.get("/user/info"); - const isOwner = me.username === info.username; + const isOwner = me.ok && me.data.username === info.username; document.getElementById("settings").hidden = !isOwner; createFeed(