Moderation entfernt, JSON-API unter /api

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
This commit is contained in:
irrlicht
2026-09-26 23:10:02 +02:00
co-authored by Claude Opus 5.5
parent 2714fdb1a5
commit 030a5bd943
21 changed files with 430 additions and 726 deletions
+7 -7
View File
@@ -133,9 +133,13 @@ func startSession(w http.ResponseWriter, r *http.Request, uid int64) error {
return nil 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 { 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{ http.SetCookie(w, &http.Cookie{
Name: "session", Name: "session",
@@ -148,6 +152,7 @@ func handleLogout(w http.ResponseWriter, r *http.Request) {
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
}) })
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
return nil
} }
// handleNewUser sammelt alle Validierungsfehler ein, statt beim ersten // handleNewUser sammelt alle Validierungsfehler ein, statt beim ersten
@@ -210,11 +215,6 @@ func handleSessionInfo(w http.ResponseWriter, r *http.Request) error {
return nil 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 { func sanitizeUsername(u string) string {
u = strings.TrimSpace(u) u = strings.TrimSpace(u)
// Auf 32 Zeichen (Runen) kürzen, nicht 32 Bytes -- sonst kann der Schnitt // Auf 32 Zeichen (Runen) kürzen, nicht 32 Bytes -- sonst kann der Schnitt
-15
View File
@@ -71,20 +71,6 @@ CREATE TABLE IF NOT EXISTS impression (
PRIMARY KEY (day, anon_ip, path) 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 -- 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 -- 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) -- 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_entry_uid ON entry(uid);
CREATE INDEX IF NOT EXISTS idx_vote_pid ON vote(pid); 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_session_uid ON session(uid);
CREATE INDEX IF NOT EXISTS idx_report_open ON report(handled, created_at);
` `
func initDB(dsn string) error { func initDB(dsn string) error {
+103 -73
View File
@@ -84,7 +84,7 @@ func registerAndLogin(t *testing.T, srv *httptest.Server, username string) *http
t.Helper() t.Helper()
c := newClient(t) 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"}, "user": {username}, "pass1": {"supersecret1"}, "pass2": {"supersecret1"},
}) })
resp.Body.Close() 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) 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"}, "user": {username}, "pass": {"supersecret1"}, "timeout": {"86400"},
}) })
resp.Body.Close() 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. // createEntry erstellt einen Beitrag und liefert dessen pid.
func createEntry(t *testing.T, c *http.Client, srv *httptest.Server, content string) int64 { func createEntry(t *testing.T, c *http.Client, srv *httptest.Server, content string) int64 {
t.Helper() 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() defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated { if resp.StatusCode != http.StatusCreated {
t.Fatalf("create entry: status %d", resp.StatusCode) 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 return out.PID
} }
func TestRegisterLoginHeaderbar(t *testing.T) { func TestRegisterLoginSession(t *testing.T) {
srv := newTestServer(t) srv := newTestServer(t)
c := registerAndLogin(t, srv, "alice") 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
defer resp.Body.Close() resp.Body.Close()
return resp.StatusCode
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")
}
} }
func TestFeedEmptyThenPopulated(t *testing.T) { func TestFeedEmptyThenPopulated(t *testing.T) {
srv := newTestServer(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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -170,7 +160,7 @@ func TestFeedEmptyThenPopulated(t *testing.T) {
alice := registerAndLogin(t, srv, "alice") alice := registerAndLogin(t, srv, "alice")
createEntry(t, alice, srv, "hallo welt") 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -189,7 +179,7 @@ func TestCreateEntryRequiresAuth(t *testing.T) {
srv := newTestServer(t) srv := newTestServer(t)
anon := newClient(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() resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized { if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("create ohne Login: erwartet 401, bekam %d", resp.StatusCode) 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") pid := createEntry(t, alice, srv, "vote me")
vote := func(c *http.Client, mode string) tally { 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() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
t.Fatalf("vote %s: status %d", mode, resp.StatusCode) t.Fatalf("vote %s: status %d", mode, resp.StatusCode)
@@ -237,7 +227,7 @@ func TestVoteRequiresAuthAndValidMode(t *testing.T) {
srv := newTestServer(t) srv := newTestServer(t)
alice := registerAndLogin(t, srv, "alice") alice := registerAndLogin(t, srv, "alice")
pid := createEntry(t, alice, srv, "x") 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) anon := newClient(t)
resp := postForm(t, anon, voteURL, url.Values{"mode": {"left"}}) resp := postForm(t, anon, voteURL, url.Values{"mode": {"left"}})
@@ -258,7 +248,7 @@ func TestBump(t *testing.T) {
alice := registerAndLogin(t, srv, "alice") alice := registerAndLogin(t, srv, "alice")
bob := registerAndLogin(t, srv, "bob") bob := registerAndLogin(t, srv, "bob")
pid := createEntry(t, alice, srv, "bump me") 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. // Anonym: 401.
resp := postForm(t, newClient(t), bumpURL, url.Values{}) 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. // Gelöschter Beitrag lässt sich nicht bumpen.
del := createEntry(t, alice, srv, "weg gleich") 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.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() resp.Body.Close()
if resp.StatusCode != http.StatusNotFound { if resp.StatusCode != http.StatusNotFound {
t.Fatalf("bump gelöscht: erwartet 404, bekam %d", resp.StatusCode) 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. // 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 { func createReply(t *testing.T, c *http.Client, srv *httptest.Server, content string, parentPID int64) int64 {
t.Helper() 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)}, "content": {content}, "reply_to": {fmt.Sprint(parentPID)},
}) })
defer resp.Body.Close() defer resp.Body.Close()
@@ -336,7 +326,7 @@ type threadJSON struct {
func getThread(t *testing.T, srv *httptest.Server, pid int64) threadJSON { func getThread(t *testing.T, srv *httptest.Server, pid int64) threadJSON {
t.Helper() 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -378,7 +368,7 @@ func TestThreading(t *testing.T) {
} }
// Hauptfeed: nur der Root, keine Antworten. // 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 { var feed []struct {
PID int64 `json:"pid"` PID int64 `json:"pid"`
} }
@@ -389,7 +379,7 @@ func TestThreading(t *testing.T) {
} }
// Profil-Feed: alle drei Beiträge (Root + Antworten). // 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 { var profile []struct {
PID int64 `json:"pid"` PID int64 `json:"pid"`
} }
@@ -406,7 +396,7 @@ func TestEditEntry(t *testing.T) {
bob := registerAndLogin(t, srv, "bob") bob := registerAndLogin(t, srv, "bob")
pid := createEntry(t, alice, srv, "original") 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. // Eigentümer bearbeitet -> 200, neuer Inhalt sichtbar.
resp := postForm(t, alice, editURL, url.Values{"content": {"korrigiert"}}) resp := postForm(t, alice, editURL, url.Values{"content": {"korrigiert"}})
@@ -433,14 +423,14 @@ func TestEditEntry(t *testing.T) {
} }
// Unbekannte pid -> 404. // 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() resp.Body.Close()
if resp.StatusCode != http.StatusNotFound { if resp.StatusCode != http.StatusNotFound {
t.Fatalf("edit unbekannt: erwartet 404, bekam %d", resp.StatusCode) t.Fatalf("edit unbekannt: erwartet 404, bekam %d", resp.StatusCode)
} }
// Nach Soft-Delete ist der Beitrag nicht mehr bearbeitbar (uid=0) -> 403. // 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 = postForm(t, alice, editURL, url.Values{"content": {"wieder da"}})
resp.Body.Close() resp.Body.Close()
if resp.StatusCode != http.StatusForbidden { if resp.StatusCode != http.StatusForbidden {
@@ -456,7 +446,7 @@ func TestSoftDelete(t *testing.T) {
root := createEntry(t, alice, srv, "wurzel") root := createEntry(t, alice, srv, "wurzel")
reply := createReply(t, alice, srv, "antwort", root) 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. // Fremder darf nicht löschen.
resp := postForm(t, bob, delURL, url.Values{}) 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). // 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 var feed []threadEntry
json.NewDecoder(feedResp.Body).Decode(&feed) json.NewDecoder(feedResp.Body).Decode(&feed)
feedResp.Body.Close() feedResp.Body.Close()
@@ -491,7 +481,7 @@ func TestSoftDelete(t *testing.T) {
} }
// Nicht existierender Beitrag -> 404. // 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() resp.Body.Close()
if resp.StatusCode != http.StatusNotFound { if resp.StatusCode != http.StatusNotFound {
t.Fatalf("delete unbekannt: erwartet 404, bekam %d", resp.StatusCode) t.Fatalf("delete unbekannt: erwartet 404, bekam %d", resp.StatusCode)
@@ -504,21 +494,21 @@ func TestRenameUser(t *testing.T) {
registerAndLogin(t, srv, "bob") registerAndLogin(t, srv, "bob")
// Zu kurz -> 400. // 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() resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest { if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("zu kurz: erwartet 400, bekam %d", resp.StatusCode) t.Fatalf("zu kurz: erwartet 400, bekam %d", resp.StatusCode)
} }
// Bereits vergeben -> 409. // 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() resp.Body.Close()
if resp.StatusCode != http.StatusConflict { if resp.StatusCode != http.StatusConflict {
t.Fatalf("vergeben: erwartet 409, bekam %d", resp.StatusCode) t.Fatalf("vergeben: erwartet 409, bekam %d", resp.StatusCode)
} }
// Gültig -> 200 + neuer Name. // 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() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
t.Fatalf("rename: erwartet 200, bekam %d", resp.StatusCode) 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. // 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() r2.Body.Close()
if r2.StatusCode != http.StatusOK { if r2.StatusCode != http.StatusOK {
t.Fatalf("/u/alice2/info: erwartet 200, bekam %d", r2.StatusCode) 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() r3.Body.Close()
if r3.StatusCode != http.StatusNotFound { if r3.StatusCode != http.StatusNotFound {
t.Fatalf("/u/alice/info: erwartet 404, bekam %d", r3.StatusCode) 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) 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+"/api/user/delete", url.Values{"pass1": {"falsch"}})
resp.Body.Close() resp.Body.Close()
if resp.StatusCode != http.StatusForbidden { if resp.StatusCode != http.StatusForbidden {
t.Fatalf("falsches Passwort: erwartet 403, bekam %d", resp.StatusCode) t.Fatalf("falsches Passwort: erwartet 403, bekam %d", resp.StatusCode)
} }
// Richtiges Passwort -> 200. // 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() resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
t.Fatalf("delete: erwartet 200, bekam %d", resp.StatusCode) t.Fatalf("delete: erwartet 200, bekam %d", resp.StatusCode)
@@ -569,7 +559,7 @@ func TestDeleteUser(t *testing.T) {
// Login danach schlägt fehl. // Login danach schlägt fehl.
fresh := newClient(t) 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"}, "user": {"alice"}, "pass": {"supersecret1"}, "timeout": {"86400"},
}) })
resp.Body.Close() resp.Body.Close()
@@ -613,34 +603,74 @@ func TestLogoutPost(t *testing.T) {
srv := newTestServer(t) srv := newTestServer(t)
c := registerAndLogin(t, srv, "alice") c := registerAndLogin(t, srv, "alice")
resp, err := c.Get(srv.URL + "/auth/logout") if got := getStatus(t, c, srv.URL+"/api/auth/logout"); got != http.StatusMethodNotAllowed {
if err != nil { t.Fatalf("GET logout: erwartet 405, bekam %d", got)
t.Fatal(err)
} }
resp.Body.Close() if got := getStatus(t, c, srv.URL+"/api/auth/sessioninfo"); got != http.StatusOK {
// GET fällt in den FileServer-Catch-All -> 404. Hauptsache: kein Logout. t.Fatalf("GET logout darf nicht ausloggen, sessioninfo: %d", got)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("GET logout: erwartet 404, bekam %d", resp.StatusCode)
} }
resp = postForm(t, c, srv.URL+"/auth/logout", url.Values{}) resp := postForm(t, c, srv.URL+"/api/auth/logout", url.Values{})
resp.Body.Close() resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
t.Fatalf("POST logout: erwartet 200, bekam %d", resp.StatusCode) 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
defer resp.Body.Close() check("unbekannter Pfad", resp, http.StatusNotFound, "api.not_found")
var h struct {
LoggedIn bool `json:"loggedin"` resp, err = http.Get(srv.URL + "/api/entry/create")
if err != nil {
t.Fatal(err)
} }
json.NewDecoder(resp.Body).Decode(&h) check("falsche Methode", resp, http.StatusMethodNotAllowed, "api.method_not_allowed")
if h.LoggedIn {
t.Fatal("nach Logout sollte loggedin=false sein") 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 // TestCrossOriginPostRejected prüft den CSRF-Schutz: POSTs mit fremdem
@@ -650,7 +680,7 @@ func TestCrossOriginPostRejected(t *testing.T) {
c := registerAndLogin(t, srv, "alice") c := registerAndLogin(t, srv, "alice")
post := func(origin string) int { 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())) strings.NewReader(url.Values{"content": {"x"}}.Encode()))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -706,13 +736,13 @@ func TestBlockTOREntry(t *testing.T) {
srv := newTestServer(t) srv := newTestServer(t)
c := registerAndLogin(t, srv, "toruser") 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() resp.Body.Close()
if resp.StatusCode != http.StatusForbidden { if resp.StatusCode != http.StatusForbidden {
t.Fatalf("TOR-Beitrag: erwartet 403, bekam %d", resp.StatusCode) 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() resp.Body.Close()
if resp.StatusCode != http.StatusCreated { if resp.StatusCode != http.StatusCreated {
t.Fatalf("Nicht-TOR-Beitrag: erwartet 201, bekam %d", resp.StatusCode) t.Fatalf("Nicht-TOR-Beitrag: erwartet 201, bekam %d", resp.StatusCode)
+15 -66
View File
@@ -9,7 +9,6 @@ import (
"image/jpeg" "image/jpeg"
_ "image/png" _ "image/png"
"io" "io"
"log"
"math/rand/v2" "math/rand/v2"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
@@ -480,48 +479,26 @@ func handleBump(w http.ResponseWriter, r *http.Request) error {
return nil return nil
} }
// voteTally liefert die Zähler und die eigene Auswahl (selected) für einen Beitrag. // writeTally schreibt die Zähler und die eigene Auswahl (selected) für einen
// Ist uid == 0 (nicht eingeloggt), ist selected immer "none". // Beitrag. Ist uid == 0 (nicht eingeloggt), ist selected immer "none".
func voteTally(pid, uid int64) (left, right int, selected string) { func writeTally(w http.ResponseWriter, pid, uid int64) error {
selected = "none" var left, right int64
if uid != 0 { var selected string
var cur string if err := db.QueryRow(
if err := db.QueryRow(`SELECT mode FROM vote WHERE uid = $1 AND pid = $2`, uid, pid).Scan(&cur); err == nil { `SELECT COUNT(*) FILTER (WHERE mode = 'left'),
selected = cur 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{ writeJSON(w, http.StatusOK, map[string]any{
"pid": pid, "pid": pid,
"left": left, "left": left,
"right": right, "right": right,
"selected": selected, "selected": selected,
}) })
return nil
} }
// handleVotes liest den Abstimmungsstand eines Beitrags (read-only, öffentlich). // 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 { if s, ok := getSession(r); ok {
uid = s.UID uid = s.UID
} }
writeTally(w, pid, uid) return 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
} }
// handleVote gibt die Stimme des angemeldeten Nutzers ab oder schaltet sie um: // 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 writeTally(w, pid, uid)
return nil
} }
const ( const (
+19 -22
View File
@@ -10,23 +10,22 @@ import (
// Fehler-Aufrufstelle verklebt waren -- wie schwer der Fehler ist (Kind), // Fehler-Aufrufstelle verklebt waren -- wie schwer der Fehler ist (Kind),
// was genau passiert ist (Code) und was der Nutzer lesen soll (Msg). // was genau passiert ist (Code) und was der Nutzer lesen soll (Msg).
// //
// Der Sinn der Trennung: derselbe Fehler muss künftig von zwei Adaptern // Der Sinn der Trennung: das Frontend zeigt Msg an und markiert Field, ein
// unterschiedlich dargestellt werden. Der HTML-Pfad braucht Msg und Field, um // Client braucht Code, um ohne Parsen deutscher Strings reagieren zu können,
// ein Formular mit Fehlermarkierung neu zu rendern; ein API-Client braucht // und der HTTP-Status ergibt sich allein aus Kind.
// Code, um ohne Parsen deutscher Strings reagieren zu können.
type Kind uint8 type Kind uint8
const ( const (
KindInternal Kind = iota // Bug oder Infrastruktur -- Ursache nie nach außen KindInternal Kind = iota // Bug oder Infrastruktur -- Ursache nie nach außen
KindInvalid // Eingabe passt nicht KindInvalid // Eingabe passt nicht
KindUnauth // nicht angemeldet KindUnauth // nicht angemeldet
KindForbidden // angemeldet, aber nicht erlaubt KindForbidden // angemeldet, aber nicht erlaubt
KindNotFound // Objekt existiert nicht (oder soll nicht sichtbar sein) KindNotFound // Objekt existiert nicht (oder soll nicht sichtbar sein)
KindConflict // Name vergeben, Doppel-Post KindConflict // Name vergeben, Doppel-Post
KindTooLarge // Upload über dem Limit KindTooLarge // Upload über dem Limit
KindRateLimited // zu viele Anfragen KindRateLimited // zu viele Anfragen
KindUnavailable // Feature nicht konfiguriert KindMethodNotAllowed // Route existiert, aber nicht mit dieser Methode
) )
// Error ist der Fehlertyp, den die Service-Schicht nach oben gibt. Kind ist // 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 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 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 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 // Internal verschluckt die Ursache absichtlich: der Nutzer sieht nie einen
// DB- oder Dateisystemfehler (der Interna und Pfade verraten würde), das Log // DB- oder Dateisystemfehler (der Interna und Pfade verraten würde), das Log
@@ -102,9 +103,7 @@ func asError(err error) *Error {
return Internal(err) return Internal(err)
} }
// statusOf bildet Kind auf HTTP ab. Beide HTTP-Adapter (HTML und JSON) teilen // statusOf bildet Kind auf HTTP ab -- die einzige Stelle, an der das passiert.
// sich diese Zuordnung -- unterschiedliche Statuscodes für denselben Fehler
// wären genau die Divergenz, die die Taxonomie verhindern soll.
func statusOf(k Kind) int { func statusOf(k Kind) int {
switch k { switch k {
case KindInvalid: case KindInvalid:
@@ -121,17 +120,15 @@ func statusOf(k Kind) int {
return http.StatusRequestEntityTooLarge return http.StatusRequestEntityTooLarge
case KindRateLimited: case KindRateLimited:
return http.StatusTooManyRequests return http.StatusTooManyRequests
case KindUnavailable: case KindMethodNotAllowed:
return http.StatusServiceUnavailable return http.StatusMethodNotAllowed
default: default:
return http.StatusInternalServerError return http.StatusInternalServerError
} }
} }
// logInternal protokolliert nur echte Serverfehler -- Validierungs- und // logInternal protokolliert nur echte Serverfehler -- Validierungs- und
// Rechtefehler sind normaler Betrieb und würden das Log zumüllen. Beide // Rechtefehler sind normaler Betrieb und würden das Log zumüllen.
// Adapter rufen das auf, damit ein Fehler unabhängig vom Ausgabeformat
// genau einmal im Log landet.
func logInternal(e *Error) { func logInternal(e *Error) {
if e.Kind != KindInternal { if e.Kind != KindInternal {
return return
-1
View File
@@ -23,7 +23,6 @@ var importTables = []importTable{
"reply_count", "last_activity", "deleted", "bump_count", "last_bump"}, true}, "reply_count", "last_activity", "deleted", "bump_count", "last_bump"}, true},
{"vote", "vote", []string{"id", "uid", "pid", "mode"}, true}, {"vote", "vote", []string{"id", "uid", "pid", "mode"}, true},
{"impression", "impression", []string{"day", "anon_ip", "path", "asn", "hits"}, false}, {"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 // importSQLite kopiert alle Daten aus der SQLite-Datei path in die (per initDB
-6
View File
@@ -12,12 +12,6 @@
# Postgres-Verbindung. PFLICHT. Sonderzeichen im Passwort URL-kodieren. # Postgres-Verbindung. PFLICHT. Sonderzeichen im Passwort URL-kodieren.
KVER_DSN=postgres://kver:passwort@host:5432/kontrollverlust?sslmode=disable 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). # Optional: Pfad zur GeoLite2-ASN-DB im Container (leer = ASN-Auflösung aus).
#KVER_GEOIP_ASN=/app/data/GeoLite2-ASN.mmdb #KVER_GEOIP_ASN=/app/data/GeoLite2-ASN.mmdb
+88 -52
View File
@@ -3,11 +3,13 @@ package main
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"log" "log"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"os/signal" "os/signal"
"runtime/debug"
"syscall" "syscall"
"time" "time"
@@ -98,6 +100,10 @@ func envOr(key, fallback string) string {
// routes baut den HTTP-Handler mit allen Endpunkten. In main() und in den // routes baut den HTTP-Handler mit allen Endpunkten. In main() und in den
// Tests identisch verwendet. // 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 { func routes() http.Handler {
r := chi.NewRouter() r := chi.NewRouter()
// RealIP übernimmt X-Forwarded-For/X-Real-IP vom Reverse-Proxy, damit // RealIP übernimmt X-Forwarded-For/X-Real-IP vom Reverse-Proxy, damit
@@ -107,65 +113,65 @@ func routes() http.Handler {
// spoofbar. // spoofbar.
r.Use(middleware.RealIP) r.Use(middleware.RealIP)
r.Use(middleware.Logger) r.Use(middleware.Logger)
r.Use(middleware.Recoverer) r.Use(recoverJSON)
r.Use(secHeaders) r.Use(secHeaders)
r.Use(checkOrigin) r.Use(checkOrigin)
// --- Auth (öffentlich) --- r.Route("/api", func(r chi.Router) {
// Login und Registrierung pro IP drosseln (Brute-Force-Bremse). bcrypt bremst r.NotFound(api(func(http.ResponseWriter, *http.Request) error {
// zusätzlich, aber das Limit kappt automatisiertes Durchprobieren früh. return NotFound("api.not_found", "Unbekannter Endpunkt.")
r.Group(func(r chi.Router) { }))
r.Use(httprate.LimitByIP(20, time.Minute)) r.MethodNotAllowed(api(func(http.ResponseWriter, *http.Request) error {
r.Post("/auth/login", api(handleLogin)) return MethodNotAllowed("api.method_not_allowed", "Methode nicht erlaubt.")
r.Post("/auth/newuser", api(handleNewUser)) }))
// --- 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), // --- HTML-Seiten ---
// weil TOR-Zugriffe die Go-App direkt erreichen und NICHT über Caddy laufen -- // Mit Impression-Tracking (anonymisiert, siehe stats.go). Nur echte
// ein rein proxy-seitiger Schutz hätte diese Lücke offen gelassen. Caddy // Seitenaufrufe, nicht API/Assets.
// 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.
r.With(trackImpression).Get("/", serveIndex) 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) 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.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 // Hochgeladene Medien + alte statische Assets. nosniff (global via
// secHeaders) verhindert, dass der Browser eine durchgereichte (nicht // secHeaders) verhindert, dass der Browser eine durchgereichte (nicht
@@ -173,12 +179,42 @@ func routes() http.Handler {
// Polyglot/Stored-XSS über Bild-Uploads. // Polyglot/Stored-XSS über Bild-Uploads.
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) 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"))) r.Handle("/*", http.FileServer(http.Dir("web")))
return r 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: // secHeaders setzt Standard-Security-Header auf alle Antworten:
// - nosniff: Browser darf den deklarierten Content-Type nicht überstimmen // - nosniff: Browser darf den deklarierten Content-Type nicht überstimmen
// (wichtig für durchgereichte GIF-Uploads, siehe storeImage). // (wichtig für durchgereichte GIF-Uploads, siehe storeImage).
+1 -1
View File
@@ -16,7 +16,7 @@ const uidKey ctxKey = "uid"
// der Onion-Hidden-Service reicht über einen lokalen Proxy weiter, eine echte // 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 // 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 // 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. // Per KVER_TOR_NET (CIDR) konfigurierbar; leer schaltet die Sperre ab.
var torNet *net.IPNet var torNet *net.IPNet
-147
View File
@@ -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(`<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>Moderation · Meldungen</title>
<link rel="stylesheet" href="/css/app.css">
</head>
<body>
<main>
<h1>Meldungen</h1>
<p class="muted">{{len .}} Meldung(en), offene zuerst.</p>
<table border="1" cellpadding="6" cellspacing="0">
<thead>
<tr><th>Status</th><th>Zeit</th><th>Beitrag</th><th>Autor</th><th>Inhalt (Auszug)</th><th>Grund</th></tr>
</thead>
<tbody>
{{range .}}
<tr>
<td>{{if .Handled}}erledigt{{else}}<strong>offen</strong>{{end}}</td>
<td>{{.When}}</td>
<td><a href="/e/{{.PID}}">{{.PID}}</a></td>
<td>{{if .Username}}{{.Username}}{{else}}[gelöscht/unbekannt]{{end}}</td>
<td>{{.Content}}</td>
<td>{{if .Reason}}{{.Reason}}{{else}}<span class="muted">–</span>{{end}}</td>
</tr>
{{else}}
<tr><td colspan="6" class="muted">Keine Meldungen.</td></tr>
{{end}}
</tbody>
</table>
</main>
</body>
</html>`))
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
}
+86 -189
View File
@@ -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 (`<bereich>.<sache>`), 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 ## 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. Login-Fehler: `401 auth.bad_credentials` (gleich für unbekannten Nutzer und
falsches Passwort).
**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
--- ---
### `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 ```json
{ { "pid": 123456789, "uid": 42, "created_at": 1700000000, "content": "…",
"uid": 123456, "filepath": "static/media/…", "reply_to": 0, "reply_count": 3,
"created_at": 1700000000, "last_activity": 1700000500, "deleted": 0, "bump_count": 1,
"expires": 1700086400, "last_bump": 1700000400, "username": "max", "avatar": "static/media/…" }
"description": ""
}
``` ```
--- | 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` TOR-Anfragen auf `create` werden mit `403 tor.blocked` abgewiesen.
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.
--- ---
### `POST /entry/create` ## Nutzer
Neuen Beitrag erstellen. | Methode | Pfad | Auth | Request | Erfolg |
|---------|------|------|---------|--------|
**Auth:** erforderlich | 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 |
**Request (multipart/form-data):** | GET | `/api/user/info` | ✓ | – | `200 {"uid","username","created_at","last_login","avatar"}` |
| Feld | Typ | Beschreibung | | POST | `/api/user/rename` | ✓ | `user` | `200 {"username"}`; vergeben: `409 user.name_taken` |
|------|-----|--------------| | POST | `/api/user/avatar` | ✓ | multipart: `avatar` | `200 {"avatar"}` |
| `content` | string | Text (max. 1000 Zeichen) | | POST | `/api/user/delete` | ✓ | `pass1` | `200 {"status":"deleted"}`, löscht Konto, Beiträge, Votes, Sessions; falsches Passwort: `403 auth.bad_password` |
| `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
--- ---
### `GET /entry/:pid/votes` ## Statistik
Abstimmungsstand eines Beitrags lesen (read-only). | Methode | Pfad | Erfolg |
|---------|------|--------|
**Response:** | GET | `/api/stats` | `200 {"users","entries"}` |
```json | GET | `/api/stats/detail` | `200 {"users","entries","toplevel","replies","votes":{"left","right"},"impressions_total","visitors_total","daily":[…],"top_asns":[…]}` |
{ "pid": 123, "left": 4, "right": 2, "selected": "left" }
```
`selected` ist `none`, wenn nicht eingeloggt oder keine Stimme abgegeben.
--- ---
### `POST /entry/:pid/vote` ## Seiten (kein JSON)
Eigene Stimme abgeben oder umschalten. `/`, `/e/{pid}`, `/u/{username}` liefern die HTML-Seiten des Frontends und
zählen anonymisierte Impressions. `/static/*` enthält Uploads und Dokumente,
**Auth:** erforderlich alles andere kommt aus `web/`.
**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
--- ---
## Datenbankschema ## Datenbankschema
Postgres; maßgeblich ist die Konstante `schema` in `db.go`. 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
+1 -1
View File
@@ -196,7 +196,7 @@ Volume=kver-media:/app/static/media
Environment=KVER_GEOIP_ASN=/app/data/GeoLite2-ASN.mmdb Environment=KVER_GEOIP_ASN=/app/data/GeoLite2-ASN.mmdb
# Healthcheck hier statt im Containerfile: Podman baut OCI-Images, # Healthcheck hier statt im Containerfile: Podman baut OCI-Images,
# die kennen kein eingebettetes HEALTHCHECK. # 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 HealthInterval=30s
[Service] [Service]
+1 -2
View File
@@ -1,7 +1,6 @@
# TODO # TODO
- Moderation: handled setzen - Moderation (Melden, Queue, Abhilfe) als eigenständiges Projekt
- Moderation: Löschen aus Queue
- Passwort-Reset - Passwort-Reset
- Suche - Suche
- Hashtags / Erwähnungen - Hashtags / Erwähnungen
+45 -24
View File
@@ -223,53 +223,73 @@ func recordImpression(path, anonIP, asn string) {
// handleStatsDetail liefert die Kennzahlen für die Statistik-Seite. Anders als // handleStatsDetail liefert die Kennzahlen für die Statistik-Seite. Anders als
// /stats (Tagline, bei jedem Aufruf) darf das hier ruhig mehrere Aggregate // /stats (Tagline, bei jedem Aufruf) darf das hier ruhig mehrere Aggregate
// rechnen. Öffentlich, kein Auth. // 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 var users, entries, toplevel, votesLeft, votesRight int64
db.QueryRow(`SELECT COUNT(*) FROM account`).Scan(&users) if err := db.QueryRow(
db.QueryRow(`SELECT COUNT(*) FROM entry WHERE deleted = 0`).Scan(&entries) `SELECT (SELECT COUNT(*) FROM account),
db.QueryRow(`SELECT COUNT(*) FROM entry WHERE deleted = 0 AND reply_to = 0`).Scan(&toplevel) (SELECT COUNT(*) FROM entry WHERE deleted = 0),
db.QueryRow(`SELECT COUNT(*) FROM vote WHERE mode = 'left'`).Scan(&votesLeft) (SELECT COUNT(*) FROM entry WHERE deleted = 0 AND reply_to = 0),
db.QueryRow(`SELECT COUNT(*) FROM vote WHERE mode = 'right'`).Scan(&votesRight) (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 var imprTotal, visitorsTotal int64
db.QueryRow(`SELECT COALESCE(SUM(hits), 0)::bigint, COUNT(DISTINCT anon_ip) FROM impression`). if err := db.QueryRow(`SELECT COALESCE(SUM(hits), 0)::bigint, COUNT(DISTINCT anon_ip) FROM impression`).
Scan(&imprTotal, &visitorsTotal) Scan(&imprTotal, &visitorsTotal); err != nil {
return Internal(err)
}
// Tägliche Impressions + grobe Unique-Netze, jüngste 30 Tage zuerst. Das // Tägliche Impressions + grobe Unique-Netze, jüngste 30 Tage zuerst. Das
// Frontend dreht für die Achse auf aufsteigend. // Frontend dreht für die Achse auf aufsteigend.
daily := []map[string]any{} daily := []map[string]any{}
if rows, err := db.Query( rows, err := db.Query(
`SELECT day, SUM(hits)::bigint, COUNT(DISTINCT anon_ip) `SELECT day, SUM(hits)::bigint, COUNT(DISTINCT anon_ip)
FROM impression FROM impression
GROUP BY day GROUP BY day
ORDER BY day DESC ORDER BY day DESC
LIMIT 30`); err == nil { LIMIT 30`)
defer rows.Close() if err != nil {
for rows.Next() { return Internal(err)
var day string }
var hits, visitors int64 defer rows.Close()
rows.Scan(&day, &hits, &visitors) for rows.Next() {
daily = append(daily, map[string]any{"day": day, "impressions": hits, "visitors": visitors}) 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)". // Netze nach Betreiber (autonomes System). Leere asn ('') -> "(unbekannt)".
topASNs := []map[string]any{} topASNs := []map[string]any{}
if rows, err := db.Query( rows, err = db.Query(
`SELECT CASE WHEN asn = '' THEN '(unbekannt)' ELSE asn END AS a, `SELECT CASE WHEN asn = '' THEN '(unbekannt)' ELSE asn END AS a,
SUM(hits)::bigint AS h, SUM(hits)::bigint AS h,
COUNT(DISTINCT anon_ip) COUNT(DISTINCT anon_ip)
FROM impression FROM impression
GROUP BY a GROUP BY a
ORDER BY h DESC ORDER BY h DESC
LIMIT 10`); err == nil { LIMIT 10`)
defer rows.Close() if err != nil {
for rows.Next() { return Internal(err)
var asn string }
var hits, networks int64 defer rows.Close()
rows.Scan(&asn, &hits, &networks) for rows.Next() {
topASNs = append(topASNs, map[string]any{"asn": asn, "hits": hits, "networks": networks}) 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{ writeJSON(w, http.StatusOK, map[string]any{
@@ -283,6 +303,7 @@ func handleStatsDetail(w http.ResponseWriter, r *http.Request) {
"daily": daily, "daily": daily,
"top_asns": topASNs, "top_asns": topASNs,
}) })
return nil
} }
// serveIndex liefert die Startseite. Eigene Route (statt FileServer-Catch-All), // serveIndex liefert die Startseite. Eigene Route (statt FileServer-Catch-All),
+7 -3
View File
@@ -89,14 +89,18 @@ func handleUserPage(w http.ResponseWriter, r *http.Request) error {
// handleStats liefert die Kennzahlen für die Statuszeile unter dem Titel: // handleStats liefert die Kennzahlen für die Statuszeile unter dem Titel:
// registrierte Nutzer und nicht-gelöschte Beiträge. // 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 var users, entries int64
db.QueryRow(`SELECT COUNT(*) FROM account`).Scan(&users) if err := db.QueryRow(
db.QueryRow(`SELECT COUNT(*) FROM entry WHERE deleted = 0`).Scan(&entries) `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{ writeJSON(w, http.StatusOK, map[string]any{
"users": users, "users": users,
"entries": entries, "entries": entries,
}) })
return nil
} }
func handleUserInfo(w http.ResponseWriter, r *http.Request) error { func handleUserInfo(w http.ResponseWriter, r *http.Request) error {
-46
View File
@@ -302,52 +302,6 @@ img.avatar-lg { width: 72px; height: 72px; }
.ghost.selected { font-weight: bold; } .ghost.selected { font-weight: bold; }
.msg { color: red; } .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 <details>-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 { p.warning {
background-color: #faa; background-color: #faa;
padding: 10px 15px; padding: 10px 15px;
+3 -4
View File
@@ -4,16 +4,15 @@
let loggedIn = false; let loggedIn = false;
async function refreshHeader() { async function refreshHeader() {
const h = await api.get("/auth/headerbar"); const me = await api.get("/user/info");
loggedIn = !!h.loggedin; loggedIn = me.ok;
document.getElementById("auth-box").hidden = loggedIn; document.getElementById("auth-box").hidden = loggedIn;
document.getElementById("compose").hidden = !loggedIn; document.getElementById("compose").hidden = !loggedIn;
const bar = document.getElementById("headerbar"); const bar = document.getElementById("headerbar");
if (loggedIn) { if (loggedIn) {
const me = await api.get("/user/info"); const user = encodeURIComponent(me.data.username);
const user = encodeURIComponent(me.username);
bar.innerHTML = `<a class="button" href="/u/${user}">mein Profil</a> <button id="logout-btn">Logout</button>`; bar.innerHTML = `<a class="button" href="/u/${user}">mein Profil</a> <button id="logout-btn">Logout</button>`;
document.getElementById("logout-btn").addEventListener("click", async () => { document.getElementById("logout-btn").addEventListener("click", async () => {
// POST statt GET: als GET wäre Logout per fremdem Link auslösbar (CSRF). // POST statt GET: als GET wäre Logout per fremdem Link auslösbar (CSRF).
+7 -4
View File
@@ -23,13 +23,14 @@ replyForm.addEventListener("submit", async (e) => {
}); });
async function load() { async function load() {
const data = await api.get(`/entry/${pid}/thread`); const res = await api.get(`/entry/${pid}/thread`);
const focus = document.getElementById("focus"); const focus = document.getElementById("focus");
if (data.error) { if (!res.ok) {
focus.textContent = "Beitrag nicht gefunden"; focus.textContent = errMsg(res.data, "Beitrag konnte nicht geladen werden.");
return; return;
} }
const data = res.data;
// Ahnenkette (Root zuerst), dann der fokussierte Beitrag. // Ahnenkette (Root zuerst), dann der fokussierte Beitrag.
const ancestors = document.getElementById("ancestors"); const ancestors = document.getElementById("ancestors");
@@ -40,7 +41,9 @@ async function load() {
focus.appendChild(renderEntry(data.entry)); focus.appendChild(renderEntry(data.entry));
// Eingeloggt? Reply-Box zeigen; eigener, nicht gelöschter Beitrag -> Bearbeiten/Löschen. // 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; document.getElementById("reply-box").hidden = !me.uid;
if (me.uid === data.entry.uid && !data.entry.deleted) { if (me.uid === data.entry.uid && !data.entry.deleted) {
const editBtn = document.createElement("button"); const editBtn = document.createElement("button");
+31 -57
View File
@@ -1,24 +1,28 @@
"use strict"; "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" }; const JSON_HEADERS = { Accept: "application/json" };
async function apiResult(r) {
return { ok: r.ok, status: r.status, data: await r.json().catch(() => ({})) };
}
const api = { const api = {
async get(url) { async get(path) {
const r = await fetch(url, { return apiResult(await fetch(`/api${path}`, {
credentials: "same-origin", credentials: "same-origin",
headers: JSON_HEADERS, headers: JSON_HEADERS,
}); }));
return r.json();
}, },
async post(url, formData) { async post(path, formData) {
const r = await fetch(url, { return apiResult(await fetch(`/api${path}`, {
method: "POST", method: "POST",
body: formData, body: formData,
credentials: "same-origin", credentials: "same-origin",
headers: JSON_HEADERS, headers: JSON_HEADERS,
}); }));
return { ok: r.ok, status: r.status, data: await r.json().catch(() => ({})) };
}, },
}; };
@@ -125,15 +129,6 @@ function renderEntry(e) {
<div class="muted byline"> <div class="muted byline">
<a href="/u/${user}"><img class="avatar avatar-sm" src="${avatarUrl(e.avatar)}" alt="" loading="lazy"></a> <a href="/u/${user}"><img class="avatar avatar-sm" src="${avatarUrl(e.avatar)}" alt="" loading="lazy"></a>
<span><a href="/u/${user}">${escapeHtml(e.username)}</a> · ${when}<span class="cooldown"></span></span> <span><a href="/u/${user}">${escapeHtml(e.username)}</a> · ${when}<span class="cooldown"></span></span>
<details class="report">
<summary>Melden</summary>
<form class="report-form" method="post" action="/entry/${e.pid}/report">
<label>Grund <span class="muted">(optional)</span>
<input type="text" name="reason" maxlength="500" autocomplete="off">
</label>
<button type="submit">Absenden</button>
</form>
</details>
</div> </div>
<div class="threadline-wrapper"> <div class="threadline-wrapper">
<div class="threadline"></div> <div class="threadline"></div>
@@ -154,7 +149,6 @@ function renderEntry(e) {
`; `;
setupVoting(el, e.pid); setupVoting(el, e.pid);
setupBump(el, e); setupBump(el, e);
setupReport(el, e.pid);
el.addEventListener("click", (ev) => { el.addEventListener("click", (ev) => {
if (!ev.target.closest("a, button")) location.href = `/e/${e.pid}`; 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 counts = box.querySelector("span");
const buttons = box.querySelectorAll(".ghost"); const buttons = box.querySelectorAll(".ghost");
function render(state) { function render(res) {
if (state.error) { if (!res.ok) {
counts.textContent = errMsg(state, "Fehler"); counts.textContent = errMsg(res.data, "Fehler");
return; return;
} }
const state = res.data;
counts.textContent = `${state.left} / ${state.right}`; counts.textContent = `${state.left} / ${state.right}`;
buttons.forEach((b) => buttons.forEach((b) =>
b.classList.toggle("selected", b.dataset.mode === state.selected) b.classList.toggle("selected", b.dataset.mode === state.selected)
@@ -181,8 +176,7 @@ function setupVoting(el, pid) {
b.addEventListener("click", async () => { b.addEventListener("click", async () => {
const fd = new FormData(); const fd = new FormData();
fd.append("mode", b.dataset.mode); fd.append("mode", b.dataset.mode);
const res = await api.post(`/entry/${pid}/vote`, fd); render(await api.post(`/entry/${pid}/vote`, fd));
render(res.data);
}) })
); );
@@ -226,38 +220,6 @@ function setupBump(el, e) {
}); });
} }
// setupReport verdrahtet den Melden-Aufklapper. Der <details>-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 // 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. // urlFor(page). Gibt { reset } zurück, um den Feed neu zu laden.
function createFeed(feedEl, sentinelEl, urlFor) { function createFeed(feedEl, sentinelEl, urlFor) {
@@ -269,7 +231,19 @@ function createFeed(feedEl, sentinelEl, urlFor) {
if (loading || done) return; if (loading || done) return;
loading = true; 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) { if (!entries.length) {
done = true; done = true;
const end = document.createElement("p"); const end = document.createElement("p");
+10 -2
View File
@@ -1,6 +1,6 @@
"use strict"; "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 // einfache CSS-Balken (keine Chart-Library, CSP-konform). Balkenbreiten werden
// per CSSOM (el.style.width) gesetzt -> kein Inline-style-Attribut, daher von // per CSSOM (el.style.width) gesetzt -> kein Inline-style-Attribut, daher von
// der strikten CSP nicht betroffen. // der strikten CSP nicht betroffen.
@@ -63,7 +63,15 @@ function renderBars(container, items, formatLabel) {
} }
async function render() { 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 --- // --- Auf einen Blick ---
const overview = document.getElementById("stat-overview"); const overview = document.getElementById("stat-overview");
+6 -4
View File
@@ -41,11 +41,13 @@ document.getElementById("delete-form").addEventListener("submit", async (e) => {
}); });
async function init() { async function init() {
const info = await api.get(`/u/${encodeURIComponent(username)}/info`); const res = await api.get(`/u/${encodeURIComponent(username)}/info`);
if (info.error) { if (!res.ok) {
document.getElementById("profile-name").textContent = "Nutzer nicht gefunden"; document.getElementById("profile-name").textContent =
errMsg(res.data, "Profil konnte nicht geladen werden.");
return; return;
} }
const info = res.data;
document.getElementById("profile-name").textContent = info.username; document.getElementById("profile-name").textContent = info.username;
document.getElementById("profile-avatar").src = avatarUrl(info.avatar); document.getElementById("profile-avatar").src = avatarUrl(info.avatar);
@@ -54,7 +56,7 @@ async function init() {
// Eigene Seite? -> Einstellungen einblenden. // Eigene Seite? -> Einstellungen einblenden.
const me = await api.get("/user/info"); 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; document.getElementById("settings").hidden = !isOwner;
createFeed( createFeed(