Compare commits

..
7 Commits
Author SHA1 Message Date
irrlichtandClaude Opus 5.5 9cac33d167 React-Frontend ausliefern, altes Vanilla-Frontend entfernt
- frontend.go: Seitenrouten liefern die index.html (no-cache), gehashte
  Assets unter /assets dauerhaft cachebar, sonst echtes 404
- /stats als Seite, /stats.html leitet per 301 weiter
- Containerfile: Node-Stage baut das Frontend nach web/
- Vite baut nach ../web (gitignored); web/ (Vanilla-JS) gelöscht
- notes/frontend.md, deploy.md angepasst

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
2026-09-26 23:49:20 +02:00
irrlichtandClaude Opus 5.5 728ac9b45e React-Frontend: Profilseite und Statistik, Dev-Proxy-Fix
- UserPage mit Profilkopf, Einstellungen (Avatar, Name, Konto löschen)
  und Nutzer-Feed im unveränderten Markup
- StatsPage mit Kacheln und Balken wie bisher
- Feed-Container wählbar (section/div) wie im alten Markup
- Vite-Proxy mit changeOrigin: false -- sonst scheitert jeder POST im
  Dev-Betrieb am Origin-Check

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
2026-09-26 23:44:40 +02:00
irrlichtandClaude Opus 5.5 ca65ddcacc React-Frontend: Login, Registrierung, Header, neuer Beitrag
- AuthBox mit Login/Registrierung (Auto-Login nach Registrierung)
- Compose mit Zwischenablage-Bild, Feed springt danach an den Anfang
- Headerbar mit Profil-Link und Logout
- useAuthChanged: me und Votes nach Login/Logout neu laden
- kein Aufblitzen der Login-Box, solange der Anmeldestatus lädt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
2026-09-26 23:38:03 +02:00
irrlichtandClaude Opus 5.5 b82c432bce React-Frontend: Thread-Ansicht mit Antworten, Bearbeiten, Löschen
- EntryPage: Vorfahren, Fokus, Antworten mit unverändertem Markup
- Bearbeiten/Löschen für eigene Beiträge, Antwortformular mit
  Zwischenablage-Bild
- useMe(): eigenes Konto oder null (401 = nicht angemeldet)
- nach Änderungen Thread und Feeds invalidieren

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
2026-09-26 23:34:31 +02:00
irrlichtandClaude Opus 5.5 bd5c59bcad React-Frontend: Feed mit Beitragskarte, Votes und Repost
- Feed per useInfiniteQuery mit Sentinel-Nachladen, Duplikate gefiltert,
  Stopp an der Backend-Seitengrenze
- EntryCard mit unverändertem Markup (inkl. [deleted]-Platzhalter)
- Votes mit optimistischem Umschalten, Rollback bei Fehler
- Repost mit Cooldown-Anzeige und 429-Auswertung
- linkify ohne innerHTML

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
2026-09-26 23:30:17 +02:00
irrlichtandClaude Opus 5.5 37279ac90d React-Frontend: Grundgerüst (Vite, TypeScript, Router, TanStack Query)
- frontend/ mit Vite 8, React 19, React Router 8, TanStack Query 5, TS 7
- api.ts: Fehler allein über HTTP-Status (ApiError), errMsg wie bisher
- types.ts: API-Antworten laut notes/api.md
- Layout mit unverändertem Header-Markup, app.css als Kopie
- Dev-Proxy für /api und /static aufs Go-Backend
- Containerfile: HEALTHCHECK auf /api/stats

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
2026-09-26 23:18:56 +02:00
irrlichtandClaude Opus 5.5 030a5bd943 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
2026-09-26 23:10:02 +02:00
54 changed files with 3084 additions and 1632 deletions
+2
View File
@@ -15,3 +15,5 @@ static/media/
migrate.sh
Containerfile
.containerignore
frontend/node_modules/
web/
+2
View File
@@ -10,3 +10,5 @@ original-kver.db
__pycache__/
static/media/
prod_backup/
frontend/node_modules/
/web/
+12 -2
View File
@@ -1,3 +1,13 @@
# Frontend-Stage: React-Build nach /src/web (siehe frontend/vite.config.ts).
# Erst nur die Paketdateien, damit npm ci bei reinen Code-Änderungen aus dem
# Cache kommt.
FROM docker.io/library/node:24-alpine AS frontend
WORKDIR /src/frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
# Build-Stage: statisches Binary, CGO aus (pgx und modernc.org/sqlite für den
# einmaligen Import sind pures Go).
FROM docker.io/library/golang:1.26-alpine AS build
@@ -12,7 +22,7 @@ FROM docker.io/library/alpine:3.22
RUN adduser -D -u 1000 kver
WORKDIR /app
COPY --from=build /kver /usr/local/bin/kver
COPY web/ web/
COPY --from=frontend /src/web web/
COPY static/ static/
# data: GeoLite2-ASN-DB (und die alte SQLite-DB für den Import);
# static/media: Uploads. Beide als Volume persistieren.
@@ -22,5 +32,5 @@ ENV KVER_ADDR=:8080
EXPOSE 8080
VOLUME ["/app/data", "/app/static/media"]
HEALTHCHECK --interval=30s --timeout=3s \
CMD wget -qO /dev/null http://localhost:8080/entry/feed/0 || exit 1
CMD wget -qO /dev/null http://localhost:8080/api/stats || exit 1
ENTRYPOINT ["/usr/local/bin/kver"]
+7 -7
View File
@@ -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
-15
View File
@@ -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 {
+103 -73
View File
@@ -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)
+15 -72
View File
@@ -9,7 +9,6 @@ import (
"image/jpeg"
_ "image/png"
"io"
"log"
"math/rand/v2"
"mime/multipart"
"net/http"
@@ -151,12 +150,6 @@ func handleUserFeed(w http.ResponseWriter, r *http.Request) error {
return nil
}
// serveEntryPage liefert die statische Focus-Seite; das Frontend lädt die Daten
// dann über /entry/{pid}/thread.
func serveEntryPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "web/entry.html")
}
var (
errEntryNotFound = NotFound("entry.not_found", "Beitrag nicht gefunden")
errEntryNotOwned = Forbidden("entry.not_owner", "Das ist nicht dein Beitrag.")
@@ -480,48 +473,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 +506,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 +548,7 @@ func handleVote(w http.ResponseWriter, r *http.Request) error {
}
}
writeTally(w, pid, uid)
return nil
return writeTally(w, pid, uid)
}
const (
+19 -22
View File
@@ -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
+35
View File
@@ -0,0 +1,35 @@
package main
import (
"net/http"
"strings"
)
// webDir enthält das gebaute React-Frontend (frontend/ -> npm run build):
// index.html plus gehashte Dateien unter assets/.
const webDir = "web"
// serveApp liefert die index.html des Frontends; das Routing übernimmt der
// Client. Nur für die Seitenrouten des Frontends registriert (siehe routes) --
// beliebige andere Pfade bleiben ein echtes 404, statt als Seite zu gelten und
// in der Impression-Statistik zu landen. no-cache, damit nach einem Deploy
// sofort die neue index.html mit den neuen Asset-Namen geladen wird.
func serveApp(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-cache")
http.ServeFile(w, r, webDir+"/index.html")
}
// serveAssets liefert die gebauten JS/CSS-Dateien. Ihre Namen enthalten einen
// Inhalts-Hash, sie ändern sich also nie -> dauerhaft cachebar. Verzeichnisse
// gibt es nicht zu sehen (kein Listing).
func serveAssets() http.Handler {
files := http.FileServer(http.Dir(webDir))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/") {
http.NotFound(w, r)
return
}
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
files.ServeHTTP(w, r)
})
}
+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kontrollverlust</title>
</head>
<body>
<noscript>
<p class="msg">Diese Seite braucht JavaScript.</p>
</noscript>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1373
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "kver-frontend",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"typecheck": "tsc"
},
"dependencies": {
"@tanstack/react-query": "^5.104.0",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react-router": "^8.4.0"
},
"devDependencies": {
"@types/node": "^26.6.3",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.1",
"typescript": "^7.0.2",
"vite": "^8.3.1"
}
}
+66
View File
@@ -0,0 +1,66 @@
import { Link, Outlet, useLocation } from "react-router";
import { api } from "./api";
import { useAuthChanged, useMe } from "./auth";
// Tage ohne Datenverlust (fester Startpunkt, Kontinuität seit der alten
// Version).
const dataLossStart = new Date("2025-12-13T12:44:00").getTime();
function plural(n: number, one: string, many: string) {
return `${n} ${n === 1 ? one : many}`;
}
// Layout ist der gemeinsame Seitenrahmen. Markup und IDs entsprechen dem alten
// Frontend (web/*.html), damit app.css unverändert greift: die Startseite hat
// Tagline und Headerbar, alle anderen Seiten den "zurück zum Feed"-Knopf.
export function Layout() {
const isHome = useLocation().pathname === "/";
const days = Math.floor((Date.now() - dataLossStart) / 86400000);
return (
<>
<header>
<nav id="legal">
<a href="/static/docs/impressum.html">Impressum</a> -{" "}
<a href="/static/docs/datenschutz.html">Datenschutz</a> -{" "}
<a href="/static/docs/beitragsordnung.html">Beitragsordnung</a> -{" "}
<Link to="/stats">Statistik</Link>
</nav>
<Link to="/"><h1>Kontrollverlust</h1></Link>
{isHome ? (
<>
<p id="stats" className="tagline">
Ohne Datenverlust seit {plural(days, "Tag", "Tagen")}
</p>
<Headerbar />
</>
) : (
<div id="headerbar"><Link className="button" to="/">zurück zum Feed</Link></div>
)}
</header>
<main>
<Outlet />
</main>
</>
);
}
// Headerbar der Startseite: angemeldet Profil-Link und Logout, sonst leer.
function Headerbar() {
const me = useMe().data;
const authChanged = useAuthChanged();
if (!me) return <div id="headerbar"></div>;
// POST statt GET: als GET wäre Logout per fremdem Link auslösbar (CSRF).
const logout = async () => {
await api.post("/auth/logout").catch(() => undefined);
await authChanged();
};
return (
<div id="headerbar">
<Link className="button" to={`/u/${encodeURIComponent(me.username)}`}>mein Profil</Link>{" "}
<button id="logout-btn" onClick={logout}>Logout</button>
</div>
);
}
+53
View File
@@ -0,0 +1,53 @@
import type { ApiErrorBody } from "./types";
// ApiError ist jeder Nicht-2xx-Status der API. Ob ein Aufruf geklappt hat,
// entscheidet allein der HTTP-Status; der Body liefert Code und Meldung.
// extra hält Zusatzfelder neben "error" (z. B. retry_after beim Repost).
export class ApiError extends Error {
readonly status: number;
readonly code: string;
readonly field?: string;
readonly fields?: Record<string, string>;
readonly extra: Record<string, unknown>;
constructor(status: number, body: ApiErrorBody | undefined, extra: Record<string, unknown>) {
super(body?.message ?? `HTTP ${status}`);
this.status = status;
this.code = body?.code ?? "unknown";
this.field = body?.field;
this.fields = body?.fields;
this.extra = extra;
}
}
async function request<T>(method: "GET" | "POST", path: string, body?: FormData): Promise<T> {
const r = await fetch(`/api${path}`, {
method,
body,
credentials: "same-origin",
headers: { Accept: "application/json" },
});
// Auch Fehler kommen als JSON; ein kaputter Body (Proxy-Fehlerseite o. Ä.)
// darf die Auswertung des Status nicht verhindern.
const data: unknown = await r.json().catch(() => undefined);
if (!r.ok) {
const { error, ...extra } = (data ?? {}) as { error?: ApiErrorBody } & Record<string, unknown>;
throw new ApiError(r.status, error, extra);
}
return data as T;
}
export const api = {
get: <T>(path: string) => request<T>("GET", path),
post: <T>(path: string, body: FormData = new FormData()) => request<T>("POST", path, body),
};
// errMsg macht aus einem Fehler den Anzeigetext. Liegen mehrere Feldfehler vor
// (Registrierung), werden sie zu einem Satz zusammengezogen. Der code ist
// bewusst nicht Teil der Anzeige -- er ist für Logik da, nicht für Menschen.
export function errMsg(err: unknown, fallback: string): string {
if (!(err instanceof ApiError)) return fallback;
const fields = err.fields ? Object.values(err.fields) : [];
if (fields.length) return fields.join(" ");
return err.message || fallback;
}
-46
View File
@@ -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 <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 {
background-color: #faa;
padding: 10px 15px;
+33
View File
@@ -0,0 +1,33 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ApiError, api } from "./api";
import type { Me } from "./types";
export const meKey = ["me"];
// useMe liefert das eigene Konto oder null, wenn niemand angemeldet ist. 401
// ist hier kein Fehler, sondern die Antwort "nicht angemeldet".
export function useMe() {
return useQuery({
queryKey: meKey,
queryFn: async () => {
try {
return await api.get<Me>("/user/info");
} catch (err) {
if (err instanceof ApiError && err.status === 401) return null;
throw err;
}
},
});
}
// useAuthChanged liefert die Aktion nach Login/Logout: alles, was vom
// Anmeldestatus abhängt, neu laden -- das eigene Konto und die eigene Auswahl
// bei den Votes.
export function useAuthChanged() {
const qc = useQueryClient();
return () =>
Promise.all([
qc.invalidateQueries({ queryKey: meKey }),
qc.invalidateQueries({ queryKey: ["votes"] }),
]);
}
+73
View File
@@ -0,0 +1,73 @@
import { useState, type FormEvent, type MouseEvent } from "react";
import { api, errMsg } from "../api";
import { useAuthChanged } from "../auth";
// AuthBox: Login und Registrierung im Wechsel. Die {" "} bilden die
// Leerzeichen des alten Markups nach -- Überschrift, Inputs und Knöpfe sind
// inline, der Abstand zwischen ihnen ist also sichtbar.
export function AuthBox() {
const [mode, setMode] = useState<"login" | "register">("login");
const [msg, setMsg] = useState("");
const [busy, setBusy] = useState(false);
const authChanged = useAuthChanged();
const show = (which: "login" | "register") => (e: MouseEvent) => {
e.preventDefault();
setMode(which);
setMsg("");
};
// submit sperrt das Formular für die Dauer des Requests und zeigt einen
// Fehler als Meldung; action wirft bei Misserfolg (ApiError).
const submit = (action: (fd: FormData) => Promise<unknown>, fallback: string) =>
async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
setBusy(true);
try {
await action(new FormData(e.currentTarget));
setMsg("");
await authChanged();
} catch (err) {
setMsg(errMsg(err, fallback));
} finally {
setBusy(false);
}
};
const login = submit((fd) => api.post("/auth/login", fd), "Login fehlgeschlagen.");
// Nach der Registrierung direkt mit denselben Daten einloggen, damit der
// Nutzer gleich drin ist. newuser meldet alle Feldfehler auf einmal.
const register = submit(async (fd) => {
await api.post("/auth/newuser", fd);
const creds = new FormData();
creds.append("user", String(fd.get("user")));
creds.append("pass", String(fd.get("pass1")));
creds.append("timeout", "86400");
await api.post("/auth/login", creds);
}, "Registrierung fehlgeschlagen.");
return (
<section id="auth-box">
<form id="login-form" hidden={mode !== "login"} onSubmit={login}>
<h2>Login</h2>{" "}
<input name="user" placeholder="Nutzername" autoComplete="username" />{" "}
<input name="pass" type="password" placeholder="Passwort" autoComplete="current-password" />{" "}
<input name="timeout" type="hidden" value="86400" />{" "}
<button type="submit" disabled={busy}>Einloggen</button>{" "}
<p className="muted">Noch kein Konto? <a href="#" onClick={show("register")}>Registrieren</a></p>
</form>
<form id="register-form" hidden={mode !== "register"} onSubmit={register}>
<h2>Registrieren</h2>{" "}
<input name="user" placeholder="Nutzername" autoComplete="username" />{" "}
<input name="pass1" type="password" placeholder="Passwort" autoComplete="new-password" />{" "}
<input name="pass2" type="password" placeholder="Passwort wiederholen" autoComplete="new-password" />{" "}
<button type="submit" disabled={busy}>Konto erstellen</button>{" "}
<p className="muted">Schon registriert? <a href="#" onClick={show("login")}>Zum Login</a></p>
</form>
<p className="msg" id="auth-msg">{msg}</p>
</section>
);
}
+57
View File
@@ -0,0 +1,57 @@
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { ApiError, api, errMsg } from "../api";
import type { BumpResult, Entry } from "../types";
const day = 86400;
const nowSec = () => Math.floor(Date.now() / 1000);
// cooldownText ist der Hinweis in der Byline: Restzeit auf Stunden
// aufgerundet (mindestens 1 -> nie "0h", solange Restzeit übrig ist).
export function cooldownText(retryAfter: number): string {
return ` · Repost in ${Math.max(1, Math.ceil(retryAfter / 3600))}h`;
}
// useBump hält den Repost-Zustand eines Beitrags. Der Cooldown wächst pro
// Beitrag linear (Backend, siehe handleBump); hier wird nur angezeigt und auf
// 429 reagiert. Eine Live-Aktualisierung gibt es bewusst nicht: der Zustand
// stammt aus den Beitragsdaten beim Laden, plus die Antwort des eigenen Klicks.
// Der Hinweis steht in der Byline, der Knopf unten -- deshalb ein Hook statt
// einer Komponente.
export function useBump(e: Entry) {
const initial = e.last_bump + e.bump_count * day - nowSec();
const [count, setCount] = useState(e.bump_count);
const [retryAfter, setRetryAfter] = useState(e.bump_count > 0 && initial > 0 ? initial : 0);
const [error, setError] = useState("");
const bump = useMutation({
mutationFn: () => api.post<BumpResult>(`/entry/${e.pid}/bump`),
onSuccess: (r) => {
setCount(r.bump_count);
setRetryAfter(r.retry_after || day);
},
onError: (err) => {
if (err instanceof ApiError && err.status === 429) {
setRetryAfter(Number(err.extra.retry_after) || day);
} else {
// 401 (nicht angemeldet) o. Ä. -> Fehlertext zeigen, Knopf frei lassen.
setError(errMsg(err, "Fehler"));
}
},
});
const locked = retryAfter > 0;
return {
cooldown: locked ? cooldownText(retryAfter) : "",
button: (
<button
className="bump"
type="button"
disabled={locked || bump.isPending}
onClick={() => bump.mutate()}
>
{error || `Repost (${count})`}
</button>
),
};
}
+45
View File
@@ -0,0 +1,45 @@
import { useState, type FormEvent } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api, errMsg } from "../api";
import { pasteImage } from "../paste";
import type { CreatedEntry } from "../types";
// Compose: neuer Beitrag. Nach Erfolg springt der Feed zurück auf Seite 0,
// damit der eigene Beitrag oben steht.
export function Compose() {
const qc = useQueryClient();
const [pasteMsg, setPasteMsg] = useState("");
const create = useMutation({
mutationFn: (fd: FormData) => api.post<CreatedEntry>("/entry/create", fd),
});
const onSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
create.mutate(new FormData(form), {
onSuccess: () => {
form.reset();
setPasteMsg("");
qc.resetQueries({ queryKey: ["feed"] });
},
});
};
return (
<section id="compose">
<form
id="entry-form"
onSubmit={onSubmit}
onPaste={(e) => {
if (pasteImage(e)) setPasteMsg("Bild aus Zwischenablage übernommen.");
}}
>
<textarea name="content" rows={4} maxLength={1000} placeholder="Was gibt's Neues?"></textarea>{" "}
<p className="muted" id="paste-msg">{pasteMsg}</p>{" "}
<input name="file" type="file" accept="image/*" />{" "}
<button type="submit" disabled={create.isPending}>Eintrag erstellen</button>
</form>
<p className="msg" id="entry-msg">{create.isError ? errMsg(create.error, "Beitrag fehlgeschlagen.") : ""}</p>
</section>
);
}
+67
View File
@@ -0,0 +1,67 @@
import type { MouseEvent } from "react";
import { Link, useNavigate } from "react-router";
import type { Entry } from "../types";
import { avatarUrl, formatTime, linkify } from "../format";
import { Votes } from "./Votes";
import { useBump } from "./Bump";
// Klick auf die Karte öffnet den Beitrag -- außer der Klick galt einem Link
// oder Knopf darin.
function useOpenOnClick(pid: number) {
const navigate = useNavigate();
return (ev: MouseEvent) => {
if (!(ev.target as Element).closest("a, button")) navigate(`/e/${pid}`);
};
}
export function EntryCard({ entry: e }: { entry: Entry }) {
const open = useOpenOnClick(e.pid);
const when = formatTime(e.created_at);
// Soft-gelöschter Beitrag: nur Platzhalter, damit der Thread navigierbar bleibt.
if (e.deleted) {
return (
<article className="card" onClick={open}>
<div className="muted byline"><span>[deleted] · {when}</span></div>
<div className="threadline-wrapper">
<div className="threadline"></div>
<div className="body">
<div className="content">[deleted]</div>
</div>
</div>
</article>
);
}
return <LiveCard entry={e} when={when} open={open} />;
}
function LiveCard({ entry: e, when, open }: { entry: Entry; when: string; open: (ev: MouseEvent) => void }) {
const bump = useBump(e);
const profile = `/u/${encodeURIComponent(e.username)}`;
return (
<article className="card" onClick={open}>
<div className="muted byline">
<Link to={profile}>
<img className="avatar avatar-sm" src={avatarUrl(e.avatar)} alt="" loading="lazy" />
</Link>
<span>
<Link to={profile}>{e.username}</Link> · {when}
<span className="cooldown">{bump.cooldown}</span>
</span>
</div>
<div className="threadline-wrapper">
<div className="threadline"></div>
<div className="body">
<div className="content">{linkify(e.content)}</div>
{e.filepath && <img src={`/${e.filepath}`} alt="" loading="lazy" />}
<div className="interactions">
<Link className="button reply" to={`/e/${e.pid}`}>Antworten ({e.reply_count})</Link>
<Votes pid={e.pid} />
{bump.button}
</div>
</div>
</div>
</article>
);
}
+64
View File
@@ -0,0 +1,64 @@
import { useEffect, useRef } from "react";
import { useInfiniteQuery } from "@tanstack/react-query";
import { api, errMsg } from "../api";
import type { Entry } from "../types";
import { EntryCard } from "./EntryCard";
// Das Backend deckelt die Seitenzahl bei 100 und liefert darüber wieder
// Seite 100 -- ohne diese Grenze würde der Feed sie endlos wiederholen.
const maxPage = 100;
// Feed lädt Seiten per Infinite-Scroll: queryKey identifiziert den Feed im
// Cache (zum Invalidieren nach eigenen Beiträgen), urlFor(page) baut den
// API-Pfad der Seite. as ist das Container-Element wie im alten Markup:
// <section> auf der Startseite, <div> innerhalb des Profils.
export function Feed({ queryKey, urlFor, as: Container = "section" }: {
queryKey: unknown[];
urlFor: (page: number) => string;
as?: "section" | "div";
}) {
const feed = useInfiniteQuery({
queryKey,
queryFn: ({ pageParam }) => api.get<Entry[]>(urlFor(pageParam)),
initialPageParam: 0,
getNextPageParam: (last, pages) =>
last.length && pages.length <= maxPage ? pages.length : undefined,
});
const sentinel = useRef<HTMLDivElement>(null);
const { hasNextPage, isFetching, isError, fetchNextPage } = feed;
const canLoad = hasNextPage && !isFetching && !isError;
// rootMargin lädt schon kurz vor Erreichen des Sentinels nach. Der Observer
// wird nach jeder geladenen Seite neu angelegt und meldet dabei den
// aktuellen Zustand: füllt die Seite den Viewport nicht, liegt der Sentinel
// noch im Sichtbereich und es wird direkt weitergeladen.
useEffect(() => {
const el = sentinel.current;
if (!el || !canLoad) return;
const observer = new IntersectionObserver(
(items) => {
if (items.some((i) => i.isIntersecting)) fetchNextPage();
},
{ rootMargin: "300px" },
);
observer.observe(el);
return () => observer.disconnect();
}, [canLoad, fetchNextPage]);
// Zwischen zwei Seiten kann ein Beitrag durch einen Repost nach vorn
// rutschen und auf der nächsten Seite erneut auftauchen -- nur einmal zeigen.
const seen = new Set<number>();
const entries = (feed.data?.pages.flat() ?? []).filter((e) => !seen.has(e.pid) && seen.add(e.pid));
return (
<>
<Container id="feed">
{entries.map((e) => <EntryCard key={e.pid} entry={e} />)}
{feed.isError && <p className="msg">{errMsg(feed.error, "Feed konnte nicht geladen werden.")}</p>}
{feed.isSuccess && !hasNextPage && <p className="muted">YOU REACHED THE END!</p>}
</Container>
<div id="sentinel" ref={sentinel}></div>
</>
);
}
+69
View File
@@ -0,0 +1,69 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, errMsg } from "../api";
import type { VoteMode, VoteTally } from "../types";
// toggled rechnet vor, was der Server aus einer Stimme macht (siehe
// handleVote): gleiche Stimme zieht sie zurück, andere wechselt. Damit
// schaltet der Knopf sofort um; die Serverantwort überschreibt danach.
function toggled(t: VoteTally, mode: VoteMode): VoteTally {
const next = { ...t };
if (t.selected !== "none") next[t.selected]--;
if (t.selected === mode) {
next.selected = "none";
} else {
next[mode]++;
next.selected = mode;
}
return next;
}
export function Votes({ pid }: { pid: number }) {
const qc = useQueryClient();
const key = ["votes", pid];
const tally = useQuery({
queryKey: key,
queryFn: () => api.get<VoteTally>(`/entry/${pid}/votes`),
});
const vote = useMutation({
mutationFn: (mode: VoteMode) => {
const fd = new FormData();
fd.append("mode", mode);
return api.post<VoteTally>(`/entry/${pid}/vote`, fd);
},
onMutate: async (mode) => {
await qc.cancelQueries({ queryKey: key });
const prev = qc.getQueryData<VoteTally>(key);
if (prev) qc.setQueryData(key, toggled(prev, mode));
return { prev };
},
onError: (_err, _mode, ctx) => {
if (ctx?.prev) qc.setQueryData(key, ctx.prev);
},
onSuccess: (data) => qc.setQueryData(key, data),
});
const t = tally.data;
let counts = "– / –";
if (vote.isError) counts = errMsg(vote.error, "Fehler");
else if (tally.isError) counts = errMsg(tally.error, "Fehler");
else if (t) counts = `${t.left} / ${t.right}`;
const button = (mode: VoteMode, label: string) => (
<button
className={t?.selected === mode ? "ghost selected" : "ghost"}
data-mode={mode}
onClick={() => vote.mutate(mode)}
>
{label}
</button>
);
return (
<div className="row">
{button("left", "Links")}
<span className="muted">{counts}</span>
{button("right", "Rechts")}
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
import type { ReactNode } from "react";
// avatarUrl liefert den Bildpfad oder das Platzhalterbild bei leerem Avatar.
export function avatarUrl(path: string): string {
return path ? `/${path}` : "/static/assets/no_profile_pic.jpg";
}
export function formatTime(unix: number): string {
return new Date(unix * 1000).toLocaleString("de-DE");
}
const urlPattern = /https?:\/\/[^\s<]+/g;
// linkify macht aus URLs im Text Links und aus Zeilenumbrüchen <br>. React
// escaped den Text selbst -- anders als im alten Frontend gibt es hier kein
// innerHTML und damit keinen Escaping-Schritt, der vergessen werden könnte.
export function linkify(text: string): ReactNode[] {
const out: ReactNode[] = [];
text.split("\n").forEach((line, i) => {
if (i > 0) out.push(<br key={`br${i}`} />);
let last = 0;
for (const m of line.matchAll(urlPattern)) {
const start = m.index;
if (start > last) out.push(line.slice(last, start));
out.push(
<a key={`a${i}-${start}`} href={m[0]} target="_blank" rel="noopener">
{m[0]}
</a>,
);
last = start + m[0].length;
}
if (last < line.length) out.push(line.slice(last));
});
return out;
}
+44
View File
@@ -0,0 +1,44 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider, createBrowserRouter } from "react-router";
import { ApiError } from "./api";
import { Layout } from "./Layout";
import { FeedPage } from "./pages/FeedPage";
import { EntryPage } from "./pages/EntryPage";
import { UserPage } from "./pages/UserPage";
import { StatsPage } from "./pages/StatsPage";
import "./app.css";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
// 4xx ist eine Antwort, kein Aussetzer -- erneut fragen ändert nichts.
retry: (count, err) => !(err instanceof ApiError && err.status < 500) && count < 2,
// Beim Zurückwechseln in den Tab nicht den ganzen Feed neu laden; die
// Daten werden gezielt nach eigenen Aktionen invalidiert.
refetchOnWindowFocus: false,
staleTime: 30_000,
},
},
});
const router = createBrowserRouter([
{
element: <Layout />,
children: [
{ path: "/", element: <FeedPage /> },
{ path: "/e/:pid", element: <EntryPage /> },
{ path: "/u/:username", element: <UserPage /> },
{ path: "/stats", element: <StatsPage /> },
],
},
]);
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</StrictMode>,
);
+153
View File
@@ -0,0 +1,153 @@
import { useState, type FormEvent } from "react";
import { useParams } from "react-router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, errMsg } from "../api";
import { useMe } from "../auth";
import { pasteImage } from "../paste";
import { EntryCard } from "../components/EntryCard";
import type { CreatedEntry, Entry, Thread } from "../types";
// Beim Wechsel zu einem anderen Beitrag frisch anfangen (offene Bearbeitung,
// Meldungen) -- deshalb hängt die Seite per key an der pid.
export function EntryPage() {
const { pid = "" } = useParams();
return <ThreadView key={pid} pid={pid} />;
}
function ThreadView({ pid }: { pid: string }) {
const qc = useQueryClient();
const thread = useQuery({
queryKey: ["thread", pid],
queryFn: () => api.get<Thread>(`/entry/${pid}/thread`),
});
const me = useMe().data;
// Nach eigenen Änderungen den Thread und alle Feeds (Antwortzahl, Inhalt)
// neu laden.
const refresh = () => {
qc.invalidateQueries({ queryKey: ["thread"] });
qc.invalidateQueries({ queryKey: ["feed"] });
};
if (thread.isError) {
return (
<>
<section id="ancestors"></section>
<div id="focus">{errMsg(thread.error, "Beitrag konnte nicht geladen werden.")}</div>
</>
);
}
const data = thread.data;
const isOwner = !!me && !!data && me.uid === data.entry.uid && !data.entry.deleted;
return (
<>
<section id="ancestors">
{data?.ancestors.map((a) => <EntryCard key={a.pid} entry={a} />)}
</section>
<div id="focus">
{data && <EntryCard entry={data.entry} />}
{data && isOwner && <OwnerActions entry={data.entry} onChange={refresh} />}
</div>
<section id="reply-box" hidden={!me}>
<ReplyForm pid={pid} onDone={refresh} />
</section>
<section id="replies">
{data && !data.replies.length && <p className="muted">Noch keine Antworten.</p>}
{data?.replies.map((e) => <EntryCard key={e.pid} entry={e} />)}
</section>
</>
);
}
// OwnerActions: Bearbeiten und Löschen für den eigenen, nicht gelöschten Beitrag.
// Die {" "} bilden die Leerzeichen des alten Markups nach -- Inputs und Knöpfe
// sind inline, der Abstand zwischen ihnen ist also sichtbar.
function OwnerActions({ entry, onChange }: { entry: Entry; onChange: () => void }) {
const [editing, setEditing] = useState(false);
const edit = useMutation({
mutationFn: (fd: FormData) => api.post(`/entry/${entry.pid}/edit`, fd),
onSuccess: () => {
setEditing(false);
onChange();
},
});
const del = useMutation({
mutationFn: () => api.post(`/entry/${entry.pid}/delete`),
onSuccess: onChange,
onError: (err) => alert(errMsg(err, "Löschen fehlgeschlagen.")),
});
const onSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
edit.mutate(new FormData(e.currentTarget));
};
const cancel = () => {
edit.reset();
setEditing(false);
};
return (
<>
<button disabled={editing} onClick={() => setEditing(true)}>Bearbeiten</button>
<button
onClick={() => {
if (confirm("Beitrag wirklich löschen?")) del.mutate();
}}
>
Löschen
</button>
{editing && (
<form onSubmit={onSubmit}>
<textarea name="content" rows={4} maxLength={1000} defaultValue={entry.content}></textarea>{" "}
<button type="submit">Speichern</button>{" "}
<button type="button" className="cancel" onClick={cancel}>Abbrechen</button>{" "}
<p className="msg">{edit.isError ? errMsg(edit.error, "Bearbeiten fehlgeschlagen.") : ""}</p>
</form>
)}
</>
);
}
function ReplyForm({ pid, onDone }: { pid: string; onDone: () => void }) {
const [pasteMsg, setPasteMsg] = useState("");
const reply = useMutation({
mutationFn: (fd: FormData) => api.post<CreatedEntry>("/entry/create", fd),
});
const onSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
const fd = new FormData(form);
fd.append("reply_to", pid);
reply.mutate(fd, {
onSuccess: () => {
form.reset();
setPasteMsg("");
onDone();
},
});
};
return (
<>
<form
id="reply-form"
onSubmit={onSubmit}
onPaste={(e) => {
if (pasteImage(e)) setPasteMsg("Bild aus Zwischenablage übernommen.");
}}
>
<textarea name="content" rows={3} maxLength={1000} placeholder="Antworten…"></textarea>{" "}
<p className="muted" id="reply-paste-msg">{pasteMsg}</p>{" "}
<input name="file" type="file" accept="image/*" />{" "}
<button type="submit" disabled={reply.isPending}>Antworten</button>
</form>
<p className="msg" id="reply-msg">{reply.isError ? errMsg(reply.error, "Antwort fehlgeschlagen.") : ""}</p>
</>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { useMe } from "../auth";
import { AuthBox } from "../components/AuthBox";
import { Compose } from "../components/Compose";
import { Feed } from "../components/Feed";
// Solange der Anmeldestatus noch lädt, weder Login noch Formular zeigen --
// sonst blitzt bei Angemeldeten kurz die Login-Box auf.
export function FeedPage() {
const me = useMe();
return (
<>
{me.isSuccess && (me.data ? <Compose /> : <AuthBox />)}
<Feed queryKey={["feed"]} urlFor={(page) => `/entry/feed/${page}`} />
</>
);
}
+104
View File
@@ -0,0 +1,104 @@
import { useQuery } from "@tanstack/react-query";
import { api, errMsg } from "../api";
import type { StatsDetail } from "../types";
// Statistik-Seite: Zahlen + einfache CSS-Balken (keine Chart-Library).
// Balkenbreiten setzt React per CSSOM (element.style), nicht als
// style-Attribut im HTML -> von der strikten CSP nicht betroffen.
// NumCard ist eine Kennzahl-Kachel: große Zahl + Beschriftung darunter.
function NumCard({ value, label }: { value: number; label: string }) {
return (
<div>
<span className="num">{value}</span>
<span className="muted">{label}</span>
</div>
);
}
// Bars zeichnet eine Liste {label, value} als horizontale Balken. Die Breite
// ist relativ zum größten Wert.
function Bars({ items, formatLabel }: {
items: { label: string; value: number }[];
formatLabel?: (label: string) => string;
}) {
if (items.length === 0) return <p className="muted">Noch keine Daten.</p>;
const max = Math.max(...items.map((it) => it.value), 1);
// Wertespalte für alle Zeilen gleich breit: an der längsten Zahl ausrichten
// (in ch), damit die Tracks bündig sind und nicht je nach Stellenzahl wandern.
const valWidth = Math.max(...items.map((it) => String(it.value).length));
return items.map((it) => (
<div className="bar-row" key={it.label}>
<span className="bar-label">{formatLabel ? formatLabel(it.label) : it.label}</span>
<div className="bar-track">
<div className="bar-fill" style={{ width: `${(it.value / max) * 100}%` }}></div>
</div>
<span className="bar-value" style={{ width: `${valWidth}ch` }}>{it.value}</span>
</div>
));
}
const dayAxis = (day: string) => day.slice(5); // "MM-DD" reicht auf der Achse
export function StatsPage() {
const stats = useQuery({
queryKey: ["stats"],
queryFn: () => api.get<StatsDetail>("/stats/detail"),
});
const s = stats.data;
// Backend liefert die Tage absteigend -> für die Achse drehen.
const daily = s ? s.daily.slice().reverse() : [];
return (
<section>
<h2>Statistik</h2>
<h3>Auf einen Blick</h3>
<div id="stat-overview" className="statgrid">
{stats.isError && <p className="msg">{errMsg(stats.error, "Statistik konnte nicht geladen werden.")}</p>}
{s && (
<>
<NumCard value={s.users} label={s.users === 1 ? "Profil" : "Profile"} />
<NumCard value={s.entries} label="Beiträge" />
<NumCard value={s.toplevel} label="davon Threads" />
<NumCard value={s.replies} label="davon Antworten" />
<NumCard value={s.votes.left} label="Stimmen Links" />
<NumCard value={s.votes.right} label="Stimmen Rechts" />
</>
)}
</div>
<h3>Reichweite</h3>
<p className="muted">
Seitenaufrufe werden mit anonymisierten Netzen gezählt (IPv4 /16, IPv6 /32).
„Netze" ist eine grobe Annäherung an Besucher, keine Personenzählung.
</p>
<div id="stat-reach" className="statgrid">
{s && (
<>
<NumCard value={s.impressions_total} label="Aufrufe gesamt" />
<NumCard value={s.visitors_total} label="Netze gesamt" />
</>
)}
</div>
<h3>Netze nach Betreiber</h3>
<p className="muted">
Aufrufe je autonomem System (Netzbetreiber), aufgelöst über GeoLite2-ASN.
</p>
<div id="stat-asns" className="bars">
{s && <Bars items={s.top_asns.map((a) => ({ label: a.asn, value: a.hits }))} />}
</div>
<h3>Aufrufe pro Tag</h3>
<div id="stat-daily" className="bars">
{s && <Bars items={daily.map((d) => ({ label: d.day, value: d.impressions }))} formatLabel={dayAxis} />}
</div>
<h3>Verschiedene Netze pro Tag</h3>
<div id="stat-networks" className="bars">
{s && <Bars items={daily.map((d) => ({ label: d.day, value: d.visitors }))} formatLabel={dayAxis} />}
</div>
</section>
);
}
+143
View File
@@ -0,0 +1,143 @@
import { useState, type FormEvent } from "react";
import { useNavigate, useParams } from "react-router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, errMsg } from "../api";
import { useAuthChanged, useMe } from "../auth";
import { avatarUrl } from "../format";
import { Feed } from "../components/Feed";
import type { Profile } from "../types";
// Beim Wechsel zu einem anderen Profil frisch anfangen (Meldungen, Formulare).
export function UserPage() {
const { username = "" } = useParams();
return <ProfileView key={username} username={username} />;
}
function ProfileView({ username }: { username: string }) {
const profile = useQuery({
queryKey: ["profile", username],
queryFn: () => api.get<Profile>(`/u/${encodeURIComponent(username)}/info`),
});
const me = useMe().data;
// Nach einem Upload das neue Bild sofort zeigen: Zeitstempel am URL
// umgeht den Browser-Cache.
const [avatarBust, setAvatarBust] = useState("");
const info = profile.data;
let name = "";
if (profile.isError) name = errMsg(profile.error, "Profil konnte nicht geladen werden.");
else if (info) name = info.username;
const isOwner = !!me && !!info && me.username === info.username;
return (
<section id="profile">
<div className="profile-head">
<img
id="profile-avatar"
className="avatar avatar-lg"
src={info ? avatarUrl(info.avatar) + avatarBust : "/static/assets/no_profile_pic.jpg"}
alt=""
/>
<div>
<h2 id="profile-name">{name}</h2>
<p id="profile-info" className="muted">
{info && `Mitglied seit ${new Date(info.created_at * 1000).toLocaleDateString("de-DE")}`}
</p>
</div>
</div>
<div id="settings" hidden={!isOwner}>
{isOwner && <Settings onAvatar={() => setAvatarBust(`?t=${Date.now()}`)} />}
</div>
{info && (
<Feed
as="div"
queryKey={["feed", "user", username]}
urlFor={(page) => `/u/${encodeURIComponent(username)}/feed/${page}`}
/>
)}
</section>
);
}
// useFormAction verdrahtet ein Einstellungsformular: POST auf path, bei Erfolg
// onSuccess mit Formular und Antwort, bei Fehler die Meldung unter dem Formular.
function useFormAction<T>(path: string, fallback: string, onSuccess: (form: HTMLFormElement, data: T) => void) {
const m = useMutation({ mutationFn: (fd: FormData) => api.post<T>(path, fd) });
const onSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
m.mutate(new FormData(form), { onSuccess: (data) => onSuccess(form, data) });
};
return { onSubmit, busy: m.isPending, msg: m.isError ? errMsg(m.error, fallback) : "" };
}
// Settings: nur für den Eigentümer. Die {" "} bilden die Leerzeichen des alten
// Markups nach -- Inputs und Knöpfe sind inline.
function Settings({ onAvatar }: { onAvatar: () => void }) {
const qc = useQueryClient();
const navigate = useNavigate();
const authChanged = useAuthChanged();
// Neues Bild und neuer Name tauchen auch in den Beitragskarten auf -> die
// Feeds und das eigene Konto neu laden.
const refresh = () => {
qc.invalidateQueries({ queryKey: ["feed"] });
qc.invalidateQueries({ queryKey: ["thread"] });
qc.invalidateQueries({ queryKey: ["profile"] });
return authChanged();
};
const avatar = useFormAction<{ avatar: string }>("/user/avatar", "Hochladen fehlgeschlagen.", (form) => {
form.reset();
onAvatar();
refresh();
});
const rename = useFormAction<{ username: string }>("/user/rename", "Umbenennen fehlgeschlagen.", async (_form, data) => {
await refresh();
navigate(`/u/${encodeURIComponent(data.username)}`);
});
const del = useFormAction("/user/delete", "Löschen fehlgeschlagen.", async () => {
await refresh();
navigate("/");
});
return (
<>
<h2>Einstellungen</h2>
<details>
<summary>Profilbild ändern</summary>
<form id="avatar-form" onSubmit={avatar.onSubmit}>
<input name="avatar" type="file" accept="image/*" />{" "}
<button type="submit" disabled={avatar.busy}>Bild hochladen</button>
</form>
<p className="msg" id="avatar-msg">{avatar.msg}</p>
</details>
<details>
<summary>Name ändern</summary>
<form id="rename-form" onSubmit={rename.onSubmit}>
<input name="user" type="text" placeholder="Neuer Nutzername" autoComplete="username" />{" "}
<button type="submit" disabled={rename.busy}>Namen ändern</button>
</form>
<p className="msg" id="rename-msg">{rename.msg}</p>
</details>
<details>
<summary>Konto löschen</summary>
<form
id="delete-form"
onSubmit={(e) => {
if (confirm("Konto wirklich unwiderruflich löschen?")) del.onSubmit(e);
else e.preventDefault();
}}
>
<p className="muted">Löscht dein Konto samt Beiträgen und Votes unwiderruflich.</p>{" "}
<input name="pass1" type="password" placeholder="Passwort bestätigen" autoComplete="current-password" />{" "}
<button type="submit" disabled={del.busy}>Konto endgültig löschen</button>
</form>
<p className="msg" id="delete-msg">{del.msg}</p>
</details>
</>
);
}
+28
View File
@@ -0,0 +1,28 @@
import type { ClipboardEvent } from "react";
// pasteImage erlaubt Strg+V eines Bildes aus der Zwischenablage direkt im
// Formular: das Bild wandert in den File-Input (über DataTransfer, weil sich
// .files nicht direkt setzen lässt). Liegt kein Bild vor (Text o. Ä.), tun wir
// nichts und lassen den normalen Paste durchlaufen -> preventDefault nur, wenn
// wir wirklich ein Bild gegriffen haben. Gibt true zurück, wenn ein Bild
// übernommen wurde.
export function pasteImage(e: ClipboardEvent<HTMLFormElement>): boolean {
const item = [...(e.clipboardData?.items ?? [])].find(
(it) => it.kind === "file" && it.type.startsWith("image/"),
);
const original = item?.getAsFile();
if (!original) return false;
e.preventDefault();
// Browser benennen Clipboard-Bilder uneinheitlich ("grafik.png" o. Ä.) ->
// neu verpacken mit sprechendem Namen. Endung aus dem MIME-Typ, sonst png.
const ext = (original.type.split("/")[1] || "png").toLowerCase();
const file = new File([original], `zwischenablage.${ext}`, { type: original.type });
const input = e.currentTarget.querySelector<HTMLInputElement>("input[name='file']");
if (!input) return false;
const dt = new DataTransfer();
dt.items.add(file);
input.files = dt.files;
return true;
}
+86
View File
@@ -0,0 +1,86 @@
// Typen der JSON-API; Referenz ist notes/api.md. Zeitstempel sind
// Unix-Sekunden.
export interface Entry {
pid: number;
uid: number;
created_at: number;
content: string;
filepath: string;
reply_to: number;
reply_count: number;
last_activity: number;
deleted: number;
bump_count: number;
last_bump: number;
username: string;
avatar: string;
}
export interface Thread {
entry: Entry;
ancestors: Entry[];
replies: Entry[];
}
export type VoteMode = "left" | "right";
export interface VoteTally {
pid: number;
left: number;
right: number;
selected: VoteMode | "none";
}
export interface BumpResult {
pid: number;
bump_count: number;
last_bump: number;
last_activity: number;
retry_after: number;
}
export interface CreatedEntry {
pid: number;
content: string;
filepath: string;
reply_to: number;
}
// GET /api/user/info -- das eigene Konto.
export interface Me {
uid: number;
username: string;
created_at: number;
last_login: number;
avatar: string;
}
// GET /api/u/{username}/info -- öffentliches Profil.
export interface Profile {
uid: number;
username: string;
created_at: number;
avatar: string;
}
export interface StatsDetail {
users: number;
entries: number;
toplevel: number;
replies: number;
votes: { left: number; right: number };
impressions_total: number;
visitors_total: number;
daily: { day: string; impressions: number; visitors: number }[];
top_asns: { asn: string; hits: number; networks: number }[];
}
// Fehlerformat: {"error": {...}}, bei manchen Fehlern mit Zusatzfeldern
// daneben (Repost-Cooldown: retry_after, bump_count).
export interface ApiErrorBody {
code: string;
message: string;
field?: string;
fields?: Record<string, string>;
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "es2022",
"lib": ["es2022", "dom", "dom.iterable"],
"module": "esnext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"isolatedModules": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src", "vite.config.ts"]
}
+29
View File
@@ -0,0 +1,29 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// Im Dev-Betrieb läuft das Go-Backend separat (siehe dev.env, Port 8081).
// Vite reicht /api und /static dorthin durch -- für den Browser bleibt alles
// eine Origin, Session-Cookie und Origin-Check funktionieren unverändert.
//
// changeOrigin: false ist Pflicht: die Kurzform (nur URL) setzt es auf true
// und schreibt den Host-Header auf das Backend um. Dann passt der Origin des
// Browsers nicht mehr zum Host und checkOrigin weist jeden POST als CSRF ab.
const backend = process.env.KVER_BACKEND ?? "http://127.0.0.1:8081";
const proxy = { target: backend, changeOrigin: false };
export default defineConfig({
plugins: [react()],
// Das Go-Backend liefert das Build aus web/ aus (siehe frontend.go).
// emptyOutDir muss explizit an sein, weil das Ziel außerhalb von frontend/
// liegt.
build: {
outDir: "../web",
emptyOutDir: true,
},
server: {
proxy: {
"/api": proxy,
"/static": proxy,
},
},
});
-1
View File
@@ -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
-6
View File
@@ -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
+102 -61
View File
@@ -3,11 +3,13 @@ package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"os"
"os/signal"
"runtime/debug"
"syscall"
"time"
@@ -17,9 +19,9 @@ import (
)
func main() {
// KVER_DIR: Verzeichnis mit web/ und static/. Per Chdir werden alle
// relativen Pfade (auch Uploads) auf einmal verankert -- die App ist damit
// nicht mehr an das Start-CWD gebunden.
// KVER_DIR: Verzeichnis mit web/ (gebautes Frontend) und static/. Per
// Chdir werden alle relativen Pfade (auch Uploads) auf einmal verankert --
// die App ist damit nicht mehr an das Start-CWD gebunden.
if dir := os.Getenv("KVER_DIR"); dir != "" {
if err := os.Chdir(dir); err != nil {
log.Fatalf("KVER_DIR: %v", err)
@@ -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 das Frontend (frontend.go) und
// statische Dateien.
func routes() http.Handler {
r := chi.NewRouter()
// RealIP übernimmt X-Forwarded-For/X-Real-IP vom Reverse-Proxy, damit
@@ -107,78 +113,113 @@ 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))
})
// 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)
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.")
}))
// 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))
// --- 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))
// HTML-Seiten: mit Impression-Tracking (anonymisiert, siehe stats.go). Nur
// echte Seitenaufrufe, nicht API/Assets.
r.With(trackImpression).Get("/", serveIndex)
// --- Entries (öffentlich) ---
r.Get("/entry/feed/{page}", api(handleFeed))
r.Get("/entry/{pid}/votes", api(handleVotes))
r.Get("/entry/{pid}/thread", api(handleThread))
// --- 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)
// --- User (öffentlich) ---
r.Get("/u/{username}/info", api(handleUserPage))
r.Get("/u/{username}/feed/{page}", api(handleUserFeed))
// 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))
// --- 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
// re-kodierte) Datei als HTML/JS interpretiert -> schließt
// Polyglot/Stored-XSS über Bild-Uploads.
// --- Frontend (React) ---
// Die Seitenrouten entsprechen denen des Client-Routers (frontend/src/
// main.tsx) und liefern alle dieselbe index.html. Impression-Tracking
// (anonymisiert, siehe stats.go) nur auf echten Seitenaufrufen, nicht auf
// API/Assets; die Statistik-Seite zählt wie bisher nicht mit.
r.With(trackImpression).Get("/", serveApp)
r.With(trackImpression).Get("/e/{pid}", serveApp)
r.With(trackImpression).Get("/u/{username}", serveApp)
r.Get("/stats", serveApp)
// Alte Adresse der Statistik-Seite (Lesezeichen, Links von außen).
r.Get("/stats.html", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/stats", http.StatusMovedPermanently)
})
r.Handle("/assets/*", serveAssets())
// Hochgeladene Medien + statische Dateien (Schriften, Rechtstexte).
// nosniff (global via secHeaders) verhindert, dass der Browser eine
// durchgereichte (nicht re-kodierte) Datei als HTML/JS interpretiert ->
// schließt Polyglot/Stored-XSS über Bild-Uploads.
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
// Neues JS-Frontend
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).
+1 -1
View File
@@ -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
-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
### `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
+6 -3
View File
@@ -27,8 +27,11 @@ Beim ersten Deployment: DB und Medien einmalig in die Volumes kopieren
podman build -t kver .
```
Multi-Stage-Build (`Containerfile`): statisches Go-Binary (CGO aus), Laufzeit-
Image ist Alpine mit unprivilegiertem Nutzer.
Multi-Stage-Build (`Containerfile`): das React-Frontend wird in einer
Node-Stage gebaut (`npm ci && npm run build`), das Go-Binary statisch (CGO
aus); das Laufzeit-Image ist Alpine mit unprivilegiertem Nutzer und enthält
nur Binary, gebautes Frontend (`web/`) und `static/`. Auf dem Server wird
also weder Node noch Go gebraucht, nur Podman.
Die Warnung `HEALTHCHECK is not supported for OCI image format` ist erwartbar:
Podman baut OCI-Images, dort gibt es kein eingebettetes HEALTHCHECK. Der
@@ -196,7 +199,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]
+46
View File
@@ -0,0 +1,46 @@
# Frontend (React)
Quellcode in `frontend/`: Vite, React, TypeScript, React Router, TanStack Query.
**Optik:** bewusst brutalistisch-roh. Markup, IDs und Klassen entsprechen dem
früheren Vanilla-Frontend, `frontend/src/app.css` ist das einzige Stylesheet.
Umbauten ändern Funktion, nicht Aussehen.
## Entwickeln
Zwei Terminals:
```sh
set -a; . ./dev.env; set +a; go run . # Backend auf :8081
cd frontend && npm run dev # http://localhost:5173
```
Vite reicht `/api` und `/static` ans Backend durch (`frontend/vite.config.ts`,
`changeOrigin: false` ist Pflicht, sonst scheitert jeder POST am Origin-Check).
## Bauen
`npm run build` in `frontend/` prüft die Typen und schreibt nach `../web/`
(gitignored). Von dort liefert das Go-Backend aus (`frontend.go`), lokal also
auch ohne Vite: `npm run build`, dann `go run .`.
## Aufbau
| Datei | Zweck |
|-------|-------|
| `src/api.ts` | API-Client; jeder Nicht-2xx-Status wird zu `ApiError` |
| `src/types.ts` | Typen der API-Antworten (Referenz: `notes/api.md`) |
| `src/auth.ts` | `useMe()` (eigenes Konto oder `null`), `useAuthChanged()` |
| `src/main.tsx` | Router, QueryClient |
| `src/Layout.tsx` | Header, Headerbar |
| `src/pages/*` | Startseite, Thread, Profil, Statistik |
| `src/components/*` | Feed, Beitragskarte, Votes, Repost, Login, Beitrag verfassen |
Seitenrouten des Clients (`/`, `/e/:pid`, `/u/:username`, `/stats`) müssen
auch in `routes()` (main.go) stehen, sonst liefert ein direkter Aufruf 404.
## Impressions
Das Backend zählt Seitenaufrufe beim Ausliefern der `index.html`. Navigation
innerhalb der App lädt keine Seite mehr vom Server und wird deshalb nicht
gezählt -- nur der erste Aufruf bzw. ein Reload.
+1 -2
View File
@@ -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
+45 -31
View File
@@ -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,11 +303,5 @@ func handleStatsDetail(w http.ResponseWriter, r *http.Request) {
"daily": daily,
"top_asns": topASNs,
})
}
// serveIndex liefert die Startseite. Eigene Route (statt FileServer-Catch-All),
// damit trackImpression nur echte Seitenaufrufe von "/" zählt und nicht jedes
// Asset.
func serveIndex(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "web/index.html")
return nil
}
+7 -9
View File
@@ -63,12 +63,6 @@ func idFromUsername(username string) int64 {
return id
}
// serveUserPage liefert die statische Profilseite; das Frontend lädt die Daten
// dann über /u/{username}/info und /u/{username}/feed/{page}.
func serveUserPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "web/user.html")
}
func handleUserPage(w http.ResponseWriter, r *http.Request) error {
username := chi.URLParam(r, "username")
@@ -89,14 +83,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 {
-6
View File
@@ -1,6 +0,0 @@
# Kontrollverlust — Responsible Disclosure
# Siehe auch Beitragsordnung §7 (Technische Regeln).
Contact: mailto:stress@bnd.wtf
Expires: 2027-06-15T00:00:00.000Z
Preferred-Languages: de, en
Canonical: https://kver.xyz/.well-known/security.txt
-42
View File
@@ -1,42 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kontrollverlust</title>
<link rel="stylesheet" href="/css/app.css">
</head>
<body>
<header>
<nav id="legal">
<a href="/static/docs/impressum.html">Impressum</a> -
<a href="/static/docs/datenschutz.html">Datenschutz</a> -
<a href="/static/docs/beitragsordnung.html">Beitragsordnung</a> -
<a href="/stats.html">Statistik</a>
</nav>
<a href="/"><h1>Kontrollverlust</h1></a>
<div id="headerbar"><a class="button" href="/">zurück zum Feed</a></div>
</header>
<main>
<section id="ancestors"></section>
<div id="focus"></div>
<!-- Nur für eingeloggte Nutzer -->
<section id="reply-box" hidden>
<form id="reply-form">
<textarea name="content" rows="3" maxlength="1000" placeholder="Antworten…"></textarea>
<p class="muted" id="reply-paste-msg"></p>
<input name="file" type="file" accept="image/*">
<button type="submit">Antworten</button>
</form>
<p class="msg" id="reply-msg"></p>
</section>
<section id="replies"></section>
</main>
<script src="/js/feed.js"></script>
<script src="/js/entry.js"></script>
</body>
</html>
-69
View File
@@ -1,69 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kontrollverlust</title>
<link rel="stylesheet" href="/css/app.css">
</head>
<body>
<header>
<nav id="legal">
<a href="/static/docs/impressum.html">Impressum</a> -
<a href="/static/docs/datenschutz.html">Datenschutz</a> -
<a href="/static/docs/beitragsordnung.html">Beitragsordnung</a> -
<a href="/stats.html">Statistik</a>
</nav>
<a href="/"><h1>Kontrollverlust</h1></a>
<p id="stats" class="tagline"></p>
<div id="headerbar"></div>
</header>
<main>
<noscript>
<p class="msg">Diese Seite braucht JavaScript.</p>
</noscript>
<!-- Login / Registrierung -->
<section id="auth-box">
<form id="login-form">
<h2>Login</h2>
<input name="user" placeholder="Nutzername" autocomplete="username">
<input name="pass" type="password" placeholder="Passwort" autocomplete="current-password">
<input name="timeout" type="hidden" value="86400">
<button type="submit">Einloggen</button>
<p class="muted">Noch kein Konto? <a href="#" id="show-register">Registrieren</a></p>
</form>
<form id="register-form" hidden>
<h2>Registrieren</h2>
<input name="user" placeholder="Nutzername" autocomplete="username">
<input name="pass1" type="password" placeholder="Passwort" autocomplete="new-password">
<input name="pass2" type="password" placeholder="Passwort wiederholen" autocomplete="new-password">
<button type="submit">Konto erstellen</button>
<p class="muted">Schon registriert? <a href="#" id="show-login">Zum Login</a></p>
</form>
<p class="msg" id="auth-msg"></p>
</section>
<!-- Neuer Beitrag -->
<section id="compose" hidden>
<form id="entry-form">
<textarea name="content" rows="4" maxlength="1000" placeholder="Was gibt's Neues?"></textarea>
<p class="muted" id="paste-msg"></p>
<input name="file" type="file" accept="image/*">
<button type="submit">Eintrag erstellen</button>
</form>
<p class="msg" id="entry-msg"></p>
</section>
<!-- Feed -->
<section id="feed"></section>
<div id="sentinel"></div>
</main>
<script src="/js/feed.js"></script>
<script src="/js/app.js"></script>
</body>
</html>
-123
View File
@@ -1,123 +0,0 @@
"use strict";
// --- Headerbar (eingeloggt?) ---
let loggedIn = false;
async function refreshHeader() {
const h = await api.get("/auth/headerbar");
loggedIn = !!h.loggedin;
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);
bar.innerHTML = `<a class="button" href="/u/${user}">mein Profil</a> <button id="logout-btn">Logout</button>`;
document.getElementById("logout-btn").addEventListener("click", async () => {
// POST statt GET: als GET wäre Logout per fremdem Link auslösbar (CSRF).
await api.post("/auth/logout", new FormData());
await refreshHeader();
});
} else {
bar.innerHTML = "";
showAuth("login");
}
}
// --- Login / Registrierung ---
const loginForm = document.getElementById("login-form");
const registerForm = document.getElementById("register-form");
const authMsg = document.getElementById("auth-msg");
function showAuth(which) {
loginForm.hidden = which !== "login";
registerForm.hidden = which !== "register";
authMsg.textContent = "";
}
document.getElementById("show-register").addEventListener("click", (e) => {
e.preventDefault();
showAuth("register");
});
document.getElementById("show-login").addEventListener("click", (e) => {
e.preventDefault();
showAuth("login");
});
loginForm.addEventListener("submit", async (e) => {
e.preventDefault();
const res = await api.post("/auth/login", new FormData(e.target));
if (res.ok) {
await refreshHeader();
} else {
authMsg.textContent = errMsg(res.data, "Login fehlgeschlagen.");
}
});
registerForm.addEventListener("submit", async (e) => {
e.preventDefault();
const data = new FormData(e.target);
const res = await api.post("/auth/newuser", data);
if (!res.ok) {
// newuser meldet alle Feldfehler auf einmal (error.fields).
authMsg.textContent = errMsg(res.data, "Registrierung fehlgeschlagen.");
return;
}
// Direkt mit denselben Daten einloggen, damit der Nutzer gleich drin ist.
const login = new FormData();
login.append("user", data.get("user"));
login.append("pass", data.get("pass1"));
login.append("timeout", "86400");
await api.post("/auth/login", login);
await refreshHeader();
});
// --- Feed ---
const feed = createFeed(
document.getElementById("feed"),
document.getElementById("sentinel"),
(page) => `/entry/feed/${page}`
);
// --- Neuer Beitrag ---
document.getElementById("entry-form").addEventListener("submit", async (e) => {
e.preventDefault();
const res = await api.post("/entry/create", new FormData(e.target));
const msg = document.getElementById("entry-msg");
if (res.ok) {
msg.textContent = "";
document.getElementById("paste-msg").textContent = "";
e.target.reset();
feed.reset();
} else {
msg.textContent = errMsg(res.data, "Beitrag fehlgeschlagen.");
}
});
// --- Bild aus der Zwischenablage einfügen ---
setupClipboardPaste(
document.getElementById("entry-form"),
document.getElementById("paste-msg")
);
// --- Statuszeile unter dem Titel ---
// Tage ohne Datenverlust (fester Startpunkt, Kontinuität seit der alten
// Version).
const dataLossStart = new Date("2025-12-13T12:44:00").getTime();
function plural(n, one, many) {
return `${n} ${n === 1 ? one : many}`;
}
function renderStats() {
const el = document.getElementById("stats");
if (!el) return;
const days = Math.floor((Date.now() - dataLossStart) / 86400000);
el.textContent = `Ohne Datenverlust seit ${plural(days, "Tag", "Tagen")}`;
}
// --- Start ---
refreshHeader();
renderStats();
-94
View File
@@ -1,94 +0,0 @@
"use strict";
// pid aus dem Pfad /e/<pid>.
const pid = location.pathname.split("/")[2] || "";
const replyForm = document.getElementById("reply-form");
setupClipboardPaste(replyForm, document.getElementById("reply-paste-msg"));
replyForm.addEventListener("submit", async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
fd.append("reply_to", pid);
const res = await api.post("/entry/create", fd);
if (res.ok) {
e.target.reset();
document.getElementById("reply-msg").textContent = "";
document.getElementById("reply-paste-msg").textContent = "";
load();
} else {
document.getElementById("reply-msg").textContent =
errMsg(res.data, "Antwort fehlgeschlagen.");
}
});
async function load() {
const data = await api.get(`/entry/${pid}/thread`);
const focus = document.getElementById("focus");
if (data.error) {
focus.textContent = "Beitrag nicht gefunden";
return;
}
// Ahnenkette (Root zuerst), dann der fokussierte Beitrag.
const ancestors = document.getElementById("ancestors");
ancestors.innerHTML = "";
data.ancestors.forEach((a) => ancestors.appendChild(renderEntry(a)));
focus.innerHTML = "";
focus.appendChild(renderEntry(data.entry));
// Eingeloggt? Reply-Box zeigen; eigener, nicht gelöschter Beitrag -> Bearbeiten/Löschen.
const me = await api.get("/user/info");
document.getElementById("reply-box").hidden = !me.uid;
if (me.uid === data.entry.uid && !data.entry.deleted) {
const editBtn = document.createElement("button");
editBtn.textContent = "Bearbeiten";
editBtn.addEventListener("click", () => {
editBtn.disabled = true;
const form = document.createElement("form");
form.innerHTML = `
<textarea name="content" rows="4" maxlength="1000"></textarea>
<button type="submit">Speichern</button>
<button type="button" class="cancel">Abbrechen</button>
<p class="msg"></p>`;
form.querySelector("textarea").value = data.entry.content;
form.addEventListener("submit", async (ev) => {
ev.preventDefault();
const res = await api.post(`/entry/${pid}/edit`, new FormData(form));
if (res.ok) load();
else form.querySelector(".msg").textContent =
errMsg(res.data, "Bearbeiten fehlgeschlagen.");
});
form.querySelector(".cancel").addEventListener("click", load);
focus.appendChild(form);
});
const delBtn = document.createElement("button");
delBtn.textContent = "Löschen";
delBtn.addEventListener("click", async () => {
if (!confirm("Beitrag wirklich löschen?")) return;
const res = await api.post(`/entry/${pid}/delete`, new FormData());
if (res.ok) load();
else alert(errMsg(res.data, "Löschen fehlgeschlagen."));
});
focus.appendChild(editBtn);
focus.appendChild(delBtn);
}
// Direkte Antworten.
const replies = document.getElementById("replies");
replies.innerHTML = "";
if (!data.replies.length) {
const p = document.createElement("p");
p.className = "muted";
p.textContent = "Noch keine Antworten.";
replies.appendChild(p);
} else {
data.replies.forEach((e) => replies.appendChild(renderEntry(e)));
}
}
load();
-315
View File
@@ -1,315 +0,0 @@
"use strict";
// Die API antwortet ausschließlich mit JSON; der Accept-Header sagt das explizit.
const JSON_HEADERS = { Accept: "application/json" };
const api = {
async get(url) {
const r = await fetch(url, {
credentials: "same-origin",
headers: JSON_HEADERS,
});
return r.json();
},
async post(url, formData) {
const r = await fetch(url, {
method: "POST",
body: formData,
credentials: "same-origin",
headers: JSON_HEADERS,
});
return { ok: r.ok, status: r.status, data: await r.json().catch(() => ({})) };
},
};
// errMsg zieht die Nutzermeldung aus dem Fehlerformat der API:
// {"error": {"code", "message", "field", "fields"}}. Liegen mehrere
// Feldfehler vor (Registrierung), werden sie zu einem Satz zusammengezogen.
// Der code ist bewusst nicht Teil der Anzeige -- er ist für Logik da, nicht
// für Menschen.
function errMsg(data, fallback) {
const e = data && data.error;
if (!e) return fallback;
const fields = e.fields ? Object.values(e.fields) : [];
if (fields.length) return fields.join(" ");
return e.message || fallback;
}
// Escaped für Text- UND Attributkontext. Der frühere textContent->innerHTML-
// Trick ließ Anführungszeichen stehen (in Textknoten harmlos); linkify steckt
// den Wert aber in ein href="..."-Attribut, wo ein " ausbrechen würde.
function escapeHtml(s) {
return String(s ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
// avatarUrl liefert den Bildpfad oder das Platzhalterbild bei leerem Avatar.
function avatarUrl(path) {
return path ? `/${path}` : "/static/assets/no_profile_pic.jpg";
}
function linkify(s) {
const escaped = escapeHtml(s);
const url = /https?:\/\/[^\s<]+/g;
return escaped
.replace(url, (m) => `<a href="${m}" target="_blank" rel="noopener">${m}</a>`)
.replace(/\n/g, "<br>");
}
// setupClipboardPaste erlaubt Strg+V eines Bildes aus der Zwischenablage direkt
// im Formular: das Bild wandert in den File-Input (über DataTransfer, weil sich
// .files nicht direkt setzen lässt). Liegt kein Bild vor (Text o. Ä.), tun wir
// nichts und lassen den normalen Paste durchlaufen -> preventDefault nur, wenn
// wir wirklich ein Bild gegriffen haben. msgEl (optional) zeigt den Hinweis.
function setupClipboardPaste(form, msgEl) {
form.addEventListener("paste", (e) => {
const item = [...(e.clipboardData?.items ?? [])].find(
(it) => it.kind === "file" && it.type.startsWith("image/")
);
if (!item) return;
const original = item.getAsFile();
if (!original) return;
e.preventDefault();
// Browser benennt Clipboard-Bilder uneinheitlich ("grafik.png" o. Ä.) -> neu
// verpacken mit sprechendem Namen. Endung aus dem MIME-Typ, sonst png.
const ext = (original.type.split("/")[1] || "png").toLowerCase();
const file = new File([original], `zwischenablage.${ext}`, { type: original.type });
const input = form.querySelector("input[name='file']");
const dt = new DataTransfer();
dt.items.add(file);
input.files = dt.files;
if (msgEl) msgEl.textContent = "Bild aus Zwischenablage übernommen.";
});
}
function renderEntry(e) {
const el = document.createElement("article");
el.className = "card";
const when = new Date(e.created_at * 1000).toLocaleString("de-DE");
// Soft-gelöschter Beitrag: nur Platzhalter, damit der Thread navigierbar bleibt.
if (e.deleted) {
el.innerHTML = `
<div class="muted byline"><span>[deleted] · ${when}</span></div>
<div class="threadline-wrapper">
<div class="threadline"></div>
<div class="body">
<div class="content">[deleted]</div>
</div>
</div>
`;
el.addEventListener("click", (ev) => {
if (!ev.target.closest("a, button")) location.href = `/e/${e.pid}`;
});
return el;
}
const user = encodeURIComponent(e.username);
let media = "";
if (e.filepath) {
media = `<img src="/${e.filepath}" alt="" loading="lazy">`;
}
// Antwort- und Repost-Zahl wandern in die Buttons unten; in der Byline bleibt
// nur Name, Zeit und (im Cooldown) der Hinweis bis zum nächsten Repost.
el.innerHTML = `
<div class="muted byline">
<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>
<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 class="threadline-wrapper">
<div class="threadline"></div>
<div class="body">
<div class="content">${linkify(e.content)}</div>
${media}
<div class="interactions">
<a class="button reply" href="/e/${e.pid}">Antworten (${e.reply_count})</a>
<div class="row">
<button class="ghost" data-mode="left">Links</button>
<span class="muted">– / –</span>
<button class="ghost" data-mode="right">Rechts</button>
</div>
<button class="bump" type="button">Repost (${e.bump_count})</button>
</div>
</div>
</div>
`;
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}`;
});
return el;
}
function setupVoting(el, pid) {
const box = el.querySelector(".row");
const counts = box.querySelector("span");
const buttons = box.querySelectorAll(".ghost");
function render(state) {
if (state.error) {
counts.textContent = errMsg(state, "Fehler");
return;
}
counts.textContent = `${state.left} / ${state.right}`;
buttons.forEach((b) =>
b.classList.toggle("selected", b.dataset.mode === state.selected)
);
}
buttons.forEach((b) =>
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);
})
);
api.get(`/entry/${pid}/votes`).then(render);
}
// setupBump verdrahtet den Bump-Button. Der Cooldown wächst pro Beitrag linear
// (Backend, siehe handleBump); hier wird nur angezeigt und auf 429 reagiert. Eine
// Live-Aktualisierung gibt es bewusst nicht: der Zustand stammt aus den
// Beitragsdaten beim Laden, plus die Antwort des eigenen Klicks.
function setupBump(el, e) {
const btn = el.querySelector(".bump");
if (!btn) return;
const cooldown = el.querySelector(".cooldown");
// sekunden bis zum nächsten erlaubten Bump aus den geladenen Daten.
const nowSec = () => Math.floor(Date.now() / 1000);
// lock sperrt den Button; die Restzeit steht jetzt oben in der Byline, auf
// Stunden aufgerundet (mindestens 1 -> nie "0h", solange Restzeit übrig ist).
function lock(retryAfter) {
btn.disabled = true;
const hours = Math.max(1, Math.ceil(retryAfter / 3600));
if (cooldown) cooldown.textContent = ` · Repost in ${hours}h`;
}
const remaining = e.last_bump + e.bump_count * 86400 - nowSec();
if (e.bump_count > 0 && remaining > 0) lock(remaining);
btn.addEventListener("click", async () => {
const res = await api.post(`/entry/${e.pid}/bump`, new FormData());
if (res.ok) {
btn.textContent = `Repost (${res.data.bump_count})`;
lock(res.data.retry_after || 86400);
} else if (res.status === 429) {
lock(res.data.retry_after || 86400);
} else {
// 401 (nicht angemeldet) o. Ä. -> Fehlertext zeigen, Button frei lassen.
btn.textContent = errMsg(res.data, "Fehler");
}
});
}
// 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
// urlFor(page). Gibt { reset } zurück, um den Feed neu zu laden.
function createFeed(feedEl, sentinelEl, urlFor) {
let page = 0;
let loading = false;
let done = false;
async function loadMore() {
if (loading || done) return;
loading = true;
const entries = await api.get(urlFor(page));
if (!entries.length) {
done = true;
const end = document.createElement("p");
end.className = "muted";
end.textContent = "YOU REACHED THE END!";
feedEl.appendChild(end);
} else {
entries.forEach((e) => feedEl.appendChild(renderEntry(e)));
page++;
}
loading = false;
// Der IntersectionObserver feuert nur bei Zustandswechseln. Füllt die
// gerade geladene Seite den Viewport nicht über den Sentinel hinaus, gäbe
// es keinen Wechsel und nie ein Nachladen -> hier aktiv weiterladen, bis
// der Sentinel aus dem Sichtbereich wandert oder das Ende erreicht ist.
if (!done && sentinelVisible()) loadMore();
}
function sentinelVisible() {
return sentinelEl.getBoundingClientRect().top <= window.innerHeight;
}
function reset() {
page = 0;
done = false;
feedEl.innerHTML = "";
loadMore();
}
// rootMargin lädt schon kurz vor Erreichen des Sentinels nach (flüssiger und
// robust gegen den Fall eines 0-hohen Sentinels exakt am Dokumentende).
const observer = new IntersectionObserver(
(items) => {
if (items.some((i) => i.isIntersecting)) loadMore();
},
{ rootMargin: "300px" }
);
observer.observe(sentinelEl);
loadMore();
return { reset };
}
-108
View File
@@ -1,108 +0,0 @@
"use strict";
// Statistik-Seite: holt die Aggregate von /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.
function plural(n, one, many) {
return `${n} ${n === 1 ? one : many}`;
}
// numCard baut eine Kennzahl-Kachel: große Zahl + Beschriftung darunter.
function numCard(value, label) {
const cell = document.createElement("div");
const num = document.createElement("span");
num.className = "num";
num.textContent = value;
const cap = document.createElement("span");
cap.className = "muted";
cap.textContent = label;
cell.append(num, cap);
return cell;
}
// renderBars zeichnet eine Liste {label, value} als horizontale Balken. Die
// Breite ist relativ zum größten Wert.
function renderBars(container, items, formatLabel) {
container.textContent = "";
if (items.length === 0) {
const p = document.createElement("p");
p.className = "muted";
p.textContent = "Noch keine Daten.";
container.append(p);
return;
}
const max = Math.max(...items.map((it) => it.value), 1);
// Wertespalte für alle Zeilen gleich breit: an der längsten Zahl ausrichten
// (in ch), damit die Tracks bündig sind und nicht je nach Stellenzahl wandern.
const valWidth = Math.max(...items.map((it) => String(it.value).length));
for (const it of items) {
const row = document.createElement("div");
row.className = "bar-row";
const label = document.createElement("span");
label.className = "bar-label";
label.textContent = formatLabel ? formatLabel(it.label) : it.label;
const track = document.createElement("div");
track.className = "bar-track";
const fill = document.createElement("div");
fill.className = "bar-fill";
fill.style.width = `${(it.value / max) * 100}%`;
track.append(fill);
const val = document.createElement("span");
val.className = "bar-value";
val.style.width = `${valWidth}ch`;
val.textContent = it.value;
row.append(label, track, val);
container.append(row);
}
}
async function render() {
const s = await api.get("/stats/detail");
// --- Auf einen Blick ---
const overview = document.getElementById("stat-overview");
overview.append(
numCard(s.users ?? 0, s.users === 1 ? "Profil" : "Profile"),
numCard(s.entries ?? 0, "Beiträge"),
numCard(s.toplevel ?? 0, "davon Threads"),
numCard(s.replies ?? 0, "davon Antworten"),
numCard(s.votes?.left ?? 0, "Stimmen Links"),
numCard(s.votes?.right ?? 0, "Stimmen Rechts"),
);
// --- Reichweite ---
document.getElementById("stat-reach").append(
numCard(s.impressions_total ?? 0, "Aufrufe gesamt"),
numCard(s.visitors_total ?? 0, "Netze gesamt"),
);
// --- Netze nach Betreiber (ASN) ---
renderBars(
document.getElementById("stat-asns"),
(s.top_asns ?? []).map((a) => ({ label: a.asn, value: a.hits })),
);
// --- Aufrufe pro Tag (Backend liefert absteigend -> für die Achse drehen) ---
const daily = (s.daily ?? []).slice().reverse();
const dayAxis = (day) => day.slice(5); // "MM-DD" reicht auf der Achse
renderBars(
document.getElementById("stat-daily"),
daily.map((d) => ({ label: d.day, value: d.impressions })),
dayAxis,
);
// --- Verschiedene Netze pro Tag ---
renderBars(
document.getElementById("stat-networks"),
daily.map((d) => ({ label: d.day, value: d.visitors })),
dayAxis,
);
}
render();
-67
View File
@@ -1,67 +0,0 @@
"use strict";
// Nutzername aus dem Pfad /u/<name>.
const username = decodeURIComponent(location.pathname.split("/")[2] || "");
document.getElementById("rename-form").addEventListener("submit", async (e) => {
e.preventDefault();
const res = await api.post("/user/rename", new FormData(e.target));
if (res.ok) {
location.href = `/u/${encodeURIComponent(res.data.username)}`;
} else {
document.getElementById("rename-msg").textContent =
errMsg(res.data, "Umbenennen fehlgeschlagen.");
}
});
document.getElementById("avatar-form").addEventListener("submit", async (e) => {
e.preventDefault();
const res = await api.post("/user/avatar", new FormData(e.target));
if (res.ok) {
// Cache umgehen, damit das neue Bild sofort erscheint.
document.getElementById("profile-avatar").src = `${avatarUrl(res.data.avatar)}?t=${Date.now()}`;
e.target.reset();
document.getElementById("avatar-msg").textContent = "";
} else {
document.getElementById("avatar-msg").textContent =
errMsg(res.data, "Hochladen fehlgeschlagen.");
}
});
document.getElementById("delete-form").addEventListener("submit", async (e) => {
e.preventDefault();
if (!confirm("Konto wirklich unwiderruflich löschen?")) return;
const res = await api.post("/user/delete", new FormData(e.target));
if (res.ok) {
location.href = "/";
} else {
document.getElementById("delete-msg").textContent =
errMsg(res.data, "Löschen fehlgeschlagen.");
}
});
async function init() {
const info = await api.get(`/u/${encodeURIComponent(username)}/info`);
if (info.error) {
document.getElementById("profile-name").textContent = "Nutzer nicht gefunden";
return;
}
document.getElementById("profile-name").textContent = info.username;
document.getElementById("profile-avatar").src = avatarUrl(info.avatar);
const since = new Date(info.created_at * 1000).toLocaleDateString("de-DE");
document.getElementById("profile-info").textContent = `Mitglied seit ${since}`;
// Eigene Seite? -> Einstellungen einblenden.
const me = await api.get("/user/info");
const isOwner = me.username === info.username;
document.getElementById("settings").hidden = !isOwner;
createFeed(
document.getElementById("feed"),
document.getElementById("sentinel"),
(page) => `/u/${encodeURIComponent(username)}/feed/${page}`
);
}
init();
-52
View File
@@ -1,52 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Statistik – Kontrollverlust</title>
<link rel="stylesheet" href="/css/app.css">
</head>
<body>
<header>
<nav id="legal">
<a href="/static/docs/impressum.html">Impressum</a> -
<a href="/static/docs/datenschutz.html">Datenschutz</a> -
<a href="/static/docs/beitragsordnung.html">Beitragsordnung</a> -
<a href="/stats.html">Statistik</a>
</nav>
<a href="/"><h1>Kontrollverlust</h1></a>
<div id="headerbar"><a class="button" href="/">zurück zum Feed</a></div>
</header>
<main>
<section>
<h2>Statistik</h2>
<h3>Auf einen Blick</h3>
<div id="stat-overview" class="statgrid"></div>
<h3>Reichweite</h3>
<p class="muted">
Seitenaufrufe werden mit anonymisierten Netzen gezählt (IPv4 /16, IPv6 /32).
„Netze" ist eine grobe Annäherung an Besucher, keine Personenzählung.
</p>
<div id="stat-reach" class="statgrid"></div>
<h3>Netze nach Betreiber</h3>
<p class="muted">
Aufrufe je autonomem System (Netzbetreiber), aufgelöst über GeoLite2-ASN.
</p>
<div id="stat-asns" class="bars"></div>
<h3>Aufrufe pro Tag</h3>
<div id="stat-daily" class="bars"></div>
<h3>Verschiedene Netze pro Tag</h3>
<div id="stat-networks" class="bars"></div>
</section>
</main>
<script src="/js/feed.js"></script>
<script src="/js/stats.js"></script>
</body>
</html>
-69
View File
@@ -1,69 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kontrollverlust</title>
<link rel="stylesheet" href="/css/app.css">
</head>
<body>
<header>
<nav id="legal">
<a href="/static/docs/impressum.html">Impressum</a> -
<a href="/static/docs/datenschutz.html">Datenschutz</a> -
<a href="/static/docs/beitragsordnung.html">Beitragsordnung</a> -
<a href="/stats.html">Statistik</a>
</nav>
<a href="/"><h1>Kontrollverlust</h1></a>
<div id="headerbar"><a class="button" href="/">zurück zum Feed</a></div>
</header>
<main>
<section id="profile">
<div class="profile-head">
<img id="profile-avatar" class="avatar avatar-lg" src="/static/assets/no_profile_pic.jpg" alt="">
<div>
<h2 id="profile-name"></h2>
<p id="profile-info" class="muted"></p>
</div>
</div>
<!-- Nur für den Eigentümer sichtbar -->
<div id="settings" hidden>
<h2>Einstellungen</h2>
<details>
<summary>Profilbild ändern</summary>
<form id="avatar-form">
<input name="avatar" type="file" accept="image/*">
<button type="submit">Bild hochladen</button>
</form>
<p class="msg" id="avatar-msg"></p>
</details>
<details>
<summary>Name ändern</summary>
<form id="rename-form">
<input name="user" type="text" placeholder="Neuer Nutzername" autocomplete="username">
<button type="submit">Namen ändern</button>
</form>
<p class="msg" id="rename-msg"></p>
</details>
<details>
<summary>Konto löschen</summary>
<form id="delete-form">
<p class="muted">Löscht dein Konto samt Beiträgen und Votes unwiderruflich.</p>
<input name="pass1" type="password" placeholder="Passwort bestätigen" autocomplete="current-password">
<button type="submit">Konto endgültig löschen</button>
</form>
<p class="msg" id="delete-msg"></p>
</details>
</div>
<div id="feed"></div>
<div id="sentinel"></div>
</section>
</main>
<script src="/js/feed.js"></script>
<script src="/js/user.js"></script>
</body>
</html>