Compare commits

..
28 Commits
Author SHA1 Message Date
irrlichtandClaude Opus 5.5 07a2b64eff Statistik: tägliche SVG-Diagramme statt CSS-Balken
Der Server rendert einmal täglich (0:01 Uhr deutscher Zeit) Liniendiagramme
für Aufrufe, Netze und Beiträge pro Tag sowie gestapelte Säulen (Threads/
Antworten) für den Ø pro Wochentag und pro Tageszeit, je schmal (600) und
breit (1200), ausgeliefert über /api/stats/charts. Hovern zeigt den
Datenpunkt in einer Infozeile und markiert ihn mit einer senkrechten Linie.

Die Statistik-Seite wird auf breiten Bildschirmen bis 1200 px breit.
"Netze nach Betreiber" entfällt; neu ist die Kennzahl Ø Beiträge/Tag der
letzten 30 Tage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
2026-10-06 20:13:30 +02:00
irrlichtandClaude Opus 5.5 047ad392d9 Datenschutz: Anonymisierung nicht absolut versprechen
Mit Zusatzwissen ließ sich ein Netz aus der Reichweitenmessung einer
Person zuordnen. Die Erklärung sagt deshalb nur noch, dass einzelne
Personen in der Regel nicht bestimmbar sind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
2026-10-06 19:37:18 +02:00
irrlichtandClaude Opus 5.5 26b0182391 Linkvorschau für den ersten Link eines Beitrags
Der Server holt zum ersten Link Titel, Seiten- bzw. Kanalname und ein
kleines Vorschaubild (320 px) und speichert das Bild lokal; der Browser
lädt nur von uns, Besucher-IPs gehen nie an die verlinkte Seite.
YouTube über oEmbed (alle Linkformen kanonisch als watch?v=<id>), alle
übrigen Seiten über OpenGraph bzw. <title>. Abruf asynchron in einem
Worker, eine Zeile je Link in link_preview, Auffrischen nach 30 Tagen.

Der Abruf-Client lässt nur öffentliche IPs auf Port 80/443 zu, geprüft
nach DNS-Auflösung und bei jeder Weiterleitung (SSRF). Bestehende
Beiträge: kver link-previews. Neue Abhängigkeit golang.org/x/net, damit
go 1.26. DEV bekommt ein beschreibbares Volume für die Vorschaubilder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
2026-10-06 18:47:21 +02:00
irrlichtandClaude Opus 5.5 a8f392edba Plan Android-App (notes/android.md)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
2026-10-06 18:47:21 +02:00
irrlichtandClaude Opus 5.5 764ad50b8e Passwort-Reset: Review-Fixes
- Token vor bcrypt prüfen, damit beliebige Tokens keine CPU-Last erzeugen
- CLI meldet DB-Fehler nicht mehr als "Nutzer nicht gefunden"
- Reset in einem Browser mit fremder Session räumt diese mit ab
- Konto-Löschen entfernt auch offene Reset-Links

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 00:44:32 +02:00
irrlichtandClaude Opus 5.5 81a74375b7 Passwort-Reset per Einmal-Link (kver reset-link)
Ohne E-Mail-System erzeugt der Admin per CLI einen Link (/reset#<token>),
gültig 72 h und einmal. Gespeichert wird nur der SHA-256 des Tokens; es
steht im Fragment und geht nur im POST-Body an die API. Der Reset meldet
alle alten Sessions ab und loggt direkt neu ein. Passwortregeln jetzt
gemeinsam in checkNewPassword.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 21:56:05 +02:00
irrlichtandClaude Opus 5.5 b6cf8c7fa0 Abstimmung Ja/Nein statt Links/Rechts
Modus-Werte yes/no statt left/right in DB, API und Statistik; Knöpfe
"Ja"/"Nein". Bestehende Stimmen müssen per SQL migriert werden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
2026-09-29 18:14:57 +02:00
irrlichtandClaude Opus 5.5 3d2447fe35 Melden rechts in der Byline jeder Karte, auch anonym
- Melden-Knopf in der Byline aller Beiträge (außer eigenen), Grund-Formular
  darunter; ersetzt den Knopf unter dem fokussierten Beitrag
- /api/entry/{pid}/report öffentlich, 10/min pro IP; ohne Session anonym
  (uid 0), Doppelmeldungen nur für Angemeldete unterdrückt
- Klicks in Formularen öffnen die Karte nicht mehr

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
2026-09-29 18:05:54 +02:00
irrlichtandClaude Opus 5.5 80c333cd58 Melden-Button: Beiträge mit optionalem Grund melden
- report-Tabelle wieder im Schema (kompatibel zur bestehenden auf PROD)
- POST /api/entry/{pid}/report, nur angemeldet, speichert Melder-uid;
  erneutes Melden legt keine zweite offene Meldung an
- "Melden" auf der Beitragsseite für fremde, nicht gelöschte Beiträge
- Test und API-Doku

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
2026-09-29 17:53:10 +02:00
irrlichtandClaude Opus 5.5 2bae608d76 Sigillen der Beitragsordnung wieder auf 50px (#sigils zurück)
Die Regel war im Code-Review fälschlich als tot entfernt worden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
2026-09-27 15:17:33 +02:00
irrlichtandClaude Opus 5.5 43c640dee6 Rechtstexte wieder gestylt: als Vite-Einstiege mit app.css gebaut
Die Seiten verlinkten /css/app.css, das es seit dem React-Umbau nicht mehr
gibt. Jetzt liegen sie unter frontend/static/docs, Vite baut sie mit und
schreibt den Link auf das gehashte app.css um; serveDoc liefert sie unter
den bisherigen Adressen /static/docs/… aus web/ aus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
2026-09-27 15:14:53 +02:00
irrlichtandClaude Opus 5.5 df0cfec132 Code-Review: tote Stellen entfernt, Umwege vereinfacht
- SQLite-Import, migrate.sh, notes/migrations.md und altes jQuery-Skript entfernt
- Backend: scanEntry/queryEntries statt doppelter Scan-Liste, Feed-Queries
  direkt statt feedWhere, Thread ohne Map-Umweg, gemeinsame Helfer für
  Cookie-Löschen und Mediennamen, usernameTaken
- Frontend: PostForm für Beitrag und Antwort, EntryCard in Deleted/LiveCard
  aufgeteilt, tote CSS-Regeln und Typen entfernt, Strich-Regeln zusammengelegt
- Rechtstexte verlinken /stats; Kommentare ohne Vorgeschichte

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
2026-09-27 15:03:22 +02:00
irrlicht d1bd0f5f1d Updated docs 2026-09-27 01:03:15 +02:00
irrlichtandClaude Opus 5.5 4616e8acbb Diskussionsketten: "k weitere Beiträge" als Button im Strich
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
2026-09-27 01:02:34 +02:00
irrlichtandClaude Opus 5.5 ade853f560 Diskussionsketten: jüngste Antwort im Teilbaum an Feed- und Antwortkarten
Jede Karte in Feed und Antwortliste zeigt die Antwort, die ihre Diskussion
zuletzt fortgesetzt hat: [Beitrag] -- "k weitere Beiträge" -- [Antwort].
Gelöschte Beiträge werden als jüngste übersprungen, zählen auf dem Pfad mit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
2026-09-27 00:52:17 +02:00
irrlichtandClaude Opus 5.5 8e79e94bbb Login: Zeilenumbruch vor "Einloggen"; dev-up.sh: Lock nicht an conmon vererben
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
2026-09-27 00:17:31 +02:00
irrlichtandClaude Opus 5.5 4e0d27c4a6 dev-up.sh: DEV-Container neu bauen und starten, auch als post-commit-Hook
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
2026-09-27 00:10:01 +02:00
irrlichtandClaude Opus 5.5 9a7282ed1d Login: Sitzungsdauer wählbar (1 Stunde bis 1 Jahr)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
2026-09-27 00:04:07 +02:00
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
irrlichtandClaude Opus 5.5 2714fdb1a5 Datenbank von SQLite auf Postgres umgestellt
Verbindung über KVER_DSN; Tabelle user heißt jetzt account (reserviertes
Wort in Postgres). kver import-sqlite übernimmt die alte Datei einmalig in
einer Transaktion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
2026-09-26 22:22:07 +02:00
irrlichtandClaude Opus 5.5 c6ab082f8c SSR-Renderer entfernt, Fokus auf SPA + JSON-API
Die Kernlogik bleibt von der Schnittstelle getrennt; der letzte SSR-Stand
liegt im Tag ssr-archiv. Die Moderationsseite bleibt, liefert Fehler aber
als JSON.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
2026-09-26 21:28:34 +02:00
irrlichtandClaude Opus 5.5 eccddcebe6 SSR: Repost ohne JS, return_to in Formularen, Werbebanner entfernt
Letzter vollständiger SSR-Stand vor dem Rückbau auf reine SPA.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
2026-09-26 21:23:09 +02:00
87 changed files with 6302 additions and 5026 deletions
+4 -1
View File
@@ -12,6 +12,9 @@ kver
*.db.bak *.db.bak
*.env *.env
static/media/ static/media/
migrate.sh
Containerfile Containerfile
.containerignore .containerignore
frontend/node_modules/
web/
dev-up.sh
githooks/
+2
View File
@@ -10,3 +10,5 @@ original-kver.db
__pycache__/ __pycache__/
static/media/ static/media/
prod_backup/ prod_backup/
frontend/node_modules/
/web/
+14 -9
View File
@@ -1,13 +1,19 @@
# Build-Stage: statisches Binary, CGO aus (modernc.org/sqlite ist pures Go). # 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 ist pures Go).
FROM docker.io/library/golang:1.26-alpine AS build FROM docker.io/library/golang:1.26-alpine AS build
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY *.go ./ COPY *.go ./
# templates/ wird per go:embed ins Binary kompiliert (siehe htmlpage.go) und
# muss deshalb schon in der Build-Stage vorliegen -- in der Laufzeit-Stage
# braucht es das Verzeichnis dann nicht mehr.
COPY templates/ templates/
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /kver . RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /kver .
# Laufzeit-Stage: minimales Alpine, eigener unprivilegierter Nutzer. # Laufzeit-Stage: minimales Alpine, eigener unprivilegierter Nutzer.
@@ -15,15 +21,14 @@ FROM docker.io/library/alpine:3.22
RUN adduser -D -u 1000 kver RUN adduser -D -u 1000 kver
WORKDIR /app WORKDIR /app
COPY --from=build /kver /usr/local/bin/kver COPY --from=build /kver /usr/local/bin/kver
COPY web/ web/ COPY --from=frontend /src/web web/
COPY static/ static/ COPY static/ static/
# data: SQLite-DB; static/media: Uploads. Beide als Volume persistieren. # data: GeoLite2-ASN-DB; static/media: Uploads. Beide als Volume persistieren.
RUN mkdir -p data static/media && chown kver:kver data static/media RUN mkdir -p data static/media && chown kver:kver data static/media
USER kver USER kver
ENV KVER_DB=/app/data/kver.db
ENV KVER_ADDR=:8080 ENV KVER_ADDR=:8080
EXPOSE 8080 EXPOSE 8080
VOLUME ["/app/data", "/app/static/media"] VOLUME ["/app/data", "/app/static/media"]
HEALTHCHECK --interval=30s --timeout=3s \ 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"] ENTRYPOINT ["/usr/local/bin/kver"]
+51 -132
View File
@@ -39,7 +39,7 @@ func getSession(r *http.Request) (*Session, bool) {
s := Session{Value: c.Value} s := Session{Value: c.Value}
err = db.QueryRow( err = db.QueryRow(
`SELECT uid, created_at, expires, description FROM session WHERE value = ?`, `SELECT uid, created_at, expires, description FROM session WHERE value = $1`,
c.Value, c.Value,
).Scan(&s.UID, &s.CreatedAt, &s.Expires, &s.Description) ).Scan(&s.UID, &s.CreatedAt, &s.Expires, &s.Description)
if err != nil { if err != nil {
@@ -47,7 +47,7 @@ func getSession(r *http.Request) (*Session, bool) {
} }
if s.Expires < time.Now().Unix() { if s.Expires < time.Now().Unix() {
db.Exec(`DELETE FROM session WHERE value = ?`, c.Value) db.Exec(`DELETE FROM session WHERE value = $1`, c.Value)
return nil, false return nil, false
} }
@@ -58,93 +58,46 @@ func getSession(r *http.Request) (*Session, bool) {
// absichtlich derselbe Fehler -- sonst wird der Endpunkt zum Nutzernamen-Orakel. // absichtlich derselbe Fehler -- sonst wird der Endpunkt zum Nutzernamen-Orakel.
var errBadCredentials = Unauth("auth.bad_credentials", "Nutzername oder Passwort ist falsch.") var errBadCredentials = Unauth("auth.bad_credentials", "Nutzername oder Passwort ist falsch.")
// loginForm trägt die Eingaben über einen fehlgeschlagenen Versuch hinweg.
// Bewusst ohne Passwort: das wird nie zurück ins Formular geschrieben.
type loginForm struct {
User string
}
// showLogin ist die serverseitig gerenderte Login-Seite -- der Weg für Clients
// ohne JavaScript (TOR Browser auf "Safest", I2P).
func showLogin(w http.ResponseWriter, r *http.Request) error {
if _, ok := getSession(r); ok {
http.Redirect(w, r, "/", http.StatusSeeOther)
return nil
}
render(w, r, "login", pageData{Title: "Login"})
return nil
}
// handleLogin bedient beide Adapter: das JS-Frontend (Accept: application/json)
// bekommt JSON, ein abgeschicktes Browser-Formular bekommt einen Redirect bzw.
// das neu gerenderte Formular. Die Logik dazwischen existiert nur einmal.
func handleLogin(w http.ResponseWriter, r *http.Request) error { func handleLogin(w http.ResponseWriter, r *http.Request) error {
json := wantsJSON(r)
// fail wählt die zum Client passende Fehlerdarstellung. Der Rückgabewert
// geht an den Wrapper; bei HTML ist die Antwort schon geschrieben, deshalb
// nil.
fail := func(username string, err error) error {
if json {
return err
}
failForm(w, r, "login", pageData{Title: "Login", Form: loginForm{User: username}}, err)
return nil
}
if _, ok := getSession(r); ok { if _, ok := getSession(r); ok {
if !json {
http.Redirect(w, r, "/", http.StatusSeeOther)
return nil
}
writeJSON(w, http.StatusOK, map[string]string{"status": "already_logged_in"}) writeJSON(w, http.StatusOK, map[string]string{"status": "already_logged_in"})
return nil return nil
} }
username := sanitizeUsername(r.FormValue("user")) username := sanitizeUsername(r.FormValue("user"))
if len(username) < 3 { if len(username) < 3 {
return fail(username, Invalid("user.name_short", "Der Nutzername ist zu kurz.").At("user")) return Invalid("user.name_short", "Der Nutzername ist zu kurz.").At("user")
} }
// Gleiche Meldung für "Nutzer existiert nicht" und "Passwort falsch": sonst
// wird der Endpunkt zum Nutzernamen-Orakel.
var uid int64 var uid int64
var hash []byte var hash []byte
err := db.QueryRow(`SELECT uid, password FROM user WHERE username = ?`, username).Scan(&uid, &hash) err := db.QueryRow(`SELECT uid, password FROM account WHERE username = $1`, username).Scan(&uid, &hash)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return fail(username, errBadCredentials) return errBadCredentials
} else if err != nil { } else if err != nil {
return Internal(err) return Internal(err)
} }
if bcrypt.CompareHashAndPassword(hash, []byte(r.FormValue("pass"))) != nil { if bcrypt.CompareHashAndPassword(hash, []byte(r.FormValue("pass"))) != nil {
return fail(username, errBadCredentials) return errBadCredentials
} }
if err := startSession(w, r, uid); err != nil { if err := startSession(w, r, uid); err != nil {
return err return err
} }
// POST -> Redirect (303) statt einer gerenderten Seite: sonst zeigt ein
// Reload den "Formular erneut senden?"-Dialog und legt eine zweite Session
// an (Post/Redirect/Get).
if !json {
http.Redirect(w, r, "/", http.StatusSeeOther)
return nil
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
return nil return nil
} }
// startSession legt eine Session an und setzt den Cookie. Von Login und // startSession legt eine Session an und setzt den Cookie.
// Registrierung gemeinsam genutzt -- die Registrierung ohne JavaScript muss
// direkt einloggen, weil es sonst zwei Formulare hintereinander bräuchte.
func startSession(w http.ResponseWriter, r *http.Request, uid int64) error { func startSession(w http.ResponseWriter, r *http.Request, uid int64) error {
now := time.Now().Unix() now := time.Now().Unix()
// timeout ist clientgesteuert -> serverseitig deckeln, damit niemand eine // timeout ist clientgesteuert -> serverseitig deckeln, damit niemand eine
// quasi-unbegrenzte Session anlegen kann. Default 1 Tag, Maximum 30 Tage. // quasi-unbegrenzte Session anlegen kann. Default 1 Tag, Maximum 1 Jahr
// (größte Option im Login-Formular).
timeoutSec, _ := strconv.ParseInt(r.FormValue("timeout"), 10, 64) timeoutSec, _ := strconv.ParseInt(r.FormValue("timeout"), 10, 64)
const maxTimeout = 30 * 86400 const maxTimeout = 365 * 86400
if timeoutSec <= 0 { if timeoutSec <= 0 {
timeoutSec = 86400 timeoutSec = 86400
} }
@@ -158,14 +111,14 @@ func startSession(w http.ResponseWriter, r *http.Request, uid int64) error {
return Internal(err) return Internal(err)
} }
db.Exec(`DELETE FROM session WHERE expires < ?`, now) db.Exec(`DELETE FROM session WHERE expires < $1`, now)
if _, err := db.Exec( if _, err := db.Exec(
`INSERT INTO session (uid, created_at, expires, description, value) VALUES (?, ?, ?, ?, ?)`, `INSERT INTO session (uid, created_at, expires, description, value) VALUES ($1, $2, $3, $4, $5)`,
uid, now, expires, "", token, uid, now, expires, "", token,
); err != nil { ); err != nil {
return Internal(err) return Internal(err)
} }
db.Exec(`UPDATE user SET last_login = ? WHERE uid = ?`, now, uid) db.Exec(`UPDATE account SET last_login = $1 WHERE uid = $2`, now, uid)
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: "session", Name: "session",
@@ -179,10 +132,20 @@ func startSession(w http.ResponseWriter, r *http.Request, uid int64) error {
return nil return nil
} }
func handleLogout(w http.ResponseWriter, r *http.Request) { func handleLogout(w http.ResponseWriter, r *http.Request) error {
// Scheitert das Löschen, bliebe die Session serverseitig gültig -- das
// muss der Client erfahren, statt ein "ok" zu sehen.
if s, ok := getSession(r); ok { if s, ok := getSession(r); ok {
db.Exec(`DELETE FROM session WHERE value = ?`, s.Value) if _, err := db.Exec(`DELETE FROM session WHERE value = $1`, s.Value); err != nil {
return Internal(err)
} }
}
clearSessionCookie(w, r)
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
return nil
}
func clearSessionCookie(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: "session", Name: "session",
Value: "", Value: "",
@@ -193,25 +156,46 @@ func handleLogout(w http.ResponseWriter, r *http.Request) {
Secure: isHTTPS(r), Secure: isHTTPS(r),
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
}) })
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
} }
// handleNewUser sammelt alle Validierungsfehler ein, statt beim ersten // handleNewUser sammelt alle Validierungsfehler ein, statt beim ersten
// abzubrechen -- der Nutzer soll nicht Feld für Feld durchprobieren müssen. // abzubrechen -- der Nutzer soll nicht Feld für Feld durchprobieren müssen.
// Deshalb hier Fields (Feld -> Meldung) statt eines einzelnen Field. // Deshalb hier Fields (Feld -> Meldung) statt eines einzelnen Field.
func handleNewUser(w http.ResponseWriter, r *http.Request) error { func handleNewUser(w http.ResponseWriter, r *http.Request) error {
json := wantsJSON(r)
fields := map[string]string{} fields := map[string]string{}
username := sanitizeUsername(r.FormValue("user")) username := sanitizeUsername(r.FormValue("user"))
if len(username) < 3 { if len(username) < 3 {
fields["user"] = "Der Nutzername ist zu kurz." fields["user"] = "Der Nutzername ist zu kurz."
} else if idFromUsername(username) != 0 { } else if usernameTaken(username) {
fields["user"] = "Der Nutzername wird bereits verwendet." fields["user"] = "Der Nutzername wird bereits verwendet."
} }
pass1 := r.FormValue("pass1") pass1 := r.FormValue("pass1")
pass2 := r.FormValue("pass2") checkNewPassword(pass1, r.FormValue("pass2"), fields)
if len(fields) > 0 {
e := Invalid("user.invalid", "Die Angaben sind unvollständig oder ungültig.")
e.Fields = fields
return e
}
_, err := createUser(username, pass1)
if errors.Is(err, errUsernameTaken) {
// Rennen zwischen Vorab-Prüfung und INSERT: der UNIQUE-Constraint hat
// gegriffen -> als normale Validierungsmeldung statt 500 melden.
return errUsernameTaken
} else if err != nil {
return Internal(err)
}
writeJSON(w, http.StatusCreated, map[string]string{"username": username})
return nil
}
// checkNewPassword trägt Fehler zu einem neuen Passwort (pass1) und seiner
// Wiederholung (pass2) in fields ein. Gemeinsam für Registrierung und Reset.
func checkNewPassword(pass1, pass2 string, fields map[string]string) {
switch { switch {
case len(pass1) < 10: case len(pass1) < 10:
fields["pass1"] = "Das Passwort ist kürzer als 10 Zeichen." fields["pass1"] = "Das Passwort ist kürzer als 10 Zeichen."
@@ -223,61 +207,6 @@ func handleNewUser(w http.ResponseWriter, r *http.Request) error {
if pass1 != pass2 { if pass1 != pass2 {
fields["pass2"] = "Passwort und Passwortbestätigung sind ungleich." fields["pass2"] = "Passwort und Passwortbestätigung sind ungleich."
} }
// fail wählt die zum Client passende Fehlerdarstellung -- wie bei
// handleLogin: JSON-Clients bekommen den Fehler über den Wrapper, ein
// Browser-Formular wird mit Meldungen neu gerendert.
fail := func(err error) error {
if json {
return err
}
failForm(w, r, "register", pageData{Title: "Registrieren", Form: registerForm{User: username}}, err)
return nil
}
if len(fields) > 0 {
e := Invalid("user.invalid", "Die Angaben sind unvollständig oder ungültig.")
e.Fields = fields
return fail(e)
}
uid, err := createUser(username, pass1)
if errors.Is(err, errUsernameTaken) {
// Rennen zwischen Vorab-Prüfung und INSERT: der UNIQUE-Constraint hat
// gegriffen -> als normale Validierungsmeldung statt 500 melden.
return fail(errUsernameTaken)
} else if err != nil {
return Internal(err)
}
// Ohne JavaScript direkt einloggen und weiterleiten: das JS-Frontend macht
// genau das (zweiter Aufruf auf /auth/login), und ein zweites Formular nur
// zum Anmelden wäre eine unnötige Hürde.
if !json {
if err := startSession(w, r, uid); err != nil {
return err
}
http.Redirect(w, r, "/", http.StatusSeeOther)
return nil
}
writeJSON(w, http.StatusCreated, map[string]string{"username": username})
return nil
}
// registerForm trägt die Eingaben über einen fehlgeschlagenen Versuch hinweg --
// wieder ohne die Passwörter.
type registerForm struct {
User string
}
// showRegister ist die serverseitig gerenderte Registrierungsseite.
func showRegister(w http.ResponseWriter, r *http.Request) error {
if _, ok := getSession(r); ok {
http.Redirect(w, r, "/", http.StatusSeeOther)
return nil
}
render(w, r, "register", pageData{Title: "Registrieren"})
return nil
} }
func handleSessionInfo(w http.ResponseWriter, r *http.Request) error { func handleSessionInfo(w http.ResponseWriter, r *http.Request) error {
@@ -294,18 +223,8 @@ func handleSessionInfo(w http.ResponseWriter, r *http.Request) error {
return nil return nil
} }
func handleHeaderbar(w http.ResponseWriter, r *http.Request) {
_, ok := getSession(r)
writeJSON(w, http.StatusOK, map[string]bool{"loggedin": ok})
}
func sanitizeUsername(u string) string { func sanitizeUsername(u string) string {
u = strings.TrimSpace(u) u = truncateRunes(strings.TrimSpace(u), 32)
// Auf 32 Zeichen (Runen) kürzen, nicht 32 Bytes -- sonst kann der Schnitt
// mitten in eine Multibyte-Rune fallen und kaputtes UTF-8 erzeugen.
if r := []rune(u); len(r) > 32 {
u = string(r[:32])
}
u = reNonWord.ReplaceAllString(u, "") u = reNonWord.ReplaceAllString(u, "")
u = reSpace.ReplaceAllString(u, "_") u = reSpace.ReplaceAllString(u, "_")
return u return u
+566
View File
@@ -0,0 +1,566 @@
package main
import (
"fmt"
"html"
"log"
"math"
"net/http"
"strconv"
"strings"
"sync"
"time"
_ "time/tzdata" // Alpine-Image hat keine Zonendaten
)
// Diagramme der Statistik-Seite. Sie werden einmal am Tag (kurz nach
// Mitternacht, deutsche Zeit) als fertiges SVG gerendert und im Speicher
// gehalten; ein Seitenaufruf liefert nur noch die Strings aus. Das SVG nutzt
// ausschließlich Präsentationsattribute (stroke, fill ...) und kein style-
// Attribut -> inline eingebettet von der strikten CSP nicht betroffen, und die
// Schrift erbt es von der Seite.
//
// Der laufende Tag fehlt bewusst: er wäre bis zum nächsten Rendern immer nur
// ein halber Tag und würde die Kurven am rechten Rand abknicken lassen.
var berlin = mustLoadLocation("Europe/Berlin")
func mustLoadLocation(name string) *time.Location {
loc, err := time.LoadLocation(name)
if err != nil {
panic(err)
}
return loc
}
// StatsCharts ist die Antwort von /api/stats/charts. Jedes Diagramm gibt es
// schmal (Handy, schmale Fenster) und breit (Widescreen); welches sichtbar ist,
// entscheidet das CSS. So bleibt die Schrift in beiden Fällen etwa 1:1.
type StatsCharts struct {
Generated int64 `json:"generated"`
Charts map[string]ChartPair `json:"charts"`
}
type ChartPair struct {
Narrow string `json:"narrow"`
Wide string `json:"wide"`
}
// both rendert ein Diagramm in beiden Größen.
func both(render func(sz size) string) ChartPair {
return ChartPair{Narrow: render(narrow), Wide: render(wide)}
}
var (
chartMu sync.RWMutex
chartCache StatsCharts
chartStop = make(chan struct{})
chartDone = make(chan struct{})
)
// startChartWorker rendert sofort und danach jede Nacht neu. Schlägt das
// Rendern fehl, bleibt der alte Stand stehen und es gibt nach 15 Minuten einen
// neuen Versuch.
func startChartWorker() {
go func() {
defer close(chartDone)
for {
wait := 15 * time.Minute
if err := renderCharts(time.Now()); err != nil {
log.Printf("statistik-diagramme: %v", err)
} else {
wait = time.Until(nextChartRun(time.Now()))
}
select {
case <-chartStop:
return
case <-time.After(wait):
}
}
}()
}
// stopChartWorker wartet ein laufendes Rendern ab (vor db.Close rufen).
func stopChartWorker() {
close(chartStop)
<-chartDone
}
// nextChartRun ist eine Minute nach der nächsten Mitternacht in Berlin.
func nextChartRun(now time.Time) time.Time {
y, m, d := now.In(berlin).Date()
return time.Date(y, m, d+1, 0, 1, 0, 0, berlin)
}
func renderCharts(now time.Time) error {
impr, err := collectImpressionStats(now)
if err != nil {
return err
}
ent, err := collectEntryStats(now)
if err != nil {
return err
}
set := StatsCharts{
Generated: now.Unix(),
Charts: map[string]ChartPair{
"impressions": both(func(sz size) string {
return lineChart(sz, "Aufrufe pro Tag", impr.days, impr.hits, "Aufrufe")
}),
"networks": both(func(sz size) string {
return lineChart(sz, "Netze pro Tag", impr.days, impr.networks, "Netze")
}),
"entries": both(func(sz size) string {
return lineChart(sz, "Beiträge pro Tag", ent.days, ent.perDay, "Beiträge")
}),
"weekday": both(func(sz size) string {
return stackedBars(sz, "Beiträge pro Wochentag", weekdayNames[:], weekdayLong[:], ent.weekday)
}),
"hour": both(func(sz size) string {
return stackedBars(sz, "Beiträge pro Tageszeit", hourLabels(), hourRanges(), ent.hour)
}),
},
}
chartMu.Lock()
chartCache = set
chartMu.Unlock()
return nil
}
// handleStatsCharts liefert die fertigen SVGs. Ist noch nichts gerendert
// (Start läuft noch, Tests ohne Worker), wird einmalig direkt gerendert.
func handleStatsCharts(w http.ResponseWriter, r *http.Request) error {
chartMu.RLock()
set := chartCache
chartMu.RUnlock()
if set.Generated == 0 {
if err := renderCharts(time.Now()); err != nil {
return Internal(err)
}
chartMu.RLock()
set = chartCache
chartMu.RUnlock()
}
w.Header().Set("Cache-Control", "public, max-age=600")
writeJSON(w, http.StatusOK, set)
return nil
}
// --- Daten ---
type impressionStats struct {
days []time.Time // lückenlos, ohne heute
hits, networks []float64
}
// collectImpressionStats summiert Aufrufe und verschiedene Netze je Tag. Die
// Impression-Tage sind UTC-Daten (siehe recordImpression).
func collectImpressionStats(now time.Time) (impressionStats, error) {
var s impressionStats
today := civilDate(now.UTC())
rows, err := db.Query(
`SELECT day, SUM(hits)::bigint, COUNT(DISTINCT anon_ip)
FROM impression
WHERE day < $1
GROUP BY day`, today.Format(time.DateOnly))
if err != nil {
return s, err
}
defer rows.Close()
type counts struct{ hits, networks int64 }
byDay := map[time.Time]counts{}
var first time.Time
for rows.Next() {
var day string
var c counts
if err := rows.Scan(&day, &c.hits, &c.networks); err != nil {
return s, err
}
d, err := time.Parse(time.DateOnly, day)
if err != nil {
continue
}
byDay[d] = c
if first.IsZero() || d.Before(first) {
first = d
}
}
if err := rows.Err(); err != nil {
return s, err
}
if first.IsZero() {
return s, nil
}
s.days = dayRange(first, today)
for _, d := range s.days {
c := byDay[d]
s.hits = append(s.hits, float64(c.hits))
s.networks = append(s.networks, float64(c.networks))
}
return s, nil
}
// Reihenfolge der Stapel: Threads unten, Antworten darüber.
var postKinds = [2]string{"Threads", "Antworten"}
type entryStats struct {
days []time.Time // erster Beitragstag bis gestern (deutsche Zeit)
perDay []float64
weekday [][2]float64 // Mo..So, Ø Beiträge je solchem Tag
hour [][2]float64 // 0..23 Uhr, Ø Beiträge je Tag
}
// collectEntryStats zählt alle Beiträge einschließlich gelöschter (created_at
// bleibt beim Löschen erhalten). Tage, Wochentage und Stunden gelten in
// deutscher Zeit. Wochentag und Stunde sind Durchschnitte: Summe geteilt durch
// die Zahl der Montage, Dienstage ... bzw. aller Tage im Zeitraum.
func collectEntryStats(now time.Time) (entryStats, error) {
var s entryStats
today := civilDate(now.In(berlin))
midnight := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, berlin)
rows, err := db.Query(
`SELECT created_at, reply_to <> 0 FROM entry WHERE created_at > 0 AND created_at < $1`,
midnight.Unix())
if err != nil {
return s, err
}
defer rows.Close()
perDay := map[time.Time]float64{}
var weekdaySum [7][2]float64
var hourSum [24][2]float64
var first time.Time
for rows.Next() {
var created int64
var reply bool
if err := rows.Scan(&created, &reply); err != nil {
return s, err
}
t := time.Unix(created, 0).In(berlin)
d := civilDate(t)
kind := 0
if reply {
kind = 1
}
perDay[d]++
weekdaySum[isoWeekday(d)][kind]++
hourSum[t.Hour()][kind]++
if first.IsZero() || d.Before(first) {
first = d
}
}
if err := rows.Err(); err != nil {
return s, err
}
s.weekday = make([][2]float64, 7)
s.hour = make([][2]float64, 24)
if first.IsZero() {
return s, nil
}
s.days = dayRange(first, today)
var weekdayCount [7]float64
for _, d := range s.days {
s.perDay = append(s.perDay, perDay[d])
weekdayCount[isoWeekday(d)]++
}
for i := range s.weekday {
for k := range postKinds {
if weekdayCount[i] > 0 {
s.weekday[i][k] = weekdaySum[i][k] / weekdayCount[i]
}
}
}
n := float64(len(s.days))
for i := range s.hour {
for k := range postKinds {
s.hour[i][k] = hourSum[i][k] / n
}
}
return s, nil
}
// civilDate ist das Kalenderdatum von t (in dessen Zone) als UTC-Mitternacht.
// Gerechnet wird mit diesen Werten, damit Sommerzeitwechsel keine 23- oder
// 25-Stunden-Tage in die Tagesfolge bringen.
func civilDate(t time.Time) time.Time {
y, m, d := t.Date()
return time.Date(y, m, d, 0, 0, 0, 0, time.UTC)
}
// dayRange liefert alle Tage von first bis ausschließlich end.
func dayRange(first, end time.Time) []time.Time {
var days []time.Time
for d := first; d.Before(end); d = d.AddDate(0, 0, 1) {
days = append(days, d)
}
return days
}
// isoWeekday: Montag = 0 ... Sonntag = 6.
func isoWeekday(d time.Time) int {
return (int(d.Weekday()) + 6) % 7
}
var weekdayNames = [7]string{"Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"}
var weekdayLong = [7]string{"Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"}
// hourRanges: "9–10 Uhr" für die Infozeile.
func hourRanges() []string {
l := make([]string, 24)
for i := range l {
l[i] = fmt.Sprintf("%d–%d Uhr", i, i+1)
}
return l
}
func hourLabels() []string {
l := make([]string, 24)
for i := range l {
l[i] = strconv.Itoa(i)
}
return l
}
// --- SVG ---
// Maße im viewBox-Raum. narrow entspricht der schmalen Inhaltsspalte (600 px),
// wide der breiten Statistik-Seite (1200 px); bis dorthin wird 1:1 gezeichnet,
// darunter skaliert das SVG mit.
type size struct{ w, h int }
var (
narrow = size{600, 240}
wide = size{1200, 300}
)
const (
padLeft = 44
padRight = 12
padTop = 12
padBottom = 26
fontSize = 13
)
var (
colorGrid = "#ddd"
colorStack = [2]string{"#444", "#aaa"}
)
type svg struct{ strings.Builder }
func (b *svg) f(format string, a ...any) { fmt.Fprintf(b, format, a...) }
func svgOpen(b *svg, sz size, label string) {
b.f(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" role="img" aria-label="%s" font-size="%d" fill="black">`,
sz.w, sz.h, html.EscapeString(label), fontSize)
}
func emptyChart(sz size, label string) string {
var b svg
svgOpen(&b, sz, label)
b.f(`<text x="%d" y="%d" text-anchor="middle" fill="gray">Noch keine Daten.</text></svg>`, sz.w/2, sz.h/2)
return b.String()
}
// niceScale wählt eine runde Schrittweite (1, 2, 5 × 10^k) für etwa vier
// Gitterlinien und die darauf aufgerundete Obergrenze.
func niceScale(max float64) (step, top float64) {
if max <= 0 {
max = 1
}
raw := max / 4
mag := math.Pow(10, math.Floor(math.Log10(raw)))
step = 10 * mag
for _, m := range []float64{1, 2, 5} {
if m*mag >= raw {
step = m * mag
break
}
}
return step, math.Ceil(max/step-1e-9) * step
}
// fmtNum schreibt eine Zahl mit höchstens zwei Nachkommastellen und
// deutschem Komma.
func fmtNum(v float64) string {
return strings.Replace(strconv.FormatFloat(math.Round(v*100)/100, 'f', -1, 64), ".", ",", 1)
}
// yAxis zeichnet Gitterlinien mit Beschriftung und liefert die Abbildung
// Wert -> y-Koordinate.
func yAxis(b *svg, sz size, max float64, top int) func(float64) float64 {
step, maxV := niceScale(max)
bottom := float64(sz.h - padBottom)
y := func(v float64) float64 { return bottom - v/maxV*(bottom-float64(top)) }
for v := 0.0; v <= maxV+step/2; v += step {
yy := y(v)
color := colorGrid
if v == 0 {
color = "black"
}
b.f(`<line x1="%d" y1="%.1f" x2="%d" y2="%.1f" stroke="%s"/>`, padLeft, yy, sz.w-padRight, yy, color)
b.f(`<text x="%d" y="%.1f" text-anchor="end">%s</text>`, padLeft-6, yy+4, fmtNum(v))
}
return y
}
func maxOf(vals []float64) float64 {
m := 0.0
for _, v := range vals {
m = math.Max(m, v)
}
return m
}
// lineChart zeichnet eine Tagesreihe als Linie. Alle Tage seit Beginn werden
// auf die volle Breite gestaucht; unsichtbare Streifen je Tag tragen den
// Tooltip (<title>), denselben Text in data-tip für die Infozeile unter dem
// Diagramm (StatsPage.tsx) und eine senkrechte Markierung.
func lineChart(sz size, label string, days []time.Time, vals []float64, unit string) string {
if len(days) == 0 {
return emptyChart(sz, label)
}
var b svg
svgOpen(&b, sz, label)
y := yAxis(&b, sz, maxOf(vals), padTop)
plotW := float64(sz.w - padLeft - padRight)
n := len(days)
x := func(i int) float64 {
if n == 1 {
return padLeft + plotW/2
}
return padLeft + float64(i)*plotW/float64(n-1)
}
bottom := sz.h - padBottom
for _, i := range dateTicks(days) {
xx := x(i)
b.f(`<line x1="%.1f" y1="%d" x2="%.1f" y2="%d" stroke="black"/>`, xx, bottom, xx, bottom+4)
tx := math.Min(math.Max(xx, padLeft+16), float64(sz.w-padRight-16))
b.f(`<text x="%.1f" y="%d" text-anchor="middle">%s</text>`, tx, bottom+18, dateTickLabel(days[i]))
}
var pts strings.Builder
for i, v := range vals {
fmt.Fprintf(&pts, "%.1f,%.1f ", x(i), y(v))
}
if n == 1 {
b.f(`<circle cx="%.1f" cy="%.1f" r="3"/>`, x(0), y(vals[0]))
} else {
b.f(`<polyline points="%s" fill="none" stroke="black" stroke-width="1.5" stroke-linejoin="round"/>`,
strings.TrimSpace(pts.String()))
}
slot := plotW / float64(max(n-1, 1))
for i, d := range days {
tip := fmt.Sprintf("%s, %s: %s %s", weekdayNames[isoWeekday(d)], d.Format("02.01.2006"), fmtNum(vals[i]), unit)
// Die Markierungslinie ist versteckt; das CSS zeigt sie beim Hovern
// über den Streifen (.chart g:hover .mark).
b.f(`<g data-tip="%s"><title>%s</title><rect x="%.1f" y="%d" width="%.1f" height="%d" fill="transparent"/>`,
tip, tip, x(i)-slot/2, padTop, slot, bottom-padTop)
b.f(`<line class="mark" x1="%.1f" y1="%d" x2="%.1f" y2="%d" stroke="black" visibility="hidden" pointer-events="none"/></g>`,
x(i), padTop, x(i), bottom)
}
b.f(`</svg>`)
return b.String()
}
var monthNames = [12]string{"Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"}
// dateTicks wählt die beschrifteten Tage: Monatsanfänge, bei langen Reihen
// nur jedes Quartal, Halbjahr oder Jahr; bei Reihen unter zwei Monaten die
// Montage. Höchstens acht Beschriftungen.
func dateTicks(days []time.Time) []int {
var months []int
for i, d := range days {
if d.Day() == 1 {
months = append(months, i)
}
}
if len(months) >= 2 {
for _, step := range []int{1, 3, 6, 12} {
var picked []int
for _, i := range months {
if (int(days[i].Month())-1)%step == 0 {
picked = append(picked, i)
}
}
if len(picked) <= 8 || step == 12 {
return picked
}
}
}
var mondays []int
for i, d := range days {
if d.Weekday() == time.Monday {
mondays = append(mondays, i)
}
}
for len(mondays) > 8 {
var half []int
for j := 0; j < len(mondays); j += 2 {
half = append(half, mondays[j])
}
mondays = half
}
return mondays
}
// dateTickLabel: "02.10." bei Wochen-Ticks, sonst Monatsname; der Januar
// zeigt stattdessen die Jahreszahl.
func dateTickLabel(d time.Time) string {
if d.Day() != 1 {
return d.Format("02.01.")
}
if d.Month() == time.January {
return strconv.Itoa(d.Year())
}
return monthNames[d.Month()-1]
}
// stackedBars zeichnet Ø Threads (unten) und Antworten (oben) je Kategorie
// als gestapelte Säulen mit Legende.
func stackedBars(sz size, label string, labels, tipLabels []string, vals [][2]float64) string {
totals := make([]float64, len(vals))
for i, v := range vals {
totals[i] = v[0] + v[1]
}
if maxOf(totals) == 0 {
return emptyChart(sz, label)
}
var b svg
svgOpen(&b, sz, label)
top := padTop + 20
x0 := padLeft + 4
for k, name := range postKinds {
b.f(`<rect x="%d" y="%d" width="11" height="11" fill="%s"/>`, x0, padTop-2, colorStack[k])
b.f(`<text x="%d" y="%d">%s</text>`, x0+16, padTop+8, name)
x0 += 16 + 9*len(name) + 16
}
y := yAxis(&b, sz, maxOf(totals), top)
bottom := float64(sz.h - padBottom)
slot := float64(sz.w-padLeft-padRight) / float64(len(vals))
barW := slot * 0.7
for i, v := range vals {
x := padLeft + float64(i)*slot + (slot-barW)/2
tip := html.EscapeString(fmt.Sprintf("%s: Ø %s Beiträge pro Tag (%s Threads, %s Antworten)",
tipLabels[i], fmtNum(totals[i]), fmtNum(v[0]), fmtNum(v[1])))
b.f(`<g data-tip="%s"><title>%s</title>`, tip, tip)
// Unsichtbare volle Säule, damit auch kurze Balken gut zu treffen sind.
b.f(`<rect x="%.1f" y="%d" width="%.1f" height="%.1f" fill="transparent"/>`,
padLeft+float64(i)*slot, top, slot, bottom-float64(top))
base := 0.0
for k := range postKinds {
if v[k] > 0 {
b.f(`<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" fill="%s"/>`,
x, y(base+v[k]), barW, y(base)-y(base+v[k]), colorStack[k])
}
base += v[k]
}
b.f(`</g>`)
b.f(`<text x="%.1f" y="%.0f" text-anchor="middle">%s</text>`, x+barW/2, bottom+18, html.EscapeString(labels[i]))
}
b.f(`</svg>`)
return b.String()
}
+199
View File
@@ -0,0 +1,199 @@
package main
import (
"encoding/json"
"encoding/xml"
"io"
"math"
"net/http"
"strings"
"testing"
"time"
)
func TestNiceScale(t *testing.T) {
cases := []struct{ max, step, top float64 }{
{0, 0.5, 1},
{1, 0.5, 1},
{7, 2, 8},
{123, 50, 150},
{0.37, 0.1, 0.4},
{1000, 500, 1000},
}
for _, c := range cases {
step, top := niceScale(c.max)
if math.Abs(step-c.step) > 1e-9 || math.Abs(top-c.top) > 1e-9 {
t.Errorf("niceScale(%v) = %v, %v; erwartet %v, %v", c.max, step, top, c.step, c.top)
}
}
}
func TestDateTicks(t *testing.T) {
day := func(s string) time.Time { d, _ := time.Parse(time.DateOnly, s); return d }
labels := func(from, to string) []string {
days := dayRange(day(from), day(to))
var l []string
for _, i := range dateTicks(days) {
l = append(l, dateTickLabel(days[i]))
}
return strings.Fields(strings.Join(l, " "))
}
if got := strings.Join(labels("2026-06-18", "2026-10-06"), " "); got != "Jul Aug Sep Okt" {
t.Errorf("Monate: %q", got)
}
if got := strings.Join(labels("2025-12-13", "2027-12-01"), " "); got != "2026 Apr Jul Okt 2027 Apr Jul Okt" {
t.Errorf("Quartale: %q", got)
}
if got := strings.Join(labels("2026-09-20", "2026-10-06"), " "); got != "21.09. 28.09. 05.10." {
t.Errorf("Wochen: %q", got)
}
}
// Ende-zu-Ende: Beiträge und Aufrufe direkt in die DB, Aggregate prüfen,
// dann über die API ausliefern und jedes SVG als XML parsen.
func TestStatsCharts(t *testing.T) {
srv := newTestServer(t)
at := func(s string) int64 {
tm, err := time.ParseInLocation("2006-01-02 15:04", s, berlin)
if err != nil {
t.Fatal(err)
}
return tm.Unix()
}
// Mo 2026-09-28 bis Mi 2026-09-30; "heute" ist Do 2026-10-01.
posts := []struct {
created int64
reply int64
deleted int64
}{
{at("2026-09-28 09:30"), 0, 0},
{at("2026-09-28 09:45"), 1, 0},
{at("2026-09-28 23:30"), 1, 1}, // gelöscht, zählt trotzdem; UTC wäre es 21 Uhr
{at("2026-09-30 00:10"), 0, 0},
{at("2026-10-01 08:00"), 0, 0}, // heute -> fehlt
}
for i, p := range posts {
if _, err := db.Exec(`INSERT INTO entry (pid, uid, created_at, reply_to, deleted) VALUES ($1, 1, $2, $3, $4)`,
100+i, p.created, p.reply, p.deleted); err != nil {
t.Fatal(err)
}
}
for _, imp := range []struct {
day, ip string
hits int
}{
{"2026-09-27", "10.1.0.0", 3},
{"2026-09-27", "10.2.0.0", 2},
{"2026-09-29", "10.1.0.0", 1},
{"2026-10-01", "10.1.0.0", 9}, // heute -> fehlt
} {
db.Exec(`INSERT INTO impression (day, anon_ip, path, hits) VALUES ($1, $2, '/', $3)`, imp.day, imp.ip, imp.hits)
}
now := time.Date(2026, 10, 1, 12, 0, 0, 0, berlin)
ent, err := collectEntryStats(now)
if err != nil {
t.Fatal(err)
}
if len(ent.days) != 3 || ent.perDay[0] != 3 || ent.perDay[1] != 0 || ent.perDay[2] != 1 {
t.Errorf("perDay %v über %d Tage", ent.perDay, len(ent.days))
}
// Je ein Mo/Di/Mi im Zeitraum -> Ø = Summe.
if ent.weekday[0] != [2]float64{1, 2} || ent.weekday[2] != [2]float64{1, 0} || ent.weekday[3] != [2]float64{} {
t.Errorf("weekday %v", ent.weekday)
}
// Stunden: Summe / 3 Tage.
if ent.hour[9] != [2]float64{1.0 / 3, 1.0 / 3} || ent.hour[23] != [2]float64{0, 1.0 / 3} || ent.hour[0] != [2]float64{1.0 / 3, 0} {
t.Errorf("hour 0/9/23: %v %v %v", ent.hour[0], ent.hour[9], ent.hour[23])
}
impr, err := collectImpressionStats(now)
if err != nil {
t.Fatal(err)
}
wantHits, wantNets := []float64{5, 0, 1, 0}, []float64{2, 0, 1, 0}
if len(impr.days) != 4 {
t.Fatalf("impression-Tage: %v", impr.days)
}
for i := range wantHits {
if impr.hits[i] != wantHits[i] || impr.networks[i] != wantNets[i] {
t.Errorf("Tag %d: %v/%v", i, impr.hits[i], impr.networks[i])
}
}
if err := renderCharts(now); err != nil {
t.Fatal(err)
}
resp, err := http.Get(srv.URL + "/api/stats/charts")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var set StatsCharts
if err := json.NewDecoder(resp.Body).Decode(&set); err != nil {
t.Fatal(err)
}
for _, name := range []string{"impressions", "networks", "entries", "weekday", "hour"} {
pair := set.Charts[name]
if !strings.Contains(pair.Narrow, `viewBox="0 0 600 `) || !strings.Contains(pair.Wide, `viewBox="0 0 1200 `) {
t.Errorf("%s: falsche Größen", name)
}
for _, s := range []string{pair.Narrow, pair.Wide} {
if !strings.HasPrefix(s, "<svg") || strings.Contains(s, "style=") {
t.Errorf("%s: kein CSP-taugliches SVG", name)
}
d := xml.NewDecoder(strings.NewReader(s))
for {
if _, err := d.Token(); err == io.EOF {
break
} else if err != nil {
t.Errorf("%s: kein gültiges XML: %v", name, err)
break
}
}
}
}
if !strings.Contains(set.Charts["weekday"].Wide, `data-tip="Montag: Ø 3 Beiträge pro Tag (1 Threads, 2 Antworten)"`) {
t.Error("Tooltip Montag fehlt")
}
}
func TestEmptyCharts(t *testing.T) {
newTestServer(t)
if err := renderCharts(time.Now()); err != nil {
t.Fatal(err)
}
for name, pair := range chartCache.Charts {
if !strings.Contains(pair.Narrow, "Noch keine Daten.") || !strings.Contains(pair.Wide, "Noch keine Daten.") {
t.Errorf("%s ohne Daten: %+v", name, pair)
}
}
}
// Ø Beiträge/Tag: nur die letzten 30 vollen Tage, gelöschte zählen, heute nicht.
func TestStatsDetailAverage(t *testing.T) {
srv := newTestServer(t)
now := time.Now().In(berlin)
midnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, berlin)
for i, created := range []int64{
midnight.Add(-time.Hour).Unix(), // gestern
midnight.AddDate(0, 0, -30).Unix(), // erster Tag des Fensters
midnight.AddDate(0, 0, -30).Unix() - 1, // davor
midnight.Add(time.Minute).Unix(), // heute
midnight.AddDate(0, 0, -10).Add(time.Hour).Unix(), // gelöscht
} {
db.Exec(`INSERT INTO entry (pid, uid, created_at, deleted) VALUES ($1, 1, $2, $3)`, 200+i, created, i/4)
}
resp, err := http.Get(srv.URL + "/api/stats/detail")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var body struct {
Avg float64 `json:"entries_per_day_30"`
}
json.NewDecoder(resp.Body).Decode(&body)
if math.Abs(body.Avg-3.0/30) > 1e-9 {
t.Errorf("entries_per_day_30 = %v, erwartet 0,1", body.Avg)
}
}
+96
View File
@@ -0,0 +1,96 @@
package main
import (
"context"
"errors"
"fmt"
"os"
"strings"
)
// runCommand führt einen Admin-Unterbefehl aus (kver <befehl> ...). Im
// Container: podman exec kver kver <befehl> ...
func runCommand(args []string) error {
switch args[0] {
case "reset-link":
if len(args) != 2 {
return errors.New("Verwendung: kver reset-link <nutzername>")
}
return cmdResetLink(args[1])
case "link-previews":
if len(args) != 1 {
return errors.New("Verwendung: kver link-previews")
}
return cmdLinkPreviews()
default:
return fmt.Errorf("unbekannter Befehl %q (bekannt: reset-link, link-previews)", args[0])
}
}
// cmdResetLink erzeugt einen Passwort-Reset-Link (siehe reset.go) und gibt ihn
// aus. KVER_BASE_URL (z. B. https://example.org) macht daraus einen fertigen
// Link; ohne steht nur der Pfad da.
func cmdResetLink(username string) error {
username = sanitizeUsername(username)
token, expires, err := createResetToken(username)
if errors.Is(err, errUserNotFound) {
return fmt.Errorf("Nutzer %q nicht gefunden", username)
} else if err != nil {
return err
}
base := strings.TrimSuffix(os.Getenv("KVER_BASE_URL"), "/")
fmt.Printf("Reset-Link für %s (gültig bis %s, einmalig):\n\n %s/reset#%s\n\n",
username, expires.Format("02.01.2006 15:04 MST"), base, token)
fmt.Println("Ältere Reset-Links dieses Nutzers sind damit ungültig.")
return nil
}
// cmdLinkPreviews trägt bei bestehenden Beiträgen den ersten Link nach
// (entry.link_url) und holt alle offenen Vorschauen nacheinander. Läuft der
// Server parallel, holt er höchstens dieselben Links noch einmal -- harmlos.
func cmdLinkPreviews() error {
rows, err := db.Query(`SELECT pid, content FROM entry WHERE deleted = 0 AND link_url = '' AND content LIKE '%http%'`)
if err != nil {
return err
}
links := map[int64]string{}
for rows.Next() {
var pid int64
var content string
if err := rows.Scan(&pid, &content); err != nil {
rows.Close()
return err
}
if key := previewKey(content); key != "" {
links[pid] = key
}
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
for pid, key := range links {
if _, err := db.Exec(`UPDATE entry SET link_url = $1 WHERE pid = $2`, key, pid); err != nil {
return err
}
if _, err := db.Exec(`INSERT INTO link_preview (url) VALUES ($1) ON CONFLICT DO NOTHING`, key); err != nil {
return err
}
}
fmt.Printf("%d Beiträge mit Link nachgetragen.\n", len(links))
pending := pendingPreviews(100000)
for i, key := range pending {
fmt.Printf("[%d/%d] %s\n", i+1, len(pending), key)
fetchPreview(context.Background(), key)
}
var ok, none int
if err := db.QueryRow(
`SELECT COUNT(*) FILTER (WHERE status = 'ok'), COUNT(*) FILTER (WHERE status = 'none') FROM link_preview`).
Scan(&ok, &none); err != nil {
return err
}
fmt.Printf("Vorschauen: %d fertig, %d ohne Vorschau.\n", ok, none)
return nil
}
+74 -62
View File
@@ -2,53 +2,59 @@ package main
import ( import (
"database/sql" "database/sql"
"strings" "errors"
_ "modernc.org/sqlite" "github.com/jackc/pgx/v5/pgconn"
_ "github.com/jackc/pgx/v5/stdlib"
) )
var db *sql.DB var db *sql.DB
// Zeitstempel sind Unix-Sekunden (BIGINT) statt timestamptz: das JSON-Format
// der API liefert sie so aus, und die Bump-Rechnung arbeitet direkt damit.
// "user" ist in Postgres ein reserviertes Wort, daher heißt die Tabelle account.
const schema = ` const schema = `
CREATE TABLE IF NOT EXISTS user ( CREATE TABLE IF NOT EXISTS account (
id INTEGER PRIMARY KEY AUTOINCREMENT, id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
uid INTEGER UNIQUE, uid BIGINT NOT NULL UNIQUE,
username TEXT UNIQUE, username TEXT NOT NULL UNIQUE,
password BLOB, password BYTEA NOT NULL,
created_at INTEGER, created_at BIGINT NOT NULL,
last_login INTEGER, last_login BIGINT,
avatar TEXT NOT NULL DEFAULT '' avatar TEXT NOT NULL DEFAULT ''
); );
CREATE TABLE IF NOT EXISTS session ( CREATE TABLE IF NOT EXISTS session (
id INTEGER PRIMARY KEY AUTOINCREMENT, id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
uid INTEGER, uid BIGINT NOT NULL,
value TEXT UNIQUE, value TEXT NOT NULL UNIQUE,
created_at INTEGER, created_at BIGINT NOT NULL,
expires INTEGER, expires BIGINT NOT NULL,
description TEXT description TEXT NOT NULL DEFAULT ''
); );
CREATE TABLE IF NOT EXISTS entry ( CREATE TABLE IF NOT EXISTS entry (
id INTEGER PRIMARY KEY AUTOINCREMENT, id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
pid INTEGER UNIQUE NOT NULL, pid BIGINT NOT NULL UNIQUE,
uid INTEGER NOT NULL, uid BIGINT NOT NULL,
created_at INTEGER NOT NULL, created_at BIGINT NOT NULL,
content TEXT NOT NULL DEFAULT '', content TEXT NOT NULL DEFAULT '',
filepath TEXT NOT NULL DEFAULT '', filepath TEXT NOT NULL DEFAULT '',
reply_to INTEGER NOT NULL DEFAULT 0, reply_to BIGINT NOT NULL DEFAULT 0,
reply_count INTEGER NOT NULL DEFAULT 0, reply_count BIGINT NOT NULL DEFAULT 0,
last_activity INTEGER NOT NULL DEFAULT 0, last_activity BIGINT NOT NULL DEFAULT 0,
deleted INTEGER NOT NULL DEFAULT 0, deleted BIGINT NOT NULL DEFAULT 0,
bump_count INTEGER NOT NULL DEFAULT 0, bump_count BIGINT NOT NULL DEFAULT 0,
last_bump INTEGER NOT NULL DEFAULT 0 last_bump BIGINT NOT NULL DEFAULT 0
); );
-- Kanonische URL des ersten Links im Inhalt (previewKey), '' ohne Link.
ALTER TABLE entry ADD COLUMN IF NOT EXISTS link_url TEXT NOT NULL DEFAULT '';
CREATE TABLE IF NOT EXISTS vote ( CREATE TABLE IF NOT EXISTS vote (
id INTEGER PRIMARY KEY AUTOINCREMENT, id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
uid INTEGER, uid BIGINT NOT NULL,
pid INTEGER, pid BIGINT NOT NULL,
mode TEXT, mode TEXT NOT NULL,
UNIQUE (uid, pid) UNIQUE (uid, pid)
); );
@@ -63,22 +69,43 @@ CREATE TABLE IF NOT EXISTS impression (
anon_ip TEXT NOT NULL, anon_ip TEXT NOT NULL,
path TEXT NOT NULL DEFAULT '', path TEXT NOT NULL DEFAULT '',
asn TEXT NOT NULL DEFAULT '', asn TEXT NOT NULL DEFAULT '',
hits INTEGER NOT NULL DEFAULT 0, hits BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (day, anon_ip, path) PRIMARY KEY (day, anon_ip, path)
); );
-- Meldungen zu Beiträgen (DSA-Melde-/Abhilfeverfahren). Bewusst ohne Auth: -- Meldungen von Beiträgen. handled bleibt vorerst 0; die Abarbeitung folgt
-- jeder darf melden, weil es eine Moderationshilfe ist. uid ist der Melder, -- mit der Moderation (notes/todo.md).
-- 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 ( CREATE TABLE IF NOT EXISTS report (
id INTEGER PRIMARY KEY AUTOINCREMENT, id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
pid INTEGER NOT NULL, pid BIGINT NOT NULL,
uid INTEGER NOT NULL DEFAULT 0, uid BIGINT NOT NULL DEFAULT 0,
reason TEXT NOT NULL DEFAULT '', reason TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL, created_at BIGINT NOT NULL,
handled INTEGER NOT NULL DEFAULT 0 handled BIGINT NOT NULL DEFAULT 0
);
-- Einmal-Links zum Passwort-Reset, per CLI erzeugt (kver reset-link, siehe
-- reset.go). Gespeichert wird nur der SHA-256 des Tokens: wer die DB lesen
-- kann, kann damit trotzdem kein Passwort setzen.
CREATE TABLE IF NOT EXISTS password_reset (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
uid BIGINT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
created_at BIGINT NOT NULL,
expires BIGINT NOT NULL
);
-- Linkvorschauen, eine Zeile je kanonischer URL (siehe preview.go), geteilt
-- von allen Beiträgen mit diesem Link. status: pending (wird geholt), ok,
-- none (keine Vorschau möglich). thumb ist das lokal gespeicherte Bild.
CREATE TABLE IF NOT EXISTS link_preview (
url TEXT PRIMARY KEY,
kind TEXT NOT NULL DEFAULT '',
title TEXT NOT NULL DEFAULT '',
site TEXT NOT NULL DEFAULT '',
thumb TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
fetched_at BIGINT NOT NULL DEFAULT 0
); );
-- Indizes für die häufigen Zugriffspfade; ohne sie werden Feed (ORDER BY -- Indizes für die häufigen Zugriffspfade; ohne sie werden Feed (ORDER BY
@@ -95,35 +122,20 @@ CREATE INDEX IF NOT EXISTS idx_report_open ON report(handled, created_at
func initDB(dsn string) error { func initDB(dsn string) error {
var err error var err error
// SQLite hat genau einen Schreiber: busy_timeout wartet bei Sperren, statt db, err = sql.Open("pgx", dsn)
// sofort mit SQLITE_BUSY zu scheitern; WAL erlaubt parallele Leser.
db, err = sql.Open("sqlite", dsn+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(on)")
if err != nil { if err != nil {
return err return err
} }
if err = db.Ping(); err != nil { if err = db.Ping(); err != nil {
return err return err
} }
if _, err = db.Exec(schema); err != nil { _, err = db.Exec(schema)
return err return err
} }
return migrate()
}
// migrate ergänzt Spalten, die nach dem ersten Deploy dazukamen. CREATE TABLE IF // isUniqueViolation meldet, ob err am UNIQUE-Constraint constraint scheiterte
// NOT EXISTS fasst eine bestehende Tabelle nicht an -> neue Spalten brauchen ein // (Postgres-Namen nach Schema <tabelle>_<spalte>_key, z. B. account_username_key).
// ALTER. SQLite kann ADD COLUMN nicht "IF NOT EXISTS", deshalb schlucken wir den func isUniqueViolation(err error, constraint string) bool {
// "duplicate column"-Fehler: bei einer frischen DB sind die Spalten schon aus var pgErr *pgconn.PgError
// dem Schema da, bei einer alten werden sie hier nachgezogen. return errors.As(err, &pgErr) && pgErr.Code == "23505" && pgErr.ConstraintName == constraint
func migrate() error {
alters := []string{
`ALTER TABLE entry ADD COLUMN bump_count INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE entry ADD COLUMN last_bump INTEGER NOT NULL DEFAULT 0`,
}
for _, stmt := range alters {
if _, err := db.Exec(stmt); err != nil && !strings.Contains(err.Error(), "duplicate column") {
return err
}
}
return nil
} }
Executable
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
#
# dev-up.sh — baut das Image aus dem aktuellen Arbeitsverzeichnis neu und
# startet den lokalen DEV-Container (kver-dev) damit neu.
#
# Verwendung:
# ./dev-up.sh
#
# Läuft auch automatisch nach jedem Commit (githooks/post-commit, im
# Hintergrund, Log in .git/dev-up.log). Einrichten einmalig per:
# git config core.hooksPath githooks
#
# Voraussetzungen:
# - dev.env mit KVER_DSN (Host "localhost" wird für den Container auf
# host.containers.internal umgeschrieben)
# - Daten/Medien aus dem PROD-Dump unter $KVER_DEV_DATA (read-only gemountet)
#
set -euo pipefail
cd "$(dirname "$0")"
NAME=kver-dev
IMAGE=localhost/kver:dev
PORT="${KVER_DEV_PORT:-8081}"
DATA="${KVER_DEV_DATA:-./prod_backup/2026-09-26}"
# Nur ein Lauf gleichzeitig (schnell aufeinanderfolgende Commits). podman
# bekommt fd 9 jeweils nicht mit (9>&-): conmon lebt so lange wie der
# Container und würde den Lock sonst für immer halten.
exec 9>.git/dev-up.lock
flock 9
DSN="$(grep '^KVER_DSN=' dev.env | cut -d= -f2- | sed 's/@localhost:/@host.containers.internal:/')"
if [[ -z "$DSN" ]]; then
echo "FEHLER: KVER_DSN fehlt in dev.env." >&2
exit 1
fi
echo "== $(date '+%F %T') Baue $IMAGE ($(git rev-parse --short HEAD))"
podman build -q -t "$IMAGE" . 9>&-
mounts=()
[[ -d "$DATA/data" ]] && mounts+=(-v "$DATA/data:/app/data:ro" -e KVER_GEOIP_ASN=/app/data/GeoLite2-ASN.mmdb)
if [[ -d "$DATA/media" ]]; then
mounts+=(-v "$DATA/media:/app/static/media:ro")
# Linkvorschau-Bilder schreibt der Server selbst: beschreibbares Volume über
# dem read-only Media-Mount. Der Mountpunkt muss im Quellverzeichnis
# existieren, weil der Container ihn im read-only Mount nicht anlegen kann.
mkdir -p "$DATA/media/preview"
mounts+=(-v kver-dev-preview:/app/static/media/preview:U)
fi
podman rm -f "$NAME" >/dev/null 2>&1 9>&- || true
podman run -d --name "$NAME" -p "127.0.0.1:$PORT:8080" \
-e KVER_DSN="$DSN" \
"${mounts[@]}" \
--security-opt label=disable "$IMAGE" >/dev/null 9>&-
# Warten, bis die API antwortet.
for _ in $(seq 30); do
if curl -fs -o /dev/null "http://127.0.0.1:$PORT/api/stats"; then
echo "== läuft: http://127.0.0.1:$PORT"
exit 0
fi
sleep 1
done
echo "FEHLER: $NAME antwortet nicht. Log:" >&2
podman logs --tail 20 "$NAME" >&2
exit 1
+357 -1045
View File
File diff suppressed because it is too large Load Diff
+274 -410
View File
@@ -1,7 +1,6 @@
package main package main
import ( import (
"database/sql"
"errors" "errors"
"fmt" "fmt"
"image" "image"
@@ -9,11 +8,9 @@ import (
"image/jpeg" "image/jpeg"
_ "image/png" _ "image/png"
"io" "io"
"log"
"math/rand/v2" "math/rand/v2"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"net/url"
"os" "os"
"strconv" "strconv"
"strings" "strings"
@@ -37,26 +34,58 @@ type Entry struct {
LastBump int64 `json:"last_bump"` LastBump int64 `json:"last_bump"`
Username string `json:"username"` Username string `json:"username"`
Avatar string `json:"avatar"` Avatar string `json:"avatar"`
// Preview ist die fertige Vorschau des ersten Links, siehe preview.go.
Preview *Preview `json:"preview,omitempty"`
// Latest ist nur in Feed und Antwortliste gesetzt, siehe attachLatest.
Latest *Latest `json:"latest,omitempty"`
}
// Latest ist der jüngste (nicht gelöschte) Beitrag im Teilbaum unter einem
// Beitrag -- die Antwort, die die Diskussion zuletzt fortgesetzt hat. Skipped
// zählt die Beiträge, die auf dem Pfad dazwischen liegen (0 = direkte Antwort).
type Latest struct {
Entry Entry `json:"entry"`
Skipped int64 `json:"skipped"`
} }
// LEFT JOIN + COALESCE, damit soft-gelöschte Beiträge (uid auf 0 gesetzt) als // LEFT JOIN + COALESCE, damit soft-gelöschte Beiträge (uid auf 0 gesetzt) als
// [deleted]-Platzhalter erhalten bleiben und Threads nicht verwaisen. // [deleted]-Platzhalter erhalten bleiben und Threads nicht verwaisen. Die
// Linkvorschau hängt nur dran, wenn sie fertig ist (status ok).
const entrySelect = ` const entrySelect = `
SELECT e.pid, e.uid, e.created_at, e.content, e.filepath, SELECT e.pid, e.uid, e.created_at, e.content, e.filepath,
e.reply_to, e.reply_count, e.last_activity, e.deleted, e.reply_to, e.reply_count, e.last_activity, e.deleted,
e.bump_count, e.last_bump, e.bump_count, e.last_bump,
COALESCE(u.username, ''), COALESCE(u.avatar, '') COALESCE(u.username, ''), COALESCE(u.avatar, ''),
FROM entry e LEFT JOIN user u ON u.uid = e.uid` COALESCE(lp.url, ''), COALESCE(lp.kind, ''), COALESCE(lp.title, ''),
COALESCE(lp.site, ''), COALESCE(lp.thumb, '')
FROM entry e LEFT JOIN account u ON u.uid = e.uid
LEFT JOIN link_preview lp ON lp.url = e.link_url AND lp.status = 'ok'`
// scanEntries liest Entry-Zeilen aus einem Query mit entrySelect-Spalten. // scanEntry liest eine Zeile mit entrySelect-Spalten (aus *sql.Row oder *sql.Rows).
func scanEntries(rows *sql.Rows) ([]Entry, error) { func scanEntry(row interface{ Scan(...any) error }) (Entry, error) {
var e Entry
var p Preview
err := row.Scan(&e.PID, &e.UID, &e.CreatedAt, &e.Content, &e.Filepath,
&e.ReplyTo, &e.ReplyCount, &e.LastActivity, &e.Deleted,
&e.BumpCount, &e.LastBump, &e.Username, &e.Avatar,
&p.URL, &p.Kind, &p.Title, &p.Site, &p.Thumb)
if p.URL != "" {
e.Preview = &p
}
return e, err
}
// queryEntries führt einen Query mit entrySelect-Spalten aus.
func queryEntries(query string, args ...any) ([]Entry, error) {
rows, err := db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close() defer rows.Close()
entries := []Entry{} entries := []Entry{}
for rows.Next() { for rows.Next() {
var e Entry e, err := scanEntry(rows)
if err := rows.Scan(&e.PID, &e.UID, &e.CreatedAt, &e.Content, &e.Filepath, if err != nil {
&e.ReplyTo, &e.ReplyCount, &e.LastActivity, &e.Deleted,
&e.BumpCount, &e.LastBump, &e.Username, &e.Avatar); err != nil {
return nil, err return nil, err
} }
entries = append(entries, e) entries = append(entries, e)
@@ -66,98 +95,120 @@ func scanEntries(rows *sql.Rows) ([]Entry, error) {
// entryByPID liefert einen einzelnen Beitrag oder sql.ErrNoRows. // entryByPID liefert einen einzelnen Beitrag oder sql.ErrNoRows.
func entryByPID(pid int64) (Entry, error) { func entryByPID(pid int64) (Entry, error) {
var e Entry return scanEntry(db.QueryRow(entrySelect+` WHERE e.pid = $1`, pid))
err := db.QueryRow(entrySelect+` WHERE e.pid = ?`, pid).Scan(
&e.PID, &e.UID, &e.CreatedAt, &e.Content, &e.Filepath,
&e.ReplyTo, &e.ReplyCount, &e.LastActivity, &e.Deleted,
&e.BumpCount, &e.LastBump, &e.Username, &e.Avatar)
return e, err
} }
const ( const (
feedPageSize = 20 feedPageSize = 20
// maxFeedPage deckelt den Offset: sehr große Seitenzahlen zwingen SQLite // maxFeedPage deckelt den Offset: sehr große Seitenzahlen zwingen die
// sonst dazu, entsprechend viele Zeilen zu überspringen. // Datenbank sonst dazu, entsprechend viele Zeilen zu überspringen.
maxFeedPage = 100 maxFeedPage = 100
) )
// feedWhere baut die Filterbedingung für Feed-Abfragen. Von feedPage und // feedPage liefert Seite page (Routenparameter {page}) eines Feed-Queries mit
// feedTotal gemeinsam benutzt -- zwei getrennte Kopien würden auseinander // entrySelect-Spalten; LIMIT und OFFSET werden hinter args angehängt.
// laufen und die Seitenzahl gegenüber dem Inhalt verfälschen. func feedPage(r *http.Request, query string, args ...any) ([]Entry, error) {
func feedWhere(uid int64, rootsOnly bool) (string, []any) {
args := []any{}
where := []string{}
if uid != 0 {
where = append(where, "e.uid = ?")
args = append(args, uid)
}
if rootsOnly {
where = append(where, "e.reply_to = 0")
}
if len(where) == 0 {
return "", args
}
return " WHERE " + strings.Join(where, " AND "), args
}
// feedTotal zählt die Beiträge, die feedPage mit denselben Filtern liefern
// würde -- Grundlage für "Seite n von m" im serverseitig gerenderten Feed.
func feedTotal(uid int64, rootsOnly bool) (int, error) {
where, args := feedWhere(uid, rootsOnly)
var n int
err := db.QueryRow(
`SELECT COUNT(*) FROM entry e LEFT JOIN user u ON u.uid = e.uid`+where, args...,
).Scan(&n)
return n, err
}
// feedPage liefert eine Feed-Seite (feedPageSize Beiträge). Ist uid != 0, nur die Beiträge
// dieses Nutzers. rootsOnly blendet Antworten aus und sortiert nach Aktivität;
// sonst werden alle Beiträge chronologisch geliefert.
func feedPage(page int, uid int64, rootsOnly bool) ([]Entry, error) {
if page < 0 {
page = 0
}
if page > maxFeedPage {
page = maxFeedPage
}
where, args := feedWhere(uid, rootsOnly)
query := entrySelect + where
if rootsOnly {
query += ` ORDER BY e.last_activity DESC`
} else {
query += ` ORDER BY e.created_at DESC`
}
query += ` LIMIT ? OFFSET ?`
args = append(args, feedPageSize, page*feedPageSize)
rows, err := db.Query(query, args...)
if err != nil {
return nil, err
}
return scanEntries(rows)
}
func handleFeed(w http.ResponseWriter, r *http.Request) error {
page, _ := strconv.Atoi(chi.URLParam(r, "page")) page, _ := strconv.Atoi(chi.URLParam(r, "page"))
entries, err := feedPage(page, 0, true) page = min(max(page, 0), maxFeedPage)
n := len(args)
query += fmt.Sprintf(` LIMIT $%d OFFSET $%d`, n+1, n+2)
return queryEntries(query, append(args, feedPageSize, page*feedPageSize)...)
}
// attachLatest hängt an jeden Beitrag den jüngsten nicht gelöschten Beitrag
// aus seinem Teilbaum (Latest). So bildet eine flache Liste auch tiefe
// Diskussionen ab: [Beitrag] -- "k weitere" -- [jüngste Antwort], ein Klick
// führt ans aktuelle Ende.
//
// Ein Query für alle Beiträge: die rekursive CTE steigt von allen zugleich
// ab, top merkt sich den Ausgangsbeitrag, depth die Tiefe darunter. Das Limit
// deckelt die Tiefe wie bei der Ahnenkette (handleThread).
func attachLatest(entries []Entry) error {
if len(entries) == 0 {
return nil
}
pids := make([]int64, len(entries))
for i, e := range entries {
pids[i] = e.PID
}
rows, err := db.Query(
`WITH RECURSIVE sub(top, pid, depth) AS (
SELECT e.reply_to, e.pid, 1 FROM entry e WHERE e.reply_to = ANY($1)
UNION ALL
SELECT s.top, e.pid, s.depth + 1
FROM entry e JOIN sub s ON e.reply_to = s.pid
WHERE s.depth < 50
)
SELECT DISTINCT ON (s.top) s.top, s.pid, s.depth
FROM sub s JOIN entry x ON x.pid = s.pid
WHERE x.deleted = 0
ORDER BY s.top, x.created_at DESC, x.id DESC`, pids)
if err != nil { if err != nil {
return err
}
type hit struct{ pid, depth int64 }
hits := map[int64]hit{}
latestPIDs := []int64{}
for rows.Next() {
var top int64
var h hit
if err := rows.Scan(&top, &h.pid, &h.depth); err != nil {
rows.Close()
return err
}
hits[top] = h
latestPIDs = append(latestPIDs, h.pid)
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
if len(latestPIDs) == 0 {
return nil
}
latest, err := queryEntries(entrySelect+` WHERE e.pid = ANY($1)`, latestPIDs)
if err != nil {
return err
}
byPID := make(map[int64]Entry, len(latest))
for _, e := range latest {
byPID[e.PID] = e
}
for i := range entries {
if h, ok := hits[entries[i].PID]; ok {
if e, ok := byPID[h.pid]; ok {
entries[i].Latest = &Latest{Entry: e, Skipped: h.depth - 1}
}
}
}
return nil
}
// handleFeed liefert die Wurzelbeiträge nach letzter Aktivität, jeweils mit
// ihrer jüngsten Antwort.
func handleFeed(w http.ResponseWriter, r *http.Request) error {
entries, err := feedPage(r, entrySelect+` WHERE e.reply_to = 0 ORDER BY e.last_activity DESC`)
if err != nil {
return Internal(err)
}
if err := attachLatest(entries); err != nil {
return Internal(err) return Internal(err)
} }
writeJSON(w, http.StatusOK, entries) writeJSON(w, http.StatusOK, entries)
return nil return nil
} }
// handleUserFeed liefert den Feed eines einzelnen Nutzers (öffentlich, inkl. Antworten). // handleUserFeed liefert alle Beiträge eines Nutzers chronologisch, Antworten
// eingeschlossen.
func handleUserFeed(w http.ResponseWriter, r *http.Request) error { func handleUserFeed(w http.ResponseWriter, r *http.Request) error {
var uid int64 var uid int64
if err := db.QueryRow(`SELECT uid FROM user WHERE username = ?`, chi.URLParam(r, "username")).Scan(&uid); err != nil { if err := db.QueryRow(`SELECT uid FROM account WHERE username = $1`, chi.URLParam(r, "username")).Scan(&uid); err != nil {
return errUserNotFound return errUserNotFound
} }
page, _ := strconv.Atoi(chi.URLParam(r, "page")) entries, err := feedPage(r, entrySelect+` WHERE e.uid = $1 ORDER BY e.created_at DESC`, uid)
entries, err := feedPage(page, uid, false)
if err != nil { if err != nil {
return Internal(err) return Internal(err)
} }
@@ -165,12 +216,6 @@ func handleUserFeed(w http.ResponseWriter, r *http.Request) error {
return nil 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 ( var (
errEntryNotFound = NotFound("entry.not_found", "Beitrag nicht gefunden") errEntryNotFound = NotFound("entry.not_found", "Beitrag nicht gefunden")
errEntryNotOwned = Forbidden("entry.not_owner", "Das ist nicht dein Beitrag.") errEntryNotOwned = Forbidden("entry.not_owner", "Das ist nicht dein Beitrag.")
@@ -186,81 +231,58 @@ func pidParam(r *http.Request) (int64, error) {
return pid, nil return pid, nil
} }
// handleThread liefert einen Beitrag mit seiner Ahnenkette (Root zuerst) und den // thread ist ein Beitrag mit seinem Kontext: Ahnenkette (Root zuerst) und
// direkten Antworten (öffentlich). // direkte Antworten samt deren jüngstem Nachfahren.
type thread struct {
Entry Entry `json:"entry"`
Ancestors []Entry `json:"ancestors"`
Replies []Entry `json:"replies"`
}
func handleThread(w http.ResponseWriter, r *http.Request) error { func handleThread(w http.ResponseWriter, r *http.Request) error {
pid, err := pidParam(r) pid, err := pidParam(r)
if err != nil { if err != nil {
return err return err
} }
t, err := threadData(pid)
if err != nil {
return err
}
writeJSON(w, http.StatusOK, map[string]any{
"entry": t.Entry,
"ancestors": t.Ancestors,
"replies": t.Replies,
})
return nil
}
// thread ist ein Beitrag mit seinem Kontext: Ahnenkette (Root zuerst) und
// direkte Antworten.
type thread struct {
Entry Entry
Ancestors []Entry
Replies []Entry
}
// threadData holt einen Thread aus der Datenbank. Von beiden Adaptern benutzt
// (JSON über handleThread, HTML über showThread) -- die rekursive CTE gibt es
// deshalb nur einmal.
func threadData(pid int64) (thread, error) {
entry, err := entryByPID(pid) entry, err := entryByPID(pid)
if err != nil { if err != nil {
return thread{}, errEntryNotFound return errEntryNotFound
} }
t := thread{Entry: entry, Ancestors: []Entry{}, Replies: []Entry{}} t := thread{Entry: entry, Ancestors: []Entry{}}
// Ahnenkette (Root zuerst) per rekursiver CTE in einem Query statt bis zu // depth zählt vom Beitrag aufwärts; ORDER BY depth DESC dreht die Kette
// 50 Einzelqueries. depth zählt vom Beitrag aufwärts; ORDER BY depth DESC // auf Root-zuerst. Das Limit deckelt die Kettenlänge und bricht auch einen
// dreht die Kette auf Root-zuerst. Das Limit deckelt die Kettenlänge und // (theoretischen) Zyklus ab.
// bricht auch einen (theoretischen) Zyklus ab.
if entry.ReplyTo != 0 { if entry.ReplyTo != 0 {
rows, err := db.Query( t.Ancestors, err = queryEntries(
`WITH RECURSIVE chain(pid, depth) AS ( `WITH RECURSIVE chain(pid, depth) AS (
SELECT ?, 1 SELECT $1::bigint, 1
UNION ALL UNION ALL
SELECT e.reply_to, c.depth + 1 SELECT e.reply_to, c.depth + 1
FROM entry e JOIN chain c ON e.pid = c.pid FROM entry e JOIN chain c ON e.pid = c.pid
WHERE e.reply_to != 0 AND c.depth < 50 WHERE e.reply_to <> 0 AND c.depth < 50
)`+entrySelect+` )`+entrySelect+`
JOIN chain c ON c.pid = e.pid JOIN chain c ON c.pid = e.pid
ORDER BY c.depth DESC`, entry.ReplyTo) ORDER BY c.depth DESC`, entry.ReplyTo)
if err != nil { if err != nil {
return thread{}, Internal(err) return Internal(err)
}
if t.Ancestors, err = scanEntries(rows); err != nil {
return thread{}, Internal(err)
} }
} }
rows, err := db.Query(entrySelect+` WHERE e.reply_to = ? ORDER BY e.last_activity DESC LIMIT 100`, pid) t.Replies, err = queryEntries(entrySelect+` WHERE e.reply_to = $1 ORDER BY e.last_activity DESC LIMIT 100`, pid)
if err != nil { if err != nil {
return thread{}, Internal(err) return Internal(err)
} }
if t.Replies, err = scanEntries(rows); err != nil { if err := attachLatest(t.Replies); err != nil {
return thread{}, Internal(err) return Internal(err)
} }
return t, nil
writeJSON(w, http.StatusOK, t)
return nil
} }
func handleCreateEntry(w http.ResponseWriter, r *http.Request) error { func handleCreateEntry(w http.ResponseWriter, r *http.Request) error {
uid := uidFromContext(r.Context()) uid := uidFromContext(r.Context())
json := wantsJSON(r)
// MaxBytesReader kappt den gesamten Request hart: ParseMultipartForm // MaxBytesReader kappt den gesamten Request hart: ParseMultipartForm
// begrenzt nur den Speicher, alles darüber liefe sonst unbegrenzt in // begrenzt nur den Speicher, alles darüber liefe sonst unbegrenzt in
@@ -268,11 +290,6 @@ func handleCreateEntry(w http.ResponseWriter, r *http.Request) error {
r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes) r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes)
parseErr := r.ParseMultipartForm(16 << 20) parseErr := r.ParseMultipartForm(16 << 20)
if parseErr != nil && parseErr != http.ErrNotMultipart { if parseErr != nil && parseErr != http.ErrNotMultipart {
// Hier ist der Formularinhalt nicht mehr lesbar (der Body ist weg oder
// kaputt) -- der Text lässt sich also nicht retten.
if !json {
return failCompose(w, r, 0, composeForm{}, errUploadTooLargeOrBroken(parseErr))
}
return errUploadTooLargeOrBroken(parseErr) return errUploadTooLargeOrBroken(parseErr)
} }
@@ -280,18 +297,9 @@ func handleCreateEntry(w http.ResponseWriter, r *http.Request) error {
hadFile := r.MultipartForm != nil && len(r.MultipartForm.File["file"]) > 0 hadFile := r.MultipartForm != nil && len(r.MultipartForm.File["file"]) > 0
replyTo, _ := strconv.ParseInt(r.FormValue("reply_to"), 10, 64) replyTo, _ := strconv.ParseInt(r.FormValue("reply_to"), 10, 64)
// fail zeichnet für Formular-Clients die Seite neu, auf der geschrieben
// wurde -- Thread bei einer Antwort, sonst den Feed.
fail := func(err error) error {
if json {
return err
}
return failCompose(w, r, replyTo, composeForm{Content: content, HadFile: hadFile}, err)
}
if replyTo != 0 { if replyTo != 0 {
if _, err := entryByPID(replyTo); err != nil { if _, err := entryByPID(replyTo); err != nil {
return fail(Invalid("entry.parent_missing", "Elternbeitrag nicht gefunden").At("reply_to")) return Invalid("entry.parent_missing", "Elternbeitrag nicht gefunden").At("reply_to")
} }
} }
@@ -301,38 +309,45 @@ func handleCreateEntry(w http.ResponseWriter, r *http.Request) error {
if err != nil { if err != nil {
// Nicht stillschweigend ohne Bild posten -- der Nutzer soll // Nicht stillschweigend ohne Bild posten -- der Nutzer soll
// erfahren, dass sein Bild abgelehnt wurde. // erfahren, dass sein Bild abgelehnt wurde.
return fail(Invalid("image.rejected", "Bild konnte nicht verarbeitet werden.").At("file").WithCause(err)) return Invalid("image.rejected", "Bild konnte nicht verarbeitet werden.").At("file").WithCause(err)
} }
filepath = stored filepath = stored
} }
if filepath == "" && content == "" { if filepath == "" && content == "" {
return fail(Invalid("entry.empty", "Leerer Beitrag!").At("content")) return Invalid("entry.empty", "Leerer Beitrag!").At("content")
} }
now := time.Now().Unix() now := time.Now().Unix()
linkURL := previewKey(content)
tx, err := db.Begin() tx, err := db.Begin()
if err != nil { if err != nil {
return Internal(err) return Internal(err)
} }
// pid würfeln; +1, weil 0 der Sentinel für "kein Elternbeitrag"/"gelöschter // pid würfeln; +1, weil 0 der Sentinel für "kein Elternbeitrag"/"gelöschter
// Autor" ist. Bei der seltenen UNIQUE-Kollision neu würfeln statt 500. // Autor" ist. Kollisionen über ON CONFLICT statt über den Fehler: ein
// fehlgeschlagenes Statement bricht in Postgres die ganze Transaktion ab.
var pid int64 var pid int64
for try := 0; ; try++ { for try := 0; ; try++ {
pid = int64(rand.IntN(999999999999)) + 1 pid = int64(rand.IntN(999999999999)) + 1
_, err := tx.Exec( res, err := tx.Exec(
`INSERT INTO entry (pid, uid, created_at, content, filepath, reply_to, reply_count, last_activity) `INSERT INTO entry (pid, uid, created_at, content, filepath, reply_to, reply_count, last_activity, link_url)
VALUES (?, ?, ?, ?, ?, ?, 0, ?)`, VALUES ($1, $2, $3, $4, $5, $6, 0, $7, $8)
pid, uid, now, content, filepath, replyTo, now, ON CONFLICT (pid) DO NOTHING`,
pid, uid, now, content, filepath, replyTo, now, linkURL,
) )
if err == nil { if err != nil {
break
}
if try >= 4 || !strings.Contains(err.Error(), "UNIQUE") {
tx.Rollback() tx.Rollback()
return Internal(err) return Internal(err)
} }
if n, _ := res.RowsAffected(); n == 1 {
break
}
if try >= 4 {
tx.Rollback()
return Internal(errors.New("pid-Kollision nach 5 Versuchen"))
}
} }
// Antwort: reply_count und last_activity aller Vorfahren bis zum Root // Antwort: reply_count und last_activity aller Vorfahren bis zum Root
// anheben -- per rekursiver CTE in einem Statement statt bis zu 50 // anheben -- per rekursiver CTE in einem Statement statt bis zu 50
@@ -341,14 +356,14 @@ func handleCreateEntry(w http.ResponseWriter, r *http.Request) error {
if replyTo != 0 { if replyTo != 0 {
if _, err := tx.Exec( if _, err := tx.Exec(
`WITH RECURSIVE chain(pid, depth) AS ( `WITH RECURSIVE chain(pid, depth) AS (
SELECT ?, 0 SELECT $1::bigint, 0
UNION ALL UNION ALL
SELECT e.reply_to, c.depth + 1 SELECT e.reply_to, c.depth + 1
FROM entry e JOIN chain c ON e.pid = c.pid FROM entry e JOIN chain c ON e.pid = c.pid
WHERE e.reply_to != 0 AND c.depth < 50 WHERE e.reply_to <> 0 AND c.depth < 50
) )
UPDATE entry SET reply_count = reply_count + 1, UPDATE entry SET reply_count = reply_count + 1,
last_activity = MAX(last_activity, ?) last_activity = GREATEST(last_activity, $2)
WHERE pid IN (SELECT pid FROM chain)`, WHERE pid IN (SELECT pid FROM chain)`,
replyTo, now, replyTo, now,
); err != nil { ); err != nil {
@@ -359,18 +374,7 @@ func handleCreateEntry(w http.ResponseWriter, r *http.Request) error {
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
return Internal(err) return Internal(err)
} }
requestPreview(linkURL)
// Ohne JavaScript zum frisch erstellten Beitrag. Nicht über return_to:
// ein neuer Wurzelbeitrag steht oben auf Seite 1, egal von welcher Seite
// aus er geschrieben wurde. Bei einer Antwort führt der Weg in den Thread.
if !json {
target := fmt.Sprintf("/feed#e%d", pid)
if replyTo != 0 {
target = fmt.Sprintf("/beitrag/%d#e%d", replyTo, pid)
}
http.Redirect(w, r, target, http.StatusSeeOther)
return nil
}
writeJSON(w, http.StatusCreated, map[string]any{ writeJSON(w, http.StatusCreated, map[string]any{
"pid": pid, "pid": pid,
@@ -385,41 +389,45 @@ func handleCreateEntry(w http.ResponseWriter, r *http.Request) error {
// bleibt unverändert; last_activity wird nicht angefasst (eine Korrektur soll den // bleibt unverändert; last_activity wird nicht angefasst (eine Korrektur soll den
// Thread nicht nach oben spülen). // Thread nicht nach oben spülen).
func handleEditEntry(w http.ResponseWriter, r *http.Request) error { func handleEditEntry(w http.ResponseWriter, r *http.Request) error {
json := wantsJSON(r)
pid, err := pidParam(r) pid, err := pidParam(r)
if err != nil { if err != nil {
return err return err
} }
// Dieselbe Prüfung wie beim Anzeigen des Formulars -- Besitz und Zustand
// können sich zwischen Aufruf und Absenden geändert haben.
if _, err := ownEntry(r, pid); err != nil { if _, err := ownEntry(r, pid); err != nil {
return err return err
} }
content := truncateRunes(strings.TrimSpace(r.FormValue("content")), 1000) content := truncateRunes(strings.TrimSpace(r.FormValue("content")), 1000)
if content == "" { if content == "" {
e := Invalid("entry.empty", "Inhalt darf nicht leer sein.").At("content") return Invalid("entry.empty", "Inhalt darf nicht leer sein.").At("content")
if json {
return e
}
return failEdit(w, r, pid, content, e)
} }
if _, err := db.Exec(`UPDATE entry SET content = ? WHERE pid = ?`, content, pid); err != nil { linkURL := previewKey(content)
if _, err := db.Exec(`UPDATE entry SET content = $1, link_url = $2 WHERE pid = $3`, content, linkURL, pid); err != nil {
return Internal(err) return Internal(err)
} }
requestPreview(linkURL)
// Zurück dorthin, wo der Bearbeiten-Link angeklickt wurde -- der Beitrag
// kann im Feed, im Nutzerfeed oder in einem Thread gestanden haben.
if !json {
redirectBack(w, r, threadAnchor(pid))
return nil
}
writeJSON(w, http.StatusOK, map[string]any{"pid": pid, "content": content}) writeJSON(w, http.StatusOK, map[string]any{"pid": pid, "content": content})
return nil return nil
} }
// ownEntry lädt einen Beitrag und prüft, ob er dem Anfragenden gehört --
// Voraussetzung fürs Bearbeiten wie fürs Löschen.
func ownEntry(r *http.Request, pid int64) (Entry, error) {
e, err := entryByPID(pid)
if err != nil {
return Entry{}, errEntryNotFound
}
// Soft-gelöschte Beiträge stehen auf uid 0 und scheitern damit an derselben
// Besitzprüfung -- sie gehören niemandem mehr. Kein eigener Zweig dafür:
// so bleibt es bei den 403, die der Endpunkt schon immer geliefert hat.
if e.UID != uidFromContext(r.Context()) {
return Entry{}, errEntryNotOwned
}
return e, nil
}
// handleDeleteEntry löscht einen eigenen Beitrag "weich": die Zeile bleibt als // handleDeleteEntry löscht einen eigenen Beitrag "weich": die Zeile bleibt als
// [deleted]-Platzhalter erhalten (damit Antworten nicht verwaisen), Inhalt, Bild // [deleted]-Platzhalter erhalten (damit Antworten nicht verwaisen), Inhalt, Bild
// und Autor werden entfernt. // und Autor werden entfernt.
@@ -428,15 +436,15 @@ func handleDeleteEntry(w http.ResponseWriter, r *http.Request) error {
if err != nil { if err != nil {
return err return err
} }
// Dieselbe Prüfung wie auf der Bestätigungsseite; entryByPID liefert dabei // ownEntry liefert auch den Bildpfad, der nach dem UPDATE nicht mehr in
// auch den Bildpfad, der nach dem UPDATE nicht mehr in der Zeile steht. // der Zeile steht.
e, err := ownEntry(r, pid) e, err := ownEntry(r, pid)
if err != nil { if err != nil {
return err return err
} }
if _, err := db.Exec( if _, err := db.Exec(
`UPDATE entry SET deleted = 1, content = '', filepath = '', uid = 0 WHERE pid = ?`, pid, `UPDATE entry SET deleted = 1, content = '', filepath = '', link_url = '', uid = 0 WHERE pid = $1`, pid,
); err != nil { ); err != nil {
return Internal(err) return Internal(err)
} }
@@ -444,13 +452,38 @@ func handleDeleteEntry(w http.ResponseWriter, r *http.Request) error {
os.Remove(e.Filepath) os.Remove(e.Filepath)
} }
// Zurück dorthin, wo gelöscht wurde -- der Platzhalter steht dort weiterhin, writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
// der Anker bleibt also gültig.
if !wantsJSON(r) {
redirectBack(w, r, "/feed")
return nil return nil
} }
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
// handleReport meldet einen Beitrag, optional mit Grund. Melden darf jeder;
// ohne Session ist der Melder anonym (uid 0). Angemeldete legen für denselben
// Beitrag keine zweite Meldung an, solange ihre erste offen ist.
func handleReport(w http.ResponseWriter, r *http.Request) error {
pid, err := pidParam(r)
if err != nil {
return err
}
e, err := entryByPID(pid)
if err != nil || e.Deleted != 0 {
return errEntryNotFound
}
reason := truncateRunes(strings.TrimSpace(r.FormValue("reason")), 500)
var uid int64
if s, ok := getSession(r); ok {
uid = s.UID
}
if _, err := db.Exec(
`INSERT INTO report (pid, uid, reason, created_at)
SELECT $1, $2, $3, $4
WHERE $2::bigint = 0 OR NOT EXISTS (SELECT 1 FROM report WHERE pid = $1 AND uid = $2 AND handled = 0)`,
pid, uid, reason, time.Now().Unix(),
); err != nil {
return Internal(err)
}
writeJSON(w, http.StatusOK, map[string]string{"status": "reported"})
return nil return nil
} }
@@ -461,10 +494,9 @@ const bumpStep = 24 * 60 * 60
// handleBump hebt last_activity eines Beitrags auf jetzt -> er steigt im Feed // handleBump hebt last_activity eines Beitrags auf jetzt -> er steigt im Feed
// (Roots, sortiert nach last_activity) bzw. in der Antwortliste eines Threads // (Roots, sortiert nach last_activity) bzw. in der Antwortliste eines Threads
// wieder nach oben. Tritt an die Stelle der früheren "Bump"-Kommentare. Gegen // wieder nach oben. Gegen Spam wächst der Cooldown linear mit der Zahl
// Spam wächst der Cooldown linear mit der Zahl bisheriger Bumps (siehe bumpStep); // bisheriger Bumps (siehe bumpStep); er sitzt am Beitrag selbst (global), nicht
// er sitzt am Beitrag selbst (global), nicht am bumpenden Nutzer. Jeder // am bumpenden Nutzer.
// Eingeloggte darf bumpen (Route in der requireAuth-Gruppe).
func handleBump(w http.ResponseWriter, r *http.Request) error { func handleBump(w http.ResponseWriter, r *http.Request) error {
pid, err := pidParam(r) pid, err := pidParam(r)
if err != nil { if err != nil {
@@ -477,16 +509,16 @@ func handleBump(w http.ResponseWriter, r *http.Request) error {
// gleichzeitige Bumps den Cooldown nicht umgehen können. // gleichzeitige Bumps den Cooldown nicht umgehen können.
now := time.Now().Unix() now := time.Now().Unix()
res, err := db.Exec( res, err := db.Exec(
`UPDATE entry SET last_activity = ?, bump_count = bump_count + 1, last_bump = ? `UPDATE entry SET last_activity = $1, bump_count = bump_count + 1, last_bump = $1
WHERE pid = ? AND deleted = 0 AND last_bump + bump_count * ? <= ?`, WHERE pid = $2 AND deleted = 0 AND last_bump + bump_count * $3 <= $1`,
now, now, pid, bumpStep, now, now, pid, bumpStep,
) )
if err != nil { if err != nil {
return Internal(err) return Internal(err)
} }
var bumpCount, lastBump, deleted int64 var bumpCount, lastBump, deleted int64
if err := db.QueryRow(`SELECT bump_count, last_bump, deleted FROM entry WHERE pid = ?`, pid). if err := db.QueryRow(`SELECT bump_count, last_bump, deleted FROM entry WHERE pid = $1`, pid).
Scan(&bumpCount, &lastBump, &deleted); err != nil || deleted != 0 { Scan(&bumpCount, &lastBump, &deleted); err != nil || deleted != 0 {
return errEntryNotFound return errEntryNotFound
} }
@@ -496,11 +528,8 @@ func handleBump(w http.ResponseWriter, r *http.Request) error {
if remaining < 0 { if remaining < 0 {
remaining = 0 remaining = 0
} }
// Bewusst kein Taxonomie-Fehler: die Antwort trägt Nutzdaten // Kein Taxonomie-Fehler: die Antwort trägt neben dem Fehler Nutzdaten
// (retry_after, bump_count), die das Frontend zum Sperren des Buttons // (retry_after, bump_count), mit denen das Frontend den Button sperrt.
// braucht -- ein reiner Fehlerkörper kann die nicht transportieren.
// Der Cooldown ist auch kein Fehlerfall, sondern ein regulärer
// Ausgang des Bumpens.
w.Header().Set("Retry-After", strconv.FormatInt(remaining, 10)) w.Header().Set("Retry-After", strconv.FormatInt(remaining, 10))
writeJSON(w, http.StatusTooManyRequests, map[string]any{ writeJSON(w, http.StatusTooManyRequests, map[string]any{
"error": apiErrorBody{ "error": apiErrorBody{
@@ -512,6 +541,7 @@ func handleBump(w http.ResponseWriter, r *http.Request) error {
}) })
return nil return nil
} }
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"pid": pid, "pid": pid,
"bump_count": bumpCount, "bump_count": bumpCount,
@@ -522,124 +552,26 @@ func handleBump(w http.ResponseWriter, r *http.Request) error {
return nil return nil
} }
// voteTally liefert die Zähler und die eigene Auswahl (selected) für einen Beitrag. // writeTally schreibt die Zähler und die eigene Auswahl (selected) für einen
// Ist uid == 0 (nicht eingeloggt), ist selected immer "none". // Beitrag. Ist uid == 0 (nicht eingeloggt), ist selected immer "none".
func voteTally(pid, uid int64) (left, right int, selected string) { func writeTally(w http.ResponseWriter, pid, uid int64) error {
selected = "none" var yes, no int64
if uid != 0 { var selected string
var cur string if err := db.QueryRow(
if err := db.QueryRow(`SELECT mode FROM vote WHERE uid = ? AND pid = ?`, uid, pid).Scan(&cur); err == nil { `SELECT COUNT(*) FILTER (WHERE mode = 'yes'),
selected = cur COUNT(*) FILTER (WHERE mode = 'no'),
COALESCE(MAX(mode) FILTER (WHERE uid = $2), 'none')
FROM vote WHERE pid = $1`, pid, uid).
Scan(&yes, &no, &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 = ? 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
}
// tally ist der Abstimmungsstand eines Beitrags aus Sicht eines Betrachters.
type tallyState struct {
Left int
Right int
Selected string // "left", "right" oder "none"
}
// voteTallies holt die Stände für eine ganze Feed-Seite in zwei Abfragen statt
// in zwei pro Beitrag. Bei 20 Beiträgen wären das sonst 40 Roundtrips für eine
// einzige Seitenansicht.
func voteTallies(pids []int64, uid int64) (map[int64]tallyState, error) {
out := make(map[int64]tallyState, len(pids))
for _, pid := range pids {
out[pid] = tallyState{Selected: "none"}
}
if len(pids) == 0 {
return out, nil
}
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(pids)), ",")
args := make([]any, 0, len(pids)+1)
for _, pid := range pids {
args = append(args, pid)
}
rows, err := db.Query(
`SELECT pid, mode, COUNT(*) FROM vote WHERE pid IN (`+placeholders+`) GROUP BY pid, mode`, args...)
if err != nil {
return nil, err
}
for rows.Next() {
var pid int64
var mode string
var n int
if err := rows.Scan(&pid, &mode, &n); err != nil {
rows.Close()
return nil, err
}
t := out[pid]
switch mode {
case "left":
t.Left = n
case "right":
t.Right = n
}
out[pid] = t
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
// Eigene Stimmen nur, wenn überhaupt jemand angemeldet ist.
if uid == 0 {
return out, nil
}
rows, err = db.Query(
`SELECT pid, mode FROM vote WHERE uid = ? AND pid IN (`+placeholders+`)`,
append([]any{uid}, args...)...)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var pid int64
var mode string
if err := rows.Scan(&pid, &mode); err != nil {
return nil, err
}
t := out[pid]
t.Selected = mode
out[pid] = t
}
return out, rows.Err()
}
func writeTally(w http.ResponseWriter, pid, uid int64) {
left, right, selected := voteTally(pid, uid)
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"pid": pid, "pid": pid,
"left": left, "yes": yes,
"right": right, "no": no,
"selected": selected, "selected": selected,
}) })
return nil
} }
// handleVotes liest den Abstimmungsstand eines Beitrags (read-only, öffentlich). // handleVotes liest den Abstimmungsstand eines Beitrags (read-only, öffentlich).
@@ -653,72 +585,7 @@ func handleVotes(w http.ResponseWriter, r *http.Request) error {
if s, ok := getSession(r); ok { if s, ok := getSession(r); ok {
uid = s.UID uid = s.UID
} }
writeTally(w, pid, uid) return writeTally(w, pid, uid)
return nil
}
// handleReport nimmt eine Meldung zu einem Beitrag entgegen (DSA-Melde-
// verfahren). Bewusst ohne Auth -- jeder darf melden, weil es eine
// Moderationshilfe ist. Die Melder-uid wird vorerst nicht gespeichert (immer
// 0); nur der optionale Grund landet in der DB. Die Route ist per IP
// rate-limited (siehe main.go), damit der offene Endpunkt kein Spam-Kanal wird.
func handleReport(w http.ResponseWriter, r *http.Request) error {
pid, err := pidParam(r)
if err != nil {
return err
}
// Nur existierende Beiträge melden, sonst entstehen Waisen-Meldungen.
if _, err := entryByPID(pid); err != nil {
return errEntryNotFound
}
reason := truncateRunes(strings.TrimSpace(r.FormValue("reason")), 500)
if _, err := db.Exec(
`INSERT INTO report (pid, uid, reason, created_at, handled) VALUES (?, 0, ?, ?, 0)`,
pid, reason, time.Now().Unix(),
); err != nil {
return Internal(err)
}
// Ohne JavaScript auf eine eigene Bestätigungsseite statt zurück in den
// Feed: bei einer Meldung soll erkennbar sein, dass sie angekommen ist.
// Ein stiller Rücksprung wäre von "nichts passiert" nicht zu unterscheiden.
// Der Weg zurück ist ein Link, kein automatischer Redirect -- der Nutzer
// entscheidet, wann er weiterliest.
if !wantsJSON(r) {
q := url.Values{"return_to": {safeReturn(r.FormValue("return_to"), "/feed")}}
if reason != "" {
q.Set("reason", reason)
}
http.Redirect(w, r, fmt.Sprintf("/entry/%d/gemeldet?%s", pid, q.Encode()), http.StatusSeeOther)
return nil
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
return nil
}
// reportedView füttert die Bestätigungsseite.
type reportedView struct {
PID int64
Reason string
BackURL string
}
// showReported bestätigt eine eingegangene Meldung. Eigene Seite mit eigener
// URL, damit ein Reload nichts erneut meldet (Post/Redirect/Get).
func showReported(w http.ResponseWriter, r *http.Request) error {
pid, err := pidParam(r)
if err != nil {
return err
}
render(w, r, "reported", pageData{
Title: "Meldung eingegangen",
Form: reportedView{
PID: pid,
Reason: truncateRunes(r.URL.Query().Get("reason"), 500),
BackURL: safeReturn(r.URL.Query().Get("return_to"), "/feed"),
},
})
return nil
} }
// handleVote gibt die Stimme des angemeldeten Nutzers ab oder schaltet sie um: // handleVote gibt die Stimme des angemeldeten Nutzers ab oder schaltet sie um:
@@ -733,8 +600,8 @@ func handleVote(w http.ResponseWriter, r *http.Request) error {
uid := uidFromContext(r.Context()) uid := uidFromContext(r.Context())
mode := r.FormValue("mode") mode := r.FormValue("mode")
if mode != "left" && mode != "right" { if mode != "yes" && mode != "no" {
return Invalid("vote.bad_mode", "Ungültiger Modus (left oder right)").At("mode") return Invalid("vote.bad_mode", "Ungültiger Modus (yes oder no)").At("mode")
} }
// Nur auf existierende Beiträge abstimmen, sonst entstehen Waisen-Votes. // Nur auf existierende Beiträge abstimmen, sonst entstehen Waisen-Votes.
@@ -746,13 +613,13 @@ func handleVote(w http.ResponseWriter, r *http.Request) error {
// (parallele Requests konnten den Toggle sonst doppelt anwenden): // (parallele Requests konnten den Toggle sonst doppelt anwenden):
// 1. Gleiche Stimme löschen -> hat das getroffen, war es ein Toggle-off. // 1. Gleiche Stimme löschen -> hat das getroffen, war es ein Toggle-off.
// 2. Sonst Upsert: neue Stimme anlegen bzw. bestehende ummodeln. // 2. Sonst Upsert: neue Stimme anlegen bzw. bestehende ummodeln.
res, err := db.Exec(`DELETE FROM vote WHERE uid = ? AND pid = ? AND mode = ?`, uid, pid, mode) res, err := db.Exec(`DELETE FROM vote WHERE uid = $1 AND pid = $2 AND mode = $3`, uid, pid, mode)
if err != nil { if err != nil {
return Internal(err) return Internal(err)
} }
if n, _ := res.RowsAffected(); n == 0 { if n, _ := res.RowsAffected(); n == 0 {
if _, err := db.Exec( if _, err := db.Exec(
`INSERT INTO vote (uid, pid, mode) VALUES (?, ?, ?) `INSERT INTO vote (uid, pid, mode) VALUES ($1, $2, $3)
ON CONFLICT (uid, pid) DO UPDATE SET mode = excluded.mode`, ON CONFLICT (uid, pid) DO UPDATE SET mode = excluded.mode`,
uid, pid, mode, uid, pid, mode,
); err != nil { ); err != nil {
@@ -760,14 +627,7 @@ func handleVote(w http.ResponseWriter, r *http.Request) error {
} }
} }
// Ohne JavaScript zurück an den Beitrag, auf dem abgestimmt wurde -- der return writeTally(w, pid, uid)
// Anker in return_to hält die Position im Feed.
if !wantsJSON(r) {
redirectBack(w, r, "/feed")
return nil
}
writeTally(w, pid, uid)
return nil
} }
const ( const (
@@ -784,8 +644,7 @@ const (
// errUploadTooLargeOrBroken unterscheidet die beiden Gründe, an denen // errUploadTooLargeOrBroken unterscheidet die beiden Gründe, an denen
// ParseMultipartForm scheitern kann: das Limit von MaxBytesReader (der Upload // ParseMultipartForm scheitern kann: das Limit von MaxBytesReader (der Upload
// war zu groß -> 413) oder kaputtes Multipart (-> 400). Vorher endete beides // war zu groß -> 413) oder kaputtes Multipart (-> 400).
// als 400, was dem Nutzer nicht sagte, dass die Datei schlicht zu groß war.
func errUploadTooLargeOrBroken(err error) error { func errUploadTooLargeOrBroken(err error) error {
var tooLarge *http.MaxBytesError var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) { if errors.As(err, &tooLarge) {
@@ -877,7 +736,7 @@ func storeImage(fh *multipart.FileHeader) (string, error) {
if format == "gif" { if format == "gif" {
ext = "gif" ext = "gif"
} }
name := fmt.Sprintf("static/media/%d-%d.%s", time.Now().Unix(), rand.IntN(999999), ext) name := newMediaName(ext)
dst := image.NewRGBA(image.Rect(0, 0, nw, nh)) dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
xdraw.CatmullRom.Scale(dst, dst.Bounds(), img, b, xdraw.Over, nil) xdraw.CatmullRom.Scale(dst, dst.Bounds(), img, b, xdraw.Over, nil)
@@ -904,7 +763,7 @@ func storeImage(fh *multipart.FileHeader) (string, error) {
// serverseitig erzeugten Namen nach static/media. Der hochgeladene Dateiname // serverseitig erzeugten Namen nach static/media. Der hochgeladene Dateiname
// fließt nie ein (kein Path-Traversal). // fließt nie ein (kein Path-Traversal).
func copyUpload(src io.Reader, ext string) (string, error) { func copyUpload(src io.Reader, ext string) (string, error) {
name := fmt.Sprintf("static/media/%d-%d.%s", time.Now().Unix(), rand.IntN(999999), ext) name := newMediaName(ext)
out, err := os.Create(name) out, err := os.Create(name)
if err != nil { if err != nil {
return "", err return "", err
@@ -923,3 +782,8 @@ func copyUpload(src io.Reader, ext string) (string, error) {
} }
return name, nil return name, nil
} }
// newMediaName erzeugt einen serverseitigen Dateinamen unter static/media.
func newMediaName(ext string) string {
return fmt.Sprintf("static/media/%d-%d.%s", time.Now().Unix(), rand.IntN(999999), ext)
}
+14 -20
View File
@@ -6,14 +6,12 @@ import (
"net/http" "net/http"
) )
// Fehler-Taxonomie: trennt die drei Informationen, die früher an jeder // Fehler-Taxonomie: trennt, wie schwer ein Fehler ist (Kind), was genau
// Fehler-Aufrufstelle verklebt waren -- wie schwer der Fehler ist (Kind), // passiert ist (Code) und was der Nutzer lesen soll (Msg).
// was genau passiert ist (Code) und was der Nutzer lesen soll (Msg).
// //
// Der Sinn der Trennung: derselbe Fehler muss künftig von zwei Adaptern // Der Sinn der Trennung: das Frontend zeigt Msg an und markiert Field, ein
// unterschiedlich dargestellt werden. Der HTML-Pfad braucht Msg und Field, um // Client braucht Code, um ohne Parsen deutscher Strings reagieren zu können,
// ein Formular mit Fehlermarkierung neu zu rendern; ein API-Client braucht // und der HTTP-Status ergibt sich allein aus Kind.
// Code, um ohne Parsen deutscher Strings reagieren zu können.
type Kind uint8 type Kind uint8
@@ -26,7 +24,7 @@ const (
KindConflict // Name vergeben, Doppel-Post KindConflict // Name vergeben, Doppel-Post
KindTooLarge // Upload über dem Limit KindTooLarge // Upload über dem Limit
KindRateLimited // zu viele Anfragen KindRateLimited // zu viele Anfragen
KindUnavailable // Feature nicht konfiguriert KindMethodNotAllowed // Route existiert, aber nicht mit dieser Methode
) )
// Error ist der Fehlertyp, den die Service-Schicht nach oben gibt. Kind ist // Error ist der Fehlertyp, den die Service-Schicht nach oben gibt. Kind ist
@@ -56,7 +54,9 @@ func NotFound(code, msg string) *Error { return &Error{Kind: KindNotFound, Co
func Conflict(code, msg string) *Error { return &Error{Kind: KindConflict, Code: code, Msg: msg} } func Conflict(code, msg string) *Error { return &Error{Kind: KindConflict, Code: code, Msg: msg} }
func TooLarge(code, msg string) *Error { return &Error{Kind: KindTooLarge, Code: code, Msg: msg} } func TooLarge(code, msg string) *Error { return &Error{Kind: KindTooLarge, Code: code, Msg: msg} }
func RateLimited(code, msg string) *Error { return &Error{Kind: KindRateLimited, Code: code, Msg: msg} } func RateLimited(code, msg string) *Error { return &Error{Kind: KindRateLimited, Code: code, Msg: msg} }
func Unavailable(code, msg string) *Error { return &Error{Kind: KindUnavailable, Code: code, Msg: msg} } func MethodNotAllowed(code, msg string) *Error {
return &Error{Kind: KindMethodNotAllowed, Code: code, Msg: msg}
}
// Internal verschluckt die Ursache absichtlich: der Nutzer sieht nie einen // Internal verschluckt die Ursache absichtlich: der Nutzer sieht nie einen
// DB- oder Dateisystemfehler (der Interna und Pfade verraten würde), das Log // DB- oder Dateisystemfehler (der Interna und Pfade verraten würde), das Log
@@ -91,9 +91,7 @@ func (e *Error) WithCause(cause error) *Error {
return e return e
} }
// asError normalisiert alles, was kein *Error ist, zu einem Internal. Damit // asError normalisiert alles, was kein *Error ist, zu einem Internal.
// müssen die Adapter keinen Sonderfall für "nackte" Fehler kennen, die aus
// Bibliotheken oder noch nicht umgestellten Stellen kommen.
func asError(err error) *Error { func asError(err error) *Error {
var e *Error var e *Error
if errors.As(err, &e) { if errors.As(err, &e) {
@@ -102,9 +100,7 @@ func asError(err error) *Error {
return Internal(err) return Internal(err)
} }
// statusOf bildet Kind auf HTTP ab. Beide HTTP-Adapter (HTML und JSON) teilen // statusOf bildet Kind auf HTTP ab -- die einzige Stelle, an der das passiert.
// sich diese Zuordnung -- unterschiedliche Statuscodes für denselben Fehler
// wären genau die Divergenz, die die Taxonomie verhindern soll.
func statusOf(k Kind) int { func statusOf(k Kind) int {
switch k { switch k {
case KindInvalid: case KindInvalid:
@@ -121,17 +117,15 @@ func statusOf(k Kind) int {
return http.StatusRequestEntityTooLarge return http.StatusRequestEntityTooLarge
case KindRateLimited: case KindRateLimited:
return http.StatusTooManyRequests return http.StatusTooManyRequests
case KindUnavailable: case KindMethodNotAllowed:
return http.StatusServiceUnavailable return http.StatusMethodNotAllowed
default: default:
return http.StatusInternalServerError return http.StatusInternalServerError
} }
} }
// logInternal protokolliert nur echte Serverfehler -- Validierungs- und // logInternal protokolliert nur echte Serverfehler -- Validierungs- und
// Rechtefehler sind normaler Betrieb und würden das Log zumüllen. Beide // Rechtefehler sind normaler Betrieb und würden das Log zumüllen.
// Adapter rufen das auf, damit ein Fehler unabhängig vom Ausgabeformat
// genau einmal im Log landet.
func logInternal(e *Error) { func logInternal(e *Error) {
if e.Kind != KindInternal { if e.Kind != KindInternal {
return return
-499
View File
@@ -1,499 +0,0 @@
package main
import (
"fmt"
"html/template"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
)
// Serverseitig gerenderter Feed: das Gegenstück zu createFeed/renderEntry in
// web/js/feed.js. Beide holen dieselben Daten über feedPage; nur das Zeichnen
// passiert einmal in Go und einmal in JavaScript.
//
// Der Unterschied im Blättern ist Absicht: mit JavaScript hängt der Feed die
// nächste Seite unten an (Infinite Scroll), ohne JavaScript wechselt man auf
// eine eigene Seiten-URL. Nur so bleibt ein Anker (#e123) nach einer Aktion
// gültig -- bei akkumulierendem Nachladen wäre der Beitrag nach der Rückkehr
// gar nicht geladen.
// reContentURL entspricht dem Muster in feed.js (linkify): http/https bis zum
// nächsten Leerzeichen oder '<'. Das Schema ist Teil des Musters, deshalb kann
// hier kein javascript:-Link entstehen.
var reContentURL = regexp.MustCompile(`https?://[^\s<]+`)
// renderContent macht aus dem rohen Beitragstext sicheres HTML: alles wird
// escaped, danach werden nur die selbst erzeugten Links und <br> eingefügt.
// Reihenfolge ist wesentlich -- erst escapen, dann Markup ergänzen. Umgekehrt
// würde das Escapen die eigenen Tags wieder zerlegen.
func renderContent(s string) template.HTML {
var b strings.Builder
esc := func(part string) {
b.WriteString(strings.ReplaceAll(template.HTMLEscapeString(part), "\n", "<br>"))
}
last := 0
for _, m := range reContentURL.FindAllStringIndex(s, -1) {
esc(s[last:m[0]])
link := template.HTMLEscapeString(s[m[0]:m[1]])
b.WriteString(`<a href="` + link + `" target="_blank" rel="noopener">` + link + `</a>`)
last = m[1]
}
esc(s[last:])
return template.HTML(b.String())
}
// avatarURL spiegelt avatarUrl aus feed.js.
func avatarURL(path string) string {
if path == "" {
return "/static/assets/no_profile_pic.jpg"
}
return "/" + path
}
// entryView ist ein Beitrag mit allem, was die Vorlage zum Zeichnen braucht.
// Der Abstimmungsstand hängt am Betrachter, ReturnTo am Seitenaufruf --
// beides kann die Entry-Struktur aus der Datenbank nicht liefern.
type entryView struct {
Entry
tallyState
// ReturnTo ist auf einer Seite überall gleich; am Beitrag statt an der
// Seite, damit der entry-Block ohne Zugriff auf den Seitenkontext
// auskommt und auch einzeln (Thread-Ansicht) verwendbar bleibt.
ReturnTo string
LoggedIn bool
// EditURL ist gesetzt, wenn der Betrachter den Beitrag bearbeiten darf.
// Die Adresse trägt den Rückweg schon mit, damit das Speichern dorthin
// zurückführt, wo der Bearbeiten-Link angeklickt wurde -- und nicht auf
// die Bearbeitungsseite selbst.
EditURL string
// DeleteURL führt auf die Bestätigungsseite, nicht direkt aufs Löschen:
// ein Link darf nichts Unwiderrufliches auslösen (Vorschauen und Crawler
// folgen GET-Links ungefragt).
DeleteURL string
}
// composeForm hält die Eingaben des Beitragsformulars über einen
// fehlgeschlagenen Versuch hinweg. Die angehängte Datei fehlt dabei
// zwangsläufig: ein <input type="file"> lässt sich aus Sicherheitsgründen
// nicht vorbelegen, deshalb HadFile als Hinweis für den Nutzer.
type composeForm struct {
Content string
HadFile bool
}
// feedView ist das Datenmodell der Feed-Seite.
type feedView struct {
Entries []entryView
Page int // 0-basiert wie feedPage
Pages int // Gesamtzahl, mindestens 1
PrevURL string
NextURL string
// CanPost steuert, ob das Beitragsformular erscheint. Ohne Anmeldung gibt
// es nichts zu schreiben; über TOR wird das Schreiben abgelehnt (blockTOR),
// und ein Formular, das beim Absenden immer scheitert, wäre schlechter als
// ein ehrlicher Hinweis.
CanPost bool
IsTOR bool
Compose composeForm
}
// HumanPage ist die für Menschen gezählte Seitenzahl (ab 1).
func (v feedView) HumanPage() int { return v.Page + 1 }
// pageURL baut die URL einer Feed-Seite aus der aktuellen Anfrage. Über
// r.URL.Query() statt aus Einzelteilen, damit spätere Filter (Suche,
// Nutzer-Feed) automatisch erhalten bleiben.
func pageURL(r *http.Request, page int) string {
q := r.URL.Query()
if page <= 0 {
q.Del("page")
} else {
q.Set("page", strconv.Itoa(page))
}
if len(q) == 0 {
return r.URL.Path
}
return r.URL.Path + "?" + q.Encode()
}
// buildFeedView holt Beiträge und Gesamtzahl und baut die Blätter-Links.
func buildFeedView(r *http.Request, uid int64, rootsOnly bool) (feedView, error) {
// Bei GET steht die Seite in der Query. Wird der Feed nach einem
// fehlgeschlagenen POST neu gerendert, kommt sie aus dem Formular --
// sonst landet der Nutzer mit seinem Text unvermittelt auf Seite 1.
pageParam := r.URL.Query().Get("page")
if pageParam == "" {
pageParam = r.FormValue("page")
}
page, _ := strconv.Atoi(pageParam)
if page < 0 {
page = 0
}
if page > maxFeedPage {
page = maxFeedPage
}
total, err := feedTotal(uid, rootsOnly)
if err != nil {
return feedView{}, Internal(err)
}
pages := (total + feedPageSize - 1) / feedPageSize
if pages < 1 {
pages = 1
}
if pages > maxFeedPage+1 {
pages = maxFeedPage + 1
}
// Hinter der letzten Seite gäbe es nur eine leere Liste ohne Weg zurück.
if page > pages-1 {
page = pages - 1
}
entries, err := feedPage(page, uid, rootsOnly)
if err != nil {
return feedView{}, Internal(err)
}
// Der Betrachter ist nicht der gefilterte Nutzer: uid oben schränkt ein,
// wessen Beiträge gezeigt werden -- viewer ist, wer zusieht.
var viewer int64
if s, ok := getSession(r); ok {
viewer = s.UID
}
pids := make([]int64, 0, len(entries))
for _, e := range entries {
pids = append(pids, e.PID)
}
tallies, err := voteTallies(pids, viewer)
if err != nil {
return feedView{}, Internal(err)
}
v := feedView{
Entries: toEntryViews(entries, tallies, returnTo(r), viewer),
Page: page,
Pages: pages,
IsTOR: isTOR(r),
}
v.CanPost = viewer != 0 && !v.IsTOR
if page > 0 {
v.PrevURL = pageURL(r, page-1)
}
if page < pages-1 {
v.NextURL = pageURL(r, page+1)
}
return v, nil
}
// threadView ist das Datenmodell der Thread-Seite.
type threadView struct {
Ancestors []entryView
Entry entryView
Replies []entryView
CanPost bool
IsTOR bool
Compose composeForm
}
// toEntryViews reichert Beiträge mit Abstimmungsstand, Rücksprungziel und
// Anmeldestatus an -- alles, was die Vorlage braucht und die Datenbank nicht
// liefert. Feed und Thread teilen sich den Schritt.
func toEntryViews(entries []Entry, tallies map[int64]tallyState, back string, viewer int64) []entryView {
views := make([]entryView, 0, len(entries))
for _, e := range entries {
v := entryView{
Entry: e,
tallyState: tallies[e.PID],
ReturnTo: back,
LoggedIn: viewer != 0,
}
// Gelöschte Beiträge haben uid 0 -- ohne die Deleted-Prüfung wäre der
// Platzhalter für einen nicht angemeldeten Betrachter "eigener"
// Beitrag. viewer != 0 fängt das zwar schon ab, die Prüfung bleibt
// trotzdem stehen: an einem [deleted] gibt es nichts zu bearbeiten.
if viewer != 0 && e.UID == viewer && e.Deleted == 0 {
v.EditURL = ownActionURL("bearbeiten", e.PID, back)
v.DeleteURL = ownActionURL("loeschen", e.PID, back)
}
views = append(views, v)
}
return views
}
// ownActionURL verlinkt eine Seite für den eigenen Beitrag (bearbeiten,
// loeschen) und hängt den Rückweg als Query an. url.Values kodiert dabei auch
// das '#' des Ankers korrekt -- von Hand zusammengesetzt würde der Anker die
// Query zerschneiden.
func ownActionURL(action string, pid int64, back string) string {
q := url.Values{"return_to": {fmt.Sprintf("%s#e%d", back, pid)}}
return fmt.Sprintf("/beitrag/%d/%s?%s", pid, action, q.Encode())
}
// buildThreadView holt den Thread und bereitet ihn zum Zeichnen auf.
func buildThreadView(r *http.Request, pid int64) (threadView, error) {
t, err := threadData(pid)
if err != nil {
return threadView{}, err
}
var viewer int64
if s, ok := getSession(r); ok {
viewer = s.UID
}
// Alle Beiträge der Seite in einem Rutsch -- Ahnen, der Beitrag selbst und
// die Antworten.
pids := make([]int64, 0, len(t.Ancestors)+1+len(t.Replies))
pids = append(pids, t.Entry.PID)
for _, e := range t.Ancestors {
pids = append(pids, e.PID)
}
for _, e := range t.Replies {
pids = append(pids, e.PID)
}
tallies, err := voteTallies(pids, viewer)
if err != nil {
return threadView{}, Internal(err)
}
back := returnTo(r)
v := threadView{
Ancestors: toEntryViews(t.Ancestors, tallies, back, viewer),
Entry: toEntryViews([]Entry{t.Entry}, tallies, back, viewer)[0],
Replies: toEntryViews(t.Replies, tallies, back, viewer),
IsTOR: isTOR(r),
}
// Auf einen gelöschten Beitrag lässt sich nicht antworten -- er hat keinen
// Autor mehr und der Platzhalter soll den Thread nur zusammenhalten.
v.CanPost = viewer != 0 && !v.IsTOR && t.Entry.Deleted == 0
return v, nil
}
// showThread rendert einen Beitrag mit Ahnenkette, Antworten und Antwortformular.
func showThread(w http.ResponseWriter, r *http.Request) error {
pid, err := pidParam(r)
if err != nil {
return err
}
v, err := buildThreadView(r, pid)
if err != nil {
return err
}
render(w, r, "thread", pageData{Title: "Beitrag", Form: v})
return nil
}
// editView füttert die Bearbeitungsseite. Anders als beim Erstellen ist der
// Ausgangszustand nicht leer, sondern der bisherige Text.
type editView struct {
PID int64
Content string
BackURL string
}
// ownEntry lädt einen Beitrag und prüft, ob er dem Anfragenden gehört --
// Voraussetzung fürs Bearbeiten wie fürs Löschen. Bewusst dieselbe Prüfkette
// wie in den schreibenden Handlern: eine Seite anzuzeigen, die beim Absenden
// abgelehnt wird, wäre irreführend.
func ownEntry(r *http.Request, pid int64) (Entry, error) {
e, err := entryByPID(pid)
if err != nil {
return Entry{}, errEntryNotFound
}
// Soft-gelöschte Beiträge stehen auf uid 0 und scheitern damit an derselben
// Besitzprüfung -- sie gehören niemandem mehr. Kein eigener Zweig dafür:
// so bleibt es bei den 403, die der Endpunkt schon immer geliefert hat.
if e.UID != uidFromContext(r.Context()) {
return Entry{}, errEntryNotOwned
}
return e, nil
}
// showEditEntry zeigt das Bearbeitungsformular mit dem bisherigen Text.
func showEditEntry(w http.ResponseWriter, r *http.Request) error {
pid, err := pidParam(r)
if err != nil {
return err
}
e, err := ownEntry(r, pid)
if err != nil {
return err
}
render(w, r, "edit", pageData{
Title: "Beitrag bearbeiten",
Form: editView{
PID: pid,
Content: e.Content,
BackURL: safeReturn(r.URL.Query().Get("return_to"), threadAnchor(pid)),
},
})
return nil
}
// failEdit zeichnet das Bearbeitungsformular nach einem Fehler neu -- mit der
// abgelehnten Fassung, nicht mit dem gespeicherten Stand. Sonst verlöre der
// Nutzer genau die Änderung, die er gerade vorgenommen hat.
func failEdit(w http.ResponseWriter, r *http.Request, pid int64, content string, err error) error {
e := asError(err)
if e.Kind == KindInternal {
renderError(w, e)
return nil
}
failForm(w, r, "edit", pageData{
Title: "Beitrag bearbeiten",
Form: editView{
PID: pid,
Content: content,
BackURL: safeReturn(r.FormValue("return_to"), threadAnchor(pid)),
},
}, e)
return nil
}
// threadAnchor ist der Standard-Rückweg zu einem Beitrag.
func threadAnchor(pid int64) string {
return fmt.Sprintf("/beitrag/%d#e%d", pid, pid)
}
// deleteView füttert die Löschbestätigung. Sie zeigt den Beitrag noch einmal,
// damit erkennbar ist, was verschwindet.
type deleteView struct {
PID int64
Content string
HasImage bool
IsRoot bool
Replies int64
BackURL string
}
// showDeleteEntry fragt vor dem Löschen nach. Eigene Seite statt eines Links,
// der direkt löscht: ein GET darf nichts Unwiderrufliches tun, und ohne
// JavaScript gibt es kein confirm().
func showDeleteEntry(w http.ResponseWriter, r *http.Request) error {
pid, err := pidParam(r)
if err != nil {
return err
}
e, err := ownEntry(r, pid)
if err != nil {
return err
}
render(w, r, "delete", pageData{
Title: "Beitrag löschen",
Form: deleteView{
PID: pid,
Content: e.Content,
HasImage: e.Filepath != "",
IsRoot: e.ReplyTo == 0,
Replies: e.ReplyCount,
BackURL: safeReturn(r.URL.Query().Get("return_to"), threadAnchor(pid)),
},
})
return nil
}
// failCompose zeichnet den Feed nach einem abgelehnten Beitrag neu -- mit
// Fehlermeldung und dem bereits getippten Text. Ohne JavaScript gibt es keinen
// Weg, nur einen Teil der Seite auszutauschen; der Text darf dabei trotzdem
// nicht verloren gehen.
// Antworten werden im Thread neu gezeichnet, neue Beiträge im Feed -- der
// Nutzer soll dort landen, wo er geschrieben hat.
func failCompose(w http.ResponseWriter, r *http.Request, replyTo int64, form composeForm, err error) error {
e := asError(err)
if e.Kind == KindInternal {
renderError(w, e)
return nil
}
if replyTo != 0 {
v, verr := buildThreadView(r, replyTo)
if verr != nil {
// Gibt es den Elternbeitrag nicht (mehr), lässt sich der Thread
// nicht zeichnen -- dann bleibt nur die Fehlerseite.
return verr
}
v.Compose = form
failForm(w, r, "thread", pageData{Title: "Beitrag", Form: v}, e)
return nil
}
v, verr := buildFeedView(r, 0, true)
if verr != nil {
// Wenn schon der Feed nicht mehr geht, ist der Beitragsfehler das
// kleinere Problem -- dann die Fehlerseite zeigen.
return verr
}
v.Compose = form
failForm(w, r, "feed", pageData{Title: "Feed", Form: v}, e)
return nil
}
// showFeed rendert den Hauptfeed (nur Wurzelbeiträge, nach Aktivität sortiert).
func showFeed(w http.ResponseWriter, r *http.Request) error {
v, err := buildFeedView(r, 0, true)
if err != nil {
return err
}
render(w, r, "feed", pageData{Title: "Feed", Form: v})
return nil
}
// showUserFeed rendert den Feed eines einzelnen Nutzers (inkl. Antworten).
func showUserFeed(w http.ResponseWriter, r *http.Request) error {
username := chi.URLParam(r, "username")
var uid int64
if err := db.QueryRow(`SELECT uid FROM user WHERE username = ?`, username).Scan(&uid); err != nil {
return errUserNotFound
}
v, err := buildFeedView(r, uid, false)
if err != nil {
return err
}
render(w, r, "userfeed", pageData{
Title: username,
Form: userFeedView{feedView: v, Username: username},
})
return nil
}
// userFeedView ergänzt den Feed um den Nutzer, dessen Beiträge gezeigt werden.
type userFeedView struct {
feedView
Username string
}
// profileURL escaped den Namen für den Pfad. chi liefert ihn roh; ohne
// Kodierung würden Namen mit '.' oder '-' zwar funktionieren, ein '/' oder '?'
// im Namen aber die URL zerlegen.
func profileURL(username string) string {
return "/u/" + url.PathEscape(username)
}
// fmtTime formatiert einen Unix-Zeitstempel für die Anzeige. Ohne JavaScript
// gibt es keine Client-Zeitzone, deshalb UTC mit ausgeschriebenem Kürzel --
// lieber eindeutig als scheinbar lokal.
func fmtTime(unix int64) string {
return time.Unix(unix, 0).UTC().Format("02.01.2006, 15:04 UTC")
}
// fmtTimeAttr liefert dieselbe Zeit maschinenlesbar für <time datetime>.
// Damit kann eine spätere JS-Schicht sie in Ortszeit umschreiben, ohne dass
// die Seite ohne JavaScript etwas verliert.
func fmtTimeAttr(unix int64) string {
return time.Unix(unix, 0).UTC().Format(time.RFC3339)
}
+45
View File
@@ -0,0 +1,45 @@
package main
import (
"net/http"
"path"
"strings"
"github.com/go-chi/chi/v5"
)
// 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")
}
// serveDoc liefert einen Rechtstext. Sie werden mit dem Frontend gebaut
// (frontend/static/docs), damit sie dasselbe app.css bekommen.
func serveDoc(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-cache")
http.ServeFile(w, r, webDir+"/static/docs/"+path.Base(chi.URLParam(r, "page")))
}
// 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"
}
}
+67
View File
@@ -0,0 +1,67 @@
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: die Startseite hat Tagline und
// Headerbar, alle anderen Seiten den "zurück zum Feed"-Knopf.
export function Layout() {
const path = useLocation().pathname;
const isHome = path === "/";
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>
{/* Die Statistik-Seite darf auf breiten Bildschirmen breiter werden. */}
<main className={path === "/stats" ? "wide" : undefined}>
<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;
}
+110 -114
View File
@@ -87,9 +87,7 @@ h2 {
color: black; color: black;
} }
/* Dezente Reihe anarchistischer Sigillen unter der Tagline. Einheitliche Höhe, /* Reihe anarchistischer Sigillen im Kopf der Beitragsordnung (Easter Egg).
gedämpft per opacity (die SVGs sind teils Strich-, teils Flächenzeichnungen,
eine gemeinsame Farbe gäbe es nicht -> Transparenz vereinheitlicht den Look).
aria-hidden im Markup: reine Dekoration, kein Vorlesen. */ aria-hidden im Markup: reine Dekoration, kein Vorlesen. */
#sigils { #sigils {
display: flex; display: flex;
@@ -119,6 +117,10 @@ main {
margin: auto; margin: auto;
} }
main.wide {
max-width: 1200px;
}
main > section, main > section,
main > div, main > div,
main > h2, main > h2,
@@ -206,6 +208,55 @@ a.button:hover, button:hover {
max-width: 100%; max-width: 100%;
} }
/* Linkvorschau: abgesetzte Karte unter dem Text, kleines Vorschaubild links,
Titel und Seiten- bzw. Kanalname daneben. Rahmen wie bei den Buttons,
weiß auch beim Card-Hover. */
.link-preview {
display: flex;
align-items: center;
border: 1px solid black;
background-color: white;
margin: 0 0 0.5rem;
text-decoration: none;
color: black;
}
.link-preview:hover .link-preview-title {
text-decoration: underline;
}
.card .link-preview img {
flex: 0 0 auto;
align-self: stretch;
width: 160px;
max-width: 40%;
aspect-ratio: 16 / 9;
object-fit: cover;
margin: 0;
border-right: 1px solid black;
}
.link-preview-text {
min-width: 0;
padding: 0.3rem 0.5rem;
}
.link-preview-title,
.link-preview-site {
display: block;
overflow-wrap: break-word;
}
.link-preview-title {
font-size: 1.1rem;
line-height: 1.3;
}
.link-preview-site {
font-size: 0.9rem;
color: gray;
}
/* --- Avatare --- */ /* --- Avatare --- */
img.avatar { img.avatar {
display: inline-block; display: inline-block;
@@ -237,6 +288,9 @@ img.avatar-lg { width: 72px; height: 72px; }
auf 1rem liegt). Hier explizit angleichen -> ganze Meta-Zeile gleich groß. */ auf 1rem liegt). Hier explizit angleichen -> ganze Meta-Zeile gleich groß. */
.byline span { font-size: 1rem; } .byline span { font-size: 1rem; }
/* Melden steht rechts in der Byline. */
.byline .report { margin-left: auto; }
/* Profilkopf: großer Avatar links neben Name und "Mitglied seit". */ /* Profilkopf: großer Avatar links neben Name und "Mitglied seit". */
.profile-head { .profile-head {
display: flex; display: flex;
@@ -258,9 +312,13 @@ img.avatar-lg { width: 72px; height: 72px; }
Die Strich-Spalte ist halb so breit wie der Avatar, ihr rechter Rand (= der Die Strich-Spalte ist halb so breit wie der Avatar, ihr rechter Rand (= der
Strich) liegt damit unter der Mitte des Byline-Avatars. Der Abstand zum Strich) liegt damit unter der Mitte des Byline-Avatars. Der Abstand zum
Inhalt ist ebenfalls die halbe Avatarbreite -> Inhalt beginnt unter dem Inhalt ist ebenfalls die halbe Avatarbreite -> Inhalt beginnt unter dem
rechten Avatarrand. Der .body füllt per flex:1 den Rest. */ rechten Avatarrand. Der .body füllt per flex:1 den Rest.
Dasselbe gilt für die Diskussionskette in Feed und Antworten (Discussion:
Beitrag, ggf. "k weitere Beiträge", jüngste Antwort) an allen Gliedern
außer dem letzten. */
#ancestors .threadline, #ancestors .threadline,
#replies .threadline { #replies .threadline,
.chain > :not(:last-child) .threadline {
display: block; display: block;
/* Box um die Linienbreite schmaler -> die rechts gezeichnete border-right /* Box um die Linienbreite schmaler -> die rechts gezeichnete border-right
landet genau auf der Avatarmitte statt rechts daneben. */ landet genau auf der Avatarmitte statt rechts daneben. */
@@ -268,12 +326,32 @@ img.avatar-lg { width: 72px; height: 72px; }
margin-right: calc(var(--avatar-sm) / 2); margin-right: calc(var(--avatar-sm) / 2);
} }
/* Nur die Vorfahren bilden einen Thread -> Linie. Antworten nur eingerückt. */ /* Strich nur, wo eine Kette ist: Vorfahren und Diskussionsketten. Direkte
#ancestors .threadline { border-right: var(--line-w) solid gray; } Antworten ohne Kette sind nur eingerückt. */
#ancestors .threadline,
.chain > :not(:last-child) .threadline {
border-right: var(--line-w) solid gray;
}
/* "k weitere Beiträge" unterbricht den Strich: der Button steht bündig mit
dem Avatar, der Strich läuft oben auf ihn zu und setzt darunter (::after)
wieder an bis zum nächsten Avatar. .chain-gap a hebt den Button-Margin auf,
damit Strich und Rahmen sich berühren. */
.chain-gap a {
margin: 0;
}
.chain-gap::after {
content: "";
display: block;
width: 0;
height: 0.6rem;
margin-left: calc(var(--avatar-sm) / 2 - var(--line-w));
border-left: var(--line-w) solid gray;
}
/* Interaktionsleiste: Antworten-Button links, Vote-Buttons mittig, Repost /* Interaktionsleiste: Antworten-Button links, Vote-Buttons mittig, Repost
rechts. Antwort- und Repost-Zahl stehen in den Buttons; der Repost-Cooldown rechts. Antwort- und Repost-Zahl stehen in den Buttons; der Repost-Cooldown
wandert in die Byline (siehe setupBump). */ wandert in die Byline (siehe useBump). */
.interactions { .interactions {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -282,8 +360,7 @@ img.avatar-lg { width: 72px; height: 72px; }
margin-top: 0.25rem; margin-top: 0.25rem;
} }
/* Bump-Button: tritt an die Stelle der früheren Bump-Kommentare. Im Cooldown /* Repost-Button: im Cooldown gesperrt (gilt pro Beitrag für alle). */
gesperrt (gilt pro Beitrag für alle), der Button zeigt dann die Restzeit. */
.bump:disabled { .bump:disabled {
color: gray; color: gray;
border-color: #ccc; border-color: #ccc;
@@ -302,59 +379,6 @@ img.avatar-lg { width: 72px; height: 72px; }
.ghost.selected { font-weight: bold; } .ghost.selected { font-weight: bold; }
.msg { color: red; } .msg { color: red; }
/* Melden sitzt rechtsbündig in der Meta-Zeile (weit weg von den Interaktionen).
margin-left:auto schiebt es im Byline-Flex ganz nach rechts; position:relative
verankert das aufklappende Grund-Feld. Der <details>-Zweischritt (erst öffnen,
dann absenden) verhindert versehentliche Meldungen; nativ (details/summary +
echtes Formular), damit es später auch ohne JS funktioniert. */
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;
font-weight: bold;
}
/* --- Formulare --- */ /* --- Formulare --- */
form { form {
background-color: white; background-color: white;
@@ -381,11 +405,6 @@ form input[type="file"] {
outline: none; outline: none;
} }
hr {
border: none;
border-top: 1px solid #ccc;
}
/* --- Statistik-Seite --- */ /* --- Statistik-Seite --- */
.statgrid { .statgrid {
display: flex; display: flex;
@@ -402,57 +421,34 @@ hr {
line-height: 1.1; line-height: 1.1;
} }
/* Horizontale Balken (Aufrufe/Tag, beliebteste Seiten). Breite wird per JS /* Server-SVGs (charts.go): schmal 600, breit 1200 im viewBox-Raum. Ab 900 px
gesetzt (CSSOM, kein Inline-style -> CSP-konform). */ Fensterbreite die breite Fassung, damit die Schrift nicht zu groß wird. */
.bars { .chart {
padding: 0.3rem 0.5rem 0.6rem; padding: 0.3rem 0 0.6rem;
} }
.bar-row { .chart svg {
display: flex; display: block;
align-items: center; width: 100%;
gap: 0.5rem; height: auto;
margin: 0.15rem 0;
} }
.bar-label { .chart-wide {
flex: 0 0 7rem; display: none;
text-align: right;
font-size: 0.95rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.bar-track { /* Senkrechte Markierung des Tages unter dem Zeiger (Liniendiagramme). */
flex: 1; .chart g:hover .mark {
background: #eee; visibility: visible;
} }
.bar-fill { /* Infozeile unter dem Diagramm: Höhe immer reserviert. */
height: 1.1rem; .chart-tip {
background: gray; margin: 0.2rem 0 0;
padding-left: 0.5rem;
min-height: 1.3em;
} }
.bar-value { @media (min-width: 900px) {
flex: 0 0 auto; .chart-narrow {
text-align: right; display: none;
font-size: 0.95rem;
} }
.chart-wide {
/* ASN-Namen sind länger als Datum/Pfad -> mehr Platz, Umbruch statt Ellipse. */ display: block;
#stat-asns .bar-label {
flex-basis: 13rem;
white-space: normal;
} }
/* Blätternavigation des serverseitig gerenderten Feeds. Ohne JavaScript gibt
es kein Nachladen beim Scrollen -- stattdessen Zurück/Weiter am Seitenende.
space-between hält die Seitenanzeige mittig, auch wenn auf der ersten bzw.
letzten Seite einer der beiden Links fehlt. */
.pager {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin: 1.5rem 0;
}
.pager > span {
flex: 1;
text-align: center;
} }
+33
View File
@@ -0,0 +1,33 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ApiError, api } from "./api";
import type { Me } from "./types";
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"] }),
]);
}
+81
View File
@@ -0,0 +1,81 @@
import { useState, type FormEvent, type MouseEvent } from "react";
import { api, errMsg } from "../api";
import { useAuthChanged } from "../auth";
// AuthBox: Login und Registrierung im Wechsel. Die {" "} sind Absicht:
// Überschrift, Inputs und Knöpfe sind inline, der Abstand ist 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" />{" "}
<label>
Angemeldet bleiben für{" "}
<select name="timeout" defaultValue="86400">
<option value="3600">1 Stunde</option>
<option value="86400">1 Tag</option>
<option value="604800">1 Woche</option>
<option value="31536000">1 Jahr</option>
</select>
</label>
<br />
<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).
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>
),
};
}
+112
View File
@@ -0,0 +1,112 @@
import type { MouseEvent } from "react";
import { Link, useNavigate } from "react-router";
import type { Entry, LinkPreview } from "../types";
import { avatarUrl, formatTime, linkify } from "../format";
import { Votes } from "./Votes";
import { useBump } from "./Bump";
import { useReport } from "./Report";
import { useMe } from "../auth";
// Klick auf die Karte öffnet den Beitrag -- außer der Klick galt einem Link,
// Knopf oder Formular darin.
function useOpenOnClick(pid: number) {
const navigate = useNavigate();
return (ev: MouseEvent) => {
if (!(ev.target as Element).closest("a, button, form")) navigate(`/e/${pid}`);
};
}
export function EntryCard({ entry }: { entry: Entry }) {
return entry.deleted ? <DeletedCard entry={entry} /> : <LiveCard entry={entry} />;
}
// Soft-gelöschter Beitrag: nur Platzhalter, damit der Thread navigierbar bleibt.
function DeletedCard({ entry: e }: { entry: Entry }) {
const open = useOpenOnClick(e.pid);
return (
<article className="card" onClick={open}>
<div className="muted byline"><span>[deleted] · {formatTime(e.created_at)}</span></div>
<div className="threadline-wrapper">
<div className="threadline"></div>
<div className="body">
<div className="content">[deleted]</div>
</div>
</div>
</article>
);
}
// LinkPreviewCard zeigt die Vorschau des ersten Links als abgesetzte Karte:
// kleines Bild links, Titel und Seite daneben.
// Alles darin kommt von unserem Server; erst der Klick führt zur fremden Seite.
function LinkPreviewCard({ preview: p }: { preview: LinkPreview }) {
return (
<a className="link-preview" href={p.url} target="_blank" rel="noopener noreferrer">
{p.thumb && <img src={`/${p.thumb}`} alt="" loading="lazy" />}
<span className="link-preview-text">
<span className="link-preview-title">{p.title}</span>
{p.site && <span className="link-preview-site">{p.site}</span>}
</span>
</a>
);
}
function LiveCard({ entry: e }: { entry: Entry }) {
const open = useOpenOnClick(e.pid);
const bump = useBump(e);
const report = useReport(e.pid);
const own = useMe().data?.uid === e.uid;
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> · {formatTime(e.created_at)}
<span className="cooldown">{bump.cooldown}</span>
</span>
{!own && report.button}
</div>
<div className="threadline-wrapper">
<div className="threadline"></div>
<div className="body">
{report.form}
<div className="content">{linkify(e.content)}</div>
{e.preview && <LinkPreviewCard preview={e.preview} />}
{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>
);
}
// Discussion zeigt einen Beitrag mit der Antwort, die seine Diskussion zuletzt
// fortgesetzt hat: [Beitrag] ── "k weitere Beiträge" ── [jüngste Antwort].
// Der Strich verbindet die Glieder wie in der Ahnenkette; "k weitere" steht
// als Unterbrechung auf dem Strich und führt wie die Antwort selbst zu deren
// Seite, wo die ganze Kette darüber steht.
export function Discussion({ entry: e }: { entry: Entry }) {
if (!e.latest) return <EntryCard entry={e} />;
const { entry: latest, skipped } = e.latest;
return (
<div className="chain">
<EntryCard entry={e} />
{skipped > 0 && (
<div className="chain-gap">
<Link className="button" to={`/e/${latest.pid}`}>
{skipped === 1 ? "1 weiterer Beitrag" : `${skipped} weitere Beiträge`}
</Link>
</div>
)}
<EntryCard entry={latest} />
</div>
);
}
+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 { Discussion } 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: <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) => <Discussion 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>
</>
);
}
+50
View File
@@ -0,0 +1,50 @@
import { useState, type FormEvent } from "react";
import { useMutation } from "@tanstack/react-query";
import { api, errMsg } from "../api";
import { pasteImage } from "../paste";
// PostForm verfasst einen Beitrag, mit replyTo eine Antwort darauf.
export function PostForm({ replyTo, rows, placeholder, submitLabel, fallback, onDone }: {
replyTo?: string;
rows: number;
placeholder: string;
submitLabel: string;
fallback: string;
onDone: () => void;
}) {
const [pasteMsg, setPasteMsg] = useState("");
const create = useMutation({
mutationFn: (fd: FormData) => api.post("/entry/create", fd),
});
const onSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
const fd = new FormData(form);
if (replyTo) fd.append("reply_to", replyTo);
create.mutate(fd, {
onSuccess: () => {
form.reset();
setPasteMsg("");
onDone();
},
});
};
return (
<>
<form
onSubmit={onSubmit}
onPaste={(e) => {
if (pasteImage(e)) setPasteMsg("Bild aus Zwischenablage übernommen.");
}}
>
<textarea name="content" rows={rows} maxLength={1000} placeholder={placeholder}></textarea>{" "}
<p className="muted">{pasteMsg}</p>{" "}
<input name="file" type="file" accept="image/*" />{" "}
<button type="submit" disabled={create.isPending}>{submitLabel}</button>
</form>
<p className="msg">{create.isError ? errMsg(create.error, fallback) : ""}</p>
</>
);
}
+38
View File
@@ -0,0 +1,38 @@
import { useState, type FormEvent } from "react";
import { useMutation } from "@tanstack/react-query";
import { api, errMsg } from "../api";
// useReport hält das Melden eines Beitrags: der Knopf steht rechts in der
// Byline, das Formular für den optionalen Grund darunter im Beitrag --
// deshalb ein Hook statt einer Komponente (wie useBump). Melden geht auch
// ohne Anmeldung, der Melder ist dann anonym.
export function useReport(pid: number) {
const [open, setOpen] = useState(false);
const report = useMutation({
mutationFn: (fd: FormData) => api.post(`/entry/${pid}/report`, fd),
onSuccess: () => setOpen(false),
});
const onSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
report.mutate(new FormData(e.currentTarget));
};
const cancel = () => {
report.reset();
setOpen(false);
};
return {
button: report.isSuccess
? <span className="report">gemeldet</span>
: <button className="report" type="button" disabled={open} onClick={() => setOpen(true)}>Melden</button>,
form: open && (
<form onSubmit={onSubmit}>
<textarea name="reason" rows={2} maxLength={500} placeholder="Grund (optional)"></textarea>{" "}
<button type="submit" disabled={report.isPending}>Melden</button>{" "}
<button type="button" onClick={cancel}>Abbrechen</button>{" "}
<p className="msg">{report.isError ? errMsg(report.error, "Melden fehlgeschlagen.") : ""}</p>
</form>
),
};
}
+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.yes} / ${t.no}`;
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("yes", "Ja")}
<span className="muted">{counts}</span>
{button("no", "Nein")}
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
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>. Den Text
// escaped React selbst.
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;
}
+46
View File
@@ -0,0 +1,46 @@
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 { ResetPage } from "./pages/ResetPage";
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 /> },
{ path: "/reset", element: <ResetPage /> },
],
},
]);
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</StrictMode>,
);
+118
View File
@@ -0,0 +1,118 @@
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 { Discussion, EntryCard } from "../components/EntryCard";
import { PostForm } from "../components/PostForm";
import type { 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}>
<PostForm
replyTo={pid}
rows={3}
placeholder="Antworten…"
submitLabel="Antworten"
fallback="Antwort fehlgeschlagen."
onDone={refresh}
/>
</section>
<section id="replies">
{data && !data.replies.length && <p className="muted">Noch keine Antworten.</p>}
{data?.replies.map((e) => <Discussion key={e.pid} entry={e} />)}
</section>
</>
);
}
// OwnerActions: Bearbeiten und Löschen für den eigenen, nicht gelöschten Beitrag.
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>
)}
</>
);
}
+34
View File
@@ -0,0 +1,34 @@
import { useQueryClient } from "@tanstack/react-query";
import { useMe } from "../auth";
import { AuthBox } from "../components/AuthBox";
import { Feed } from "../components/Feed";
import { PostForm } from "../components/PostForm";
// 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}`} />
</>
);
}
// Nach einem neuen Beitrag springt der Feed zurück auf Seite 0, damit er oben
// steht.
function Compose() {
const qc = useQueryClient();
return (
<section id="compose">
<PostForm
rows={4}
placeholder="Was gibt's Neues?"
submitLabel="Eintrag erstellen"
fallback="Beitrag fehlgeschlagen."
onDone={() => qc.resetQueries({ queryKey: ["feed"] })}
/>
</section>
);
}
+65
View File
@@ -0,0 +1,65 @@
import { useState, type FormEvent } from "react";
import { useNavigate } from "react-router";
import { useQuery } from "@tanstack/react-query";
import { api, errMsg } from "../api";
import { useAuthChanged } from "../auth";
// ResetPage: neues Passwort über einen Reset-Link (/reset#<token>, erzeugt per
// kver reset-link). Das Token steht im Fragment, damit es nie in einer URL beim
// Server landet; es geht nur im POST-Body raus.
export function ResetPage() {
const [token] = useState(() => window.location.hash.slice(1));
const [msg, setMsg] = useState("");
const [busy, setBusy] = useState(false);
const navigate = useNavigate();
const authChanged = useAuthChanged();
const check = useQuery({
queryKey: ["reset", token],
queryFn: () => {
const fd = new FormData();
fd.append("token", token);
return api.post<{ username: string }>("/auth/reset/check", fd);
},
enabled: token !== "",
staleTime: Infinity,
});
// Nach Erfolg ist man eingeloggt -> ab in den Feed.
const submit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const fd = new FormData(e.currentTarget);
fd.append("token", token);
setBusy(true);
try {
await api.post("/auth/reset", fd);
await authChanged();
navigate("/");
} catch (err) {
setMsg(errMsg(err, "Passwort konnte nicht gesetzt werden."));
} finally {
setBusy(false);
}
};
let error = "";
if (token === "") error = "Der Link ist unvollständig.";
else if (check.isError) error = errMsg(check.error, "Der Link konnte nicht geprüft werden.");
return (
<section id="reset">
<h2>Passwort neu setzen</h2>
{error && <p className="msg">{error}</p>}
{check.data && (
<form id="reset-form" onSubmit={submit}>
<p>für <strong>{check.data.username}</strong></p>
<input name="username" type="hidden" value={check.data.username} autoComplete="username" />
<input name="pass1" type="password" placeholder="Neues Passwort" autoComplete="new-password" />{" "}
<input name="pass2" type="password" placeholder="Passwort wiederholen" autoComplete="new-password" />{" "}
<button type="submit" disabled={busy}>Passwort setzen</button>
</form>
)}
<p className="msg" id="reset-msg">{msg}</p>
</section>
);
}
+110
View File
@@ -0,0 +1,110 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { api, errMsg } from "../api";
import type { ChartPair, StatsCharts, StatsDetail } from "../types";
// Statistik-Seite: Zahlen und Diagramme, die der Server einmal täglich als SVG
// rendert (charts.go). Die SVGs kommen ohne style-Attribute aus -> 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.toLocaleString("de-DE", { maximumFractionDigits: 1, useGrouping: false })}</span>
<span className="muted">{label}</span>
</div>
);
}
// Chart bettet ein Server-SVG inline ein, damit es die Schrift der Seite erbt.
// Der Inhalt stammt vollständig aus charts.go (nur Zahlen und feste Texte).
// Beide Größen stehen im DOM, das CSS zeigt je nach Fensterbreite eine.
// Die Infozeile darunter ist immer reserviert (kein Springen) und zeigt den
// data-tip des Datenpunkts unter dem Zeiger; Tippen genügt auf Touch-Geräten.
function Chart({ svg }: { svg?: ChartPair }) {
const [tip, setTip] = useState("");
if (!svg) return null;
const pick = (e: React.MouseEvent) => {
const el = (e.target as Element).closest("[data-tip]");
if (el) setTip(el.getAttribute("data-tip") ?? "");
};
return (
<div className="chart" onMouseOver={pick} onClick={pick} onMouseLeave={() => setTip("")}>
<div className="chart-narrow" dangerouslySetInnerHTML={{ __html: svg.narrow }} />
<div className="chart-wide" dangerouslySetInnerHTML={{ __html: svg.wide }} />
<p className="chart-tip">{tip || "\u00a0"}</p>
</div>
);
}
export function StatsPage() {
const stats = useQuery({
queryKey: ["stats"],
queryFn: () => api.get<StatsDetail>("/stats/detail"),
});
const charts = useQuery({
queryKey: ["stats-charts"],
queryFn: () => api.get<StatsCharts>("/stats/charts"),
staleTime: 10 * 60 * 1000,
});
const s = stats.data;
const c = charts.data?.charts;
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.entries_per_day_30} label="Ø Beiträge/Tag (30 Tage)" />
<NumCard value={s.votes.yes} label="Stimmen Ja" />
<NumCard value={s.votes.no} label="Stimmen Nein" />
</>
)}
</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>Aufrufe pro Tag</h3>
<Chart svg={c?.impressions} />
<h3>Verschiedene Netze pro Tag</h3>
<Chart svg={c?.networks} />
<h3>Beiträge pro Tag</h3>
<p className="muted">
Alle Beiträge einschließlich gelöschter, in deutscher Zeit. Wochentag und
Tageszeit zeigen den Durchschnitt pro Tag seit dem ersten Beitrag.
Die Diagramme werden einmal täglich erstellt und enden mit gestern.
</p>
{charts.isError && <p className="msg">{errMsg(charts.error, "Diagramme konnten nicht geladen werden.")}</p>}
<Chart svg={c?.entries} />
<h3>Beiträge pro Wochentag</h3>
<Chart svg={c?.weekday} />
<h3>Beiträge pro Tageszeit</h3>
<Chart svg={c?.hour} />
</section>
);
}
+142
View File
@@ -0,0 +1,142 @@
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={avatarUrl(info?.avatar ?? "") + avatarBust}
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.
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;
}
+104
View File
@@ -0,0 +1,104 @@
// 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;
// Vorschau des ersten Links, sobald der Server sie geholt hat.
preview?: LinkPreview;
// Nur in Feed und Antwortliste: jüngster Beitrag im Teilbaum, skipped =
// Beiträge auf dem Pfad dazwischen (0 = direkte Antwort).
latest?: { entry: Entry; skipped: number };
}
// Vom Server geholte Linkvorschau; thumb liegt lokal (leer = kein Bild).
export interface LinkPreview {
url: string;
kind: "youtube" | "page";
title: string;
site: string;
thumb: string;
}
export interface Thread {
entry: Entry;
ancestors: Entry[];
replies: Entry[];
}
export type VoteMode = "yes" | "no";
export interface VoteTally {
pid: number;
yes: number;
no: number;
selected: VoteMode | "none";
}
export interface BumpResult {
pid: number;
bump_count: number;
last_bump: number;
last_activity: number;
retry_after: 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: { yes: number; no: number };
impressions_total: number;
visitors_total: number;
entries_per_day_30: number;
}
// Fertig gerenderte SVG-Diagramme der Statistik-Seite (einmal täglich,
// charts.go), je schmal (600) und breit (1200).
export interface ChartPair {
narrow: string;
wide: string;
}
export interface StatsCharts {
generated: number;
charts: Record<"impressions" | "networks" | "entries" | "weekday" | "hour", ChartPair>;
}
// 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" />
@@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Beitragsordnung - Kontrollverlust</title> <title>Beitragsordnung - Kontrollverlust</title>
<link rel="stylesheet" href="/css/app.css"> <link rel="stylesheet" href="/src/app.css">
</head> </head>
<body> <body>
<header> <header>
@@ -12,7 +12,7 @@
<a href="/static/docs/impressum.html">Impressum</a> - <a href="/static/docs/impressum.html">Impressum</a> -
<a href="/static/docs/datenschutz.html">Datenschutz</a> - <a href="/static/docs/datenschutz.html">Datenschutz</a> -
<a href="/static/docs/beitragsordnung.html">Beitragsordnung</a> - <a href="/static/docs/beitragsordnung.html">Beitragsordnung</a> -
<a href="/stats.html">Statistik</a> <a href="/stats">Statistik</a>
</nav> </nav>
<a href="/"><h1>Kontrollverlust</h1></a> <a href="/"><h1>Kontrollverlust</h1></a>
<div id="sigils" aria-hidden="true"> <div id="sigils" aria-hidden="true">
@@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Datenschutz - Kontrollverlust</title> <title>Datenschutz - Kontrollverlust</title>
<link rel="stylesheet" href="/css/app.css"> <link rel="stylesheet" href="/src/app.css">
</head> </head>
<body> <body>
<header> <header>
@@ -12,7 +12,7 @@
<a href="/static/docs/impressum.html">Impressum</a> - <a href="/static/docs/impressum.html">Impressum</a> -
<a href="/static/docs/datenschutz.html">Datenschutz</a> - <a href="/static/docs/datenschutz.html">Datenschutz</a> -
<a href="/static/docs/beitragsordnung.html">Beitragsordnung</a> - <a href="/static/docs/beitragsordnung.html">Beitragsordnung</a> -
<a href="/stats.html">Statistik</a> <a href="/stats">Statistik</a>
</nav> </nav>
<a href="/"><h1>Kontrollverlust</h1></a> <a href="/"><h1>Kontrollverlust</h1></a>
</header> </header>
@@ -119,6 +119,7 @@ E-Mail: <a>stress@bnd.wtf</a>
<li>Optional: Hochgeladenes Bild (JPG, PNG, GIF; max. 1024x1024 Pixel)</li> <li>Optional: Hochgeladenes Bild (JPG, PNG, GIF; max. 1024x1024 Pixel)</li>
</ul> </ul>
<em>Sichtbarkeit:</em> Beiträge und Benutzername sind öffentlich einsehbar<br> <em>Sichtbarkeit:</em> Beiträge und Benutzername sind öffentlich einsehbar<br>
<em>Linkvorschau:</em> Enthält ein Beitrag einen Link, ruft unser Server von der verlinkten Seite Titel, Seiten- bzw. Kanalname und Vorschaubild ab (bei YouTube über den öffentlichen oEmbed-Dienst von YouTube) und speichert sie auf unserem Server. Die Vorschau wird Ihnen ausschließlich von unserem Server ausgeliefert; beim Anzeigen werden keine Daten an die verlinkte Seite oder an YouTube übermittelt. Erst wenn Sie die Vorschau oder den Link anklicken, rufen Sie die fremde Seite selbst auf.<br>
<em>Zweck:</em> Bereitstellung der Forumsfunktion<br> <em>Zweck:</em> Bereitstellung der Forumsfunktion<br>
<em>Rechtsgrundlage:</em> Vertragserfüllung (Art. 6 Abs. 1 lit. b DSGVO)<br> <em>Rechtsgrundlage:</em> Vertragserfüllung (Art. 6 Abs. 1 lit. b DSGVO)<br>
<em>Speicherdauer:</em> Bis zur Löschung durch den Nutzer <em>Speicherdauer:</em> Bis zur Löschung durch den Nutzer
@@ -152,7 +153,7 @@ E-Mail: <a>stress@bnd.wtf</a>
</ul> </ul>
<em>Zweck:</em> Erfassung des Seitenwachstums und der Reichweite (Seitenaufrufe)<br> <em>Zweck:</em> Erfassung des Seitenwachstums und der Reichweite (Seitenaufrufe)<br>
<em>Rechtsgrundlage:</em> Berechtigtes Interesse (Art. 6 Abs. 1 lit. f DSGVO)<br> <em>Rechtsgrundlage:</em> Berechtigtes Interesse (Art. 6 Abs. 1 lit. f DSGVO)<br>
<em>Hinweis:</em> Durch die Anonymisierung und Aggregation ist keine Rückverfolgung auf einzelne Personen möglich. <em>Hinweis:</em> Die Daten sind so stark gekürzt und zusammengefasst, dass einzelne Personen daraus in der Regel nicht bestimmt werden können.
</p> </p>
<p><strong>6. Datenlöschung</strong><br><br> <p><strong>6. Datenlöschung</strong><br><br>
@@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Impressum - Kontrollverlust</title> <title>Impressum - Kontrollverlust</title>
<link rel="stylesheet" href="/css/app.css"> <link rel="stylesheet" href="/src/app.css">
</head> </head>
<body> <body>
<header> <header>
@@ -12,7 +12,7 @@
<a href="/static/docs/impressum.html">Impressum</a> - <a href="/static/docs/impressum.html">Impressum</a> -
<a href="/static/docs/datenschutz.html">Datenschutz</a> - <a href="/static/docs/datenschutz.html">Datenschutz</a> -
<a href="/static/docs/beitragsordnung.html">Beitragsordnung</a> - <a href="/static/docs/beitragsordnung.html">Beitragsordnung</a> -
<a href="/stats.html">Statistik</a> <a href="/stats">Statistik</a>
</nav> </nav>
<a href="/"><h1>Kontrollverlust</h1></a> <a href="/"><h1>Kontrollverlust</h1></a>
</header> </header>
+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"]
}
+39
View File
@@ -0,0 +1,39 @@
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.
// Die Rechtstexte sind eigene statische Seiten ohne JS; als weitere Einstiege
// bekommen sie dasselbe gebaute app.css wie die App.
build: {
outDir: "../web",
emptyOutDir: true,
rollupOptions: {
input: [
"index.html",
"static/docs/impressum.html",
"static/docs/datenschutz.html",
"static/docs/beitragsordnung.html",
],
},
},
server: {
proxy: {
"/api": proxy,
"/static": proxy,
},
},
});
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
# Nach jedem Commit den DEV-Container im Hintergrund neu bauen und starten,
# damit der Commit nicht auf den Build wartet. Ausgabe: .git/dev-up.log
# Aussetzen für einen Commit: KVER_NO_DEV_UP=1 git commit ...
[[ -n "${KVER_NO_DEV_UP:-}" ]] && exit 0
root="$(git rev-parse --show-toplevel)"
nohup "$root/dev-up.sh" >"$root/.git/dev-up.log" 2>&1 </dev/null &
echo "dev-up: DEV-Container wird neu gebaut (Log: .git/dev-up.log)"
+10 -12
View File
@@ -1,26 +1,24 @@
module kver module kver
go 1.25.0 go 1.26.0
require ( require (
github.com/go-chi/chi/v5 v5.3.0 github.com/go-chi/chi/v5 v5.3.0
github.com/go-chi/httprate v0.15.0 github.com/go-chi/httprate v0.15.0
github.com/jackc/pgx/v5 v5.11.0
github.com/oschwald/maxminddb-golang v1.13.1 github.com/oschwald/maxminddb-golang v1.13.1
golang.org/x/crypto v0.52.0 golang.org/x/crypto v0.57.0
golang.org/x/image v0.41.0 golang.org/x/image v0.41.0
modernc.org/sqlite v1.51.0 golang.org/x/net v0.59.0
) )
require ( require (
github.com/dustin/go-humanize v1.0.1 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect
golang.org/x/sys v0.45.0 // indirect golang.org/x/sync v0.23.0 // indirect
modernc.org/libc v1.72.3 // indirect golang.org/x/sys v0.48.0 // indirect
modernc.org/mathutil v1.7.1 // indirect golang.org/x/text v0.42.0 // indirect
modernc.org/memory v1.11.0 // indirect
) )
+26 -55
View File
@@ -1,75 +1,46 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g= github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g=
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4= github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE= github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE=
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8= github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo= golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.51.0 h1:aH/MMSoayAIhozZ7uJbVTT9QO/VhzBf0J9tymmmuC/U=
modernc.org/sqlite v1.51.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+6 -12
View File
@@ -3,22 +3,16 @@ package main
import "net/http" import "net/http"
// handlerFunc ist die Handler-Signatur dieser App: wie http.HandlerFunc, aber // handlerFunc ist die Handler-Signatur dieser App: wie http.HandlerFunc, aber
// mit Fehler-Rückgabe. Der Gewinn ist nicht nur Kürze -- die alte Form // mit Fehler-Rückgabe. "return err" beendet den Handler zwingend -- ein
// (writeError(...); return) machte ein vergessenes return zu einem stillen Bug: // vergessenes return nach dem Fehlerschreiben kann es so nicht geben.
// der Handler lief weiter und schrieb eine zweite Antwort in denselben
// ResponseWriter. Mit "return err" kann das strukturell nicht mehr passieren.
type handlerFunc func(http.ResponseWriter, *http.Request) error type handlerFunc func(http.ResponseWriter, *http.Request) error
// api hängt einen fehlerliefernden Handler in chi ein. Der Fehler geht durch // api hängt einen fehlerliefernden Handler in chi ein; der Fehler geht als
// failRequest und wird damit in dem Format ausgegeben, das der Client erwartet // JSON über writeAPIError hinaus.
// -- diese Endpunkte bedienen inzwischen beide Seiten (siehe wantsJSON).
//
// Das Gegenstück ist page(): dort ist die Antwort immer HTML, weil es sich um
// Seiten und nicht um Endpunkte handelt.
func api(h handlerFunc) http.HandlerFunc { func api(h handlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
if err := h(w, r); err != nil { if err := h(w, r); err != nil {
failRequest(w, r, err) writeAPIError(w, err)
} }
} }
} }
@@ -30,7 +24,7 @@ func guard(check func(http.ResponseWriter, *http.Request) error) func(http.Handl
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := check(w, r); err != nil { if err := check(w, r); err != nil {
failRequest(w, r, err) writeAPIError(w, err)
return return
} }
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
-273
View File
@@ -1,273 +0,0 @@
package main
import (
"embed"
"html/template"
"net/http"
"strings"
)
// Templates werden einkompiliert statt zur Laufzeit gelesen. Zwei Gründe: das
// Deployment braucht kein zusätzliches Verzeichnis, und -- wichtiger -- sie
// dürfen NICHT unter web/ liegen, weil das komplett per FileServer ausgeliefert
// wird (siehe routes()). Eine Vorlage dort wäre über HTTP abrufbar.
//
//go:embed templates/*.html
var tmplFS embed.FS
// pages hält je Seite ein eigenes Template-Set aus base.html + der Seitendatei.
// Getrennte Sets, weil jede Seite ihren eigenen "content"-Block definiert --
// in einem gemeinsamen Set würde der letzte alle anderen überschreiben.
var pages = map[string]*template.Template{}
// tmplFuncs.fieldErr vereinheitlicht die beiden Fehlerformen für die Vorlage:
// ein Fehler mit einzelnem Field (Login) und einer mit Fields-Map
// (Registrierung, alle Felder auf einmal) werden gleich abgefragt.
var tmplFuncs = template.FuncMap{
"fieldErr": func(e *Error, field string) string {
if e == nil {
return ""
}
if msg, ok := e.Fields[field]; ok {
return msg
}
if e.Field == field {
return e.Msg
}
return ""
},
"content": renderContent,
"avatar": avatarURL,
"profile": profileURL,
"time": fmtTime,
"timeAttr": fmtTimeAttr,
}
// pageFiles listet je Seite die zusätzlich benötigten Vorlagen. base.html
// kommt überall dazu; Seiten mit Beitragslisten brauchen zusätzlich die
// gemeinsamen entry-/pager-Blöcke.
var pageFiles = map[string][]string{
"login": nil,
"register": nil,
"feed": {"templates/entries.html"},
"userfeed": {"templates/entries.html"},
"reported": nil,
"thread": {"templates/entries.html"},
"edit": nil,
"delete": nil,
}
func init() {
for name, extra := range pageFiles {
files := append([]string{"templates/base.html", "templates/" + name + ".html"}, extra...)
pages[name] = template.Must(
template.New(name).Funcs(tmplFuncs).ParseFS(tmplFS, files...))
}
}
// pageData ist der Rahmen jeder Seite. Form trägt die zuletzt eingegebenen
// Werte, damit ein Fehler den Nutzer nicht sein Formular neu tippen lässt.
// ReturnTo ist der Ort, an den Aktions-Formulare zurückführen sollen.
type pageData struct {
Title string
Err *Error
Form any
ReturnTo string
}
// render gibt eine Seite mit Status 200 aus.
func render(w http.ResponseWriter, r *http.Request, name string, data pageData) {
data.ReturnTo = returnTo(r)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
pages[name].ExecuteTemplate(w, "base", data)
}
// returnTo bestimmt den Rücksprungort für die Formulare einer Seite.
//
// Bei GET ist das die Seite selbst -- inklusive Query, damit Filter (Nutzer-
// Feed, Suche, Seitenzahl) automatisch mitreisen, ohne dass die Formulare sie
// einzeln kennen müssen. Bei POST wird das mitgeschickte return_to
// weitergereicht, damit ein neu gerendertes Formular sein Ziel behält.
func returnTo(r *http.Request) string {
if r.Method == http.MethodGet || r.Method == http.MethodHead {
return safeReturn(r.URL.RequestURI(), "/")
}
return safeReturn(r.FormValue("return_to"), "/")
}
// safeReturn akzeptiert nur seiteneigene Pfade. Ungeprüft in http.Redirect
// gesteckt wäre return_to ein Open-Redirect: ein Link auf die eigene Domain,
// der auf einer fremden landet -- die klassische Phishing-Tarnung.
//
// Abgelehnt wird deshalb alles, was nicht eindeutig ein absoluter Pfad auf
// dieser Seite ist:
//
// - "//host/x" protokoll-relativ, zeigt auf eine fremde Domain
// - `/\host/x` manche Browser normalisieren \ zu / -> wie oben
// - "https://x" absolute URL
// - "u/alice" relativ; würde je nach Basis irgendwo landen
// - "\r\n..." Steuerzeichen könnten weitere Header einschleusen
func safeReturn(v, fallback string) string {
if v == "" || len(v) > 512 {
return fallback
}
if v[0] != '/' || strings.HasPrefix(v, "//") || strings.HasPrefix(v, `/\`) {
return fallback
}
if strings.ContainsAny(v, "\r\n\x00") {
return fallback
}
return v
}
// redirectBack schickt nach einer erfolgreichen Aktion dorthin zurück, wo sie
// ausgelöst wurde -- der noscript-Ersatz für "DOM aktualisieren, Seite stehen
// lassen". 303 statt 302, damit der Browser die Zielseite per GET holt und ein
// Reload die Aktion nicht wiederholt.
func redirectBack(w http.ResponseWriter, r *http.Request, fallback string) {
http.Redirect(w, r, safeReturn(r.FormValue("return_to"), fallback), http.StatusSeeOther)
}
// failForm rendert ein Formular nach einem Fehler neu -- mit Meldung und den
// bisherigen Eingaben. Das ist der noscript-Ersatz für die Fehlermeldung, die
// das JS-Frontend sonst per fetch nachlädt.
//
// Serverfehler landen bewusst NICHT im Formular: bei einem 500 ist unklar, ob
// die Eingabe überhaupt das Problem war, und ein "bitte nochmal absenden" wäre
// die falsche Aufforderung.
func failForm(w http.ResponseWriter, r *http.Request, name string, data pageData, err error) {
e := asError(err)
if e.Kind == KindInternal {
renderError(w, e)
return
}
data.Err = e
data.ReturnTo = returnTo(r)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(statusOf(e.Kind))
pages[name].ExecuteTemplate(w, "base", data)
}
// failRequest gibt einen Fehler in dem Format aus, das der Client erwartet.
// Für Endpunkte, die beide Seiten bedienen: ein Browser-Formular darf niemals
// rohes JSON zu sehen bekommen -- ohne JavaScript gibt es nichts, was es
// übersetzen könnte.
func failRequest(w http.ResponseWriter, r *http.Request, err error) {
if wantsJSON(r) {
writeAPIError(w, err)
return
}
renderError(w, err)
}
// wantsJSON entscheidet, welcher Adapter antwortet. Das JS-Frontend schickt
// Accept: application/json (siehe api in feed.js), ein Browser-Formular
// schickt text/html -- derselbe Endpunkt bedient damit beide, ohne dass die
// Logik doppelt existiert.
func wantsJSON(r *http.Request) bool {
return strings.Contains(r.Header.Get("Accept"), "application/json")
}
// HTML-Adapter: das Gegenstück zu writeAPIError. Derselbe Fehler aus derselben
// Taxonomie, nur als Seite statt als JSON -- welchen Statuscode er bekommt,
// entscheidet weiterhin statusOf, damit die beiden Adapter nicht auseinander
// laufen können.
//
// Die Seite kommt bewusst ohne JavaScript aus: sie ist der Weg, auf dem ein
// Nutzer mit abgeschaltetem JS (TOR Browser auf "Safest", I2P) überhaupt
// erfährt, was schiefgegangen ist.
type errorPageData struct {
Status int
Title string
Message string
Code string
}
var errorTmpl = template.Must(template.New("error").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>{{.Status}} · {{.Title}}</title>
<link rel="stylesheet" href="/css/app.css">
</head>
<body>
<main>
<h1>{{.Status}} · {{.Title}}</h1>
<p>{{.Message}}</p>
<p class="muted">Fehlercode: {{.Code}}</p>
<p><a href="/feed">Zum Feed</a></p>
</main>
</body>
</html>`))
// titleForKind liefert die Überschrift der Fehlerseite. Bewusst getrennt von
// der Msg des Fehlers: die Überschrift ordnet grob ein ("Nicht gefunden"), die
// Meldung sagt konkret, was los ist ("Beitrag nicht gefunden").
func titleForKind(k Kind) string {
switch k {
case KindInvalid:
return "Ungültige Anfrage"
case KindUnauth:
return "Anmeldung erforderlich"
case KindForbidden:
return "Nicht erlaubt"
case KindNotFound:
return "Nicht gefunden"
case KindConflict:
return "Konflikt"
case KindTooLarge:
return "Zu groß"
case KindRateLimited:
return "Zu viele Anfragen"
case KindUnavailable:
return "Nicht verfügbar"
default:
return "Serverfehler"
}
}
// renderError ist der einzige Ausgang für Fehler im HTML-Adapter.
func renderError(w http.ResponseWriter, err error) {
e := asError(err)
logInternal(e)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(statusOf(e.Kind))
// Execute-Fehler werden verschluckt: der Status steht schon in der
// Leitung, eine zweite Antwort ginge nicht mehr.
errorTmpl.Execute(w, errorPageData{
Status: statusOf(e.Kind),
Title: titleForKind(e.Kind),
Message: e.Msg,
Code: e.Code,
})
}
// page hängt einen fehlerliefernden Handler als HTML-Seite ein -- das
// Gegenstück zu api(). Derselbe Handler kann an beiden Wrappern hängen; das
// Ausgabeformat entscheidet allein die Route.
func page(h handlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := h(w, r); err != nil {
renderError(w, err)
}
}
}
// pageMiddleware ist das HTML-Gegenstück zu apiMiddleware. Der Prüfschritt darf
// über den ResponseWriter, weil manche Prüfungen vor der Antwort noch Header
// setzen müssen (Basic-Auth braucht WWW-Authenticate).
func pageMiddleware(check func(http.ResponseWriter, *http.Request) error) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := check(w, r); err != nil {
renderError(w, err)
return
}
next.ServeHTTP(w, r)
})
}
}
-119
View File
@@ -1,119 +0,0 @@
package main
import (
"net/http/httptest"
"strings"
"testing"
)
// TestSafeReturn deckt die Open-Redirect-Abwehr ab. Jede hier durchgelassene
// Eingabe landet später in einem Location-Header -- die abgelehnten Fälle sind
// deshalb wichtiger als die erlaubten.
func TestSafeReturn(t *testing.T) {
const fallback = "/"
erlaubt := []string{
"/",
"/u/alice",
"/u/alice?page=3",
"/?page=2#e123",
"/u/alice?q=%22katzen%22&page=1#e42",
"/e/123",
}
for _, v := range erlaubt {
if got := safeReturn(v, fallback); got != v {
t.Errorf("safeReturn(%q) = %q, erwartet unverändert", v, got)
}
}
abgelehnt := map[string]string{
"leer": "",
"protokoll-relativ": "//evil.example/x",
"drei slashes": "///evil.example",
"backslash-variante": `/\evil.example`,
"absolute https": "https://evil.example",
"absolute http": "http://evil.example",
"javascript-schema": "javascript:alert(1)",
"data-schema": "data:text/html,<script>",
"relativ ohne slash": "u/alice",
"header-injection CR": "/x\r\nSet-Cookie: a=b",
"header-injection LF": "/x\nSet-Cookie: a=b",
"nullbyte": "/x\x00y",
"zu lang": "/" + strings.Repeat("a", 512),
"backslash am anfang": `\/evil.example`,
"leerzeichen davor": " /u/alice",
}
for name, v := range abgelehnt {
if got := safeReturn(v, fallback); got != fallback {
t.Errorf("%s: safeReturn(%q) = %q, erwartet Fallback %q", name, v, got, fallback)
}
}
// Der Fallback wird durchgereicht, nicht hartkodiert.
if got := safeReturn("//evil.example", "/u/bob"); got != "/u/bob" {
t.Errorf("eigener Fallback: bekam %q", got)
}
}
// TestReturnTo prüft die beiden Quellen: bei GET die aufgerufene URL (damit
// Filter und Seitenzahl automatisch mitreisen), bei POST das mitgeschickte
// Feld (damit ein neu gerendertes Formular sein Ziel behält).
func TestReturnTo(t *testing.T) {
get := httptest.NewRequest("GET", "/u/alice?page=3&q=katzen", nil)
if got, want := returnTo(get), "/u/alice?page=3&q=katzen"; got != want {
t.Errorf("GET: returnTo = %q, erwartet %q", got, want)
}
// Eine bösartige URL kann per GET gar nicht erst entstehen (der Pfad kommt
// vom Router), aber die Prüfung muss trotzdem greifen.
odd := httptest.NewRequest("GET", "http://example.com//evil.example", nil)
if got := returnTo(odd); got != "/" {
t.Errorf("GET mit // : returnTo = %q, erwartet /", got)
}
post := httptest.NewRequest("POST", "/entry/1/vote",
strings.NewReader("return_to=%2Fu%2Falice%3Fpage%3D3%23e42&mode=left"))
post.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if got, want := returnTo(post), "/u/alice?page=3#e42"; got != want {
t.Errorf("POST: returnTo = %q, erwartet %q", got, want)
}
evil := httptest.NewRequest("POST", "/entry/1/vote",
strings.NewReader("return_to=https%3A%2F%2Fevil.example"))
evil.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if got := returnTo(evil); got != "/" {
t.Errorf("POST mit fremder URL: returnTo = %q, erwartet /", got)
}
// Ohne Feld: Fallback, nicht die POST-Ziel-URL.
bare := httptest.NewRequest("POST", "/entry/1/vote", nil)
if got := returnTo(bare); got != "/" {
t.Errorf("POST ohne return_to: returnTo = %q, erwartet /", got)
}
}
// TestRedirectBack stellt sicher, dass die Umleitung nach einer Aktion ein 303
// ist (GET auf das Ziel, Reload wiederholt die Aktion nicht) und dass ein
// manipuliertes return_to nicht im Location-Header landet.
func TestRedirectBack(t *testing.T) {
post := func(body string) *httptest.ResponseRecorder {
r := httptest.NewRequest("POST", "/entry/1/vote", strings.NewReader(body))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
redirectBack(w, r, "/")
return w
}
w := post("return_to=%2Fu%2Falice%23e42")
if w.Code != 303 {
t.Errorf("Status = %d, erwartet 303", w.Code)
}
if got, want := w.Header().Get("Location"), "/u/alice#e42"; got != want {
t.Errorf("Location = %q, erwartet %q", got, want)
}
w = post("return_to=%2F%2Fevil.example")
if got := w.Header().Get("Location"); got != "/" {
t.Errorf("manipuliertes Ziel: Location = %q, erwartet /", got)
}
}
+6 -6
View File
@@ -9,18 +9,18 @@
# KEINE Anführungszeichen um den Wert, keine Shell-Expansion ($VAR ist literal), # KEINE Anführungszeichen um den Wert, keine Shell-Expansion ($VAR ist literal),
# `#` für Kommentare. # `#` für Kommentare.
# Basic-Auth für die Moderationsseite /moderation. PFLICHT: ohne diese beiden # Postgres-Verbindung. PFLICHT. Sonderzeichen im Passwort URL-kodieren.
# Werte bleibt /moderation gesperrt (HTTP 503, fail-closed). Langes Zufalls- KVER_DSN=postgres://kver:passwort@host:5432/kontrollverlust?sslmode=disable
# passwort verwenden.
KVER_MOD_USER=moderator
KVER_MOD_PASS=hier-ein-langes-zufallspasswort
# Optional: Pfad zur GeoLite2-ASN-DB im Container (leer = ASN-Auflösung aus). # Optional: Pfad zur GeoLite2-ASN-DB im Container (leer = ASN-Auflösung aus).
#KVER_GEOIP_ASN=/app/data/GeoLite2-ASN.mmdb #KVER_GEOIP_ASN=/app/data/GeoLite2-ASN.mmdb
# Optional: öffentliche Adresse, damit `kver reset-link` einen fertigen Link
# ausgibt statt nur des Pfads.
#KVER_BASE_URL=https://example.org
# Selten nötig -- die Container-Defaults passen normalerweise: # Selten nötig -- die Container-Defaults passen normalerweise:
#KVER_ADDR=:8080 #KVER_ADDR=:8080
#KVER_DB=/app/data/kver.db
# Optional: Verzeichnis mit web/ und static/ (die App macht dort ein chdir). # Optional: Verzeichnis mit web/ und static/ (die App macht dort ein chdir).
# Erlaubt den Start aus beliebigem Arbeitsverzeichnis. # Erlaubt den Start aus beliebigem Arbeitsverzeichnis.
+106 -62
View File
@@ -3,11 +3,13 @@ package main
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"log" "log"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"os/signal" "os/signal"
"runtime/debug"
"syscall" "syscall"
"time" "time"
@@ -17,20 +19,34 @@ import (
) )
func main() { func main() {
// KVER_DIR: Verzeichnis mit web/ und static/. Per Chdir werden alle // KVER_DIR: Verzeichnis mit web/ (gebautes Frontend) und static/. Per
// relativen Pfade (auch Uploads und ein relativer KVER_DB-Pfad) auf einmal // Chdir werden alle relativen Pfade (auch Uploads) auf einmal verankert --
// verankert -- die App ist damit nicht mehr an das Start-CWD gebunden. // die App ist damit nicht mehr an das Start-CWD gebunden.
if dir := os.Getenv("KVER_DIR"); dir != "" { if dir := os.Getenv("KVER_DIR"); dir != "" {
if err := os.Chdir(dir); err != nil { if err := os.Chdir(dir); err != nil {
log.Fatalf("KVER_DIR: %v", err) log.Fatalf("KVER_DIR: %v", err)
} }
} }
if err := initDB(envOr("KVER_DB", "kver.db")); err != nil { dsn := os.Getenv("KVER_DSN")
if dsn == "" {
log.Fatal("KVER_DSN fehlt (z. B. postgres://kver:passwort@host:5432/kontrollverlust)")
}
if err := initDB(dsn); err != nil {
log.Fatalf("db init: %v", err) log.Fatalf("db init: %v", err)
} }
defer db.Close() defer db.Close()
// Unterbefehle (z. B. reset-link) statt Server: laufen gegen dieselbe DB
// und beenden sich danach, siehe command.go.
if len(os.Args) > 1 {
if err := runCommand(os.Args[1:]); err != nil {
db.Close()
log.Fatal(err)
}
return
}
// Optionale GeoLite2-ASN-Datenbank für die netzwerktopologische Einordnung // Optionale GeoLite2-ASN-Datenbank für die netzwerktopologische Einordnung
// der Impressions. Inaktiv, wenn KVER_GEOIP_ASN nicht gesetzt ist. // der Impressions. Inaktiv, wenn KVER_GEOIP_ASN nicht gesetzt ist.
initGeoASN() initGeoASN()
@@ -41,6 +57,10 @@ func main() {
// die geschlossene DB oder den unmap-ten GeoIP-Reader zugreifen. // die geschlossene DB oder den unmap-ten GeoIP-Reader zugreifen.
startImpressionWorker() startImpressionWorker()
defer stopImpressionWorker() defer stopImpressionWorker()
startPreviewWorker()
defer stopPreviewWorker()
startChartWorker()
defer stopChartWorker()
srv := &http.Server{ srv := &http.Server{
Addr: envOr("KVER_ADDR", ":8080"), Addr: envOr("KVER_ADDR", ":8080"),
@@ -54,7 +74,7 @@ func main() {
} }
// Graceful Shutdown: auf SIGINT/SIGTERM laufende Requests zu Ende bedienen, // Graceful Shutdown: auf SIGINT/SIGTERM laufende Requests zu Ende bedienen,
// dann die DB sauber schließen (WAL-Checkpoint) statt hart abzubrechen. // dann über die defers Worker und DB sauber schließen.
idle := make(chan struct{}) idle := make(chan struct{})
go func() { go func() {
sig := make(chan os.Signal, 1) sig := make(chan os.Signal, 1)
@@ -85,6 +105,10 @@ func envOr(key, fallback string) string {
// routes baut den HTTP-Handler mit allen Endpunkten. In main() und in den // routes baut den HTTP-Handler mit allen Endpunkten. In main() und in den
// Tests identisch verwendet. // Tests identisch verwendet.
//
// Aufteilung: alles unter /api spricht ausschließlich JSON -- auch 404, 405,
// Rate-Limit und Panics. Der Rest liefert das Frontend (frontend.go) und
// statische Dateien.
func routes() http.Handler { func routes() http.Handler {
r := chi.NewRouter() r := chi.NewRouter()
// RealIP übernimmt X-Forwarded-For/X-Real-IP vom Reverse-Proxy, damit // RealIP übernimmt X-Forwarded-For/X-Real-IP vom Reverse-Proxy, damit
@@ -94,68 +118,47 @@ func routes() http.Handler {
// spoofbar. // spoofbar.
r.Use(middleware.RealIP) r.Use(middleware.RealIP)
r.Use(middleware.Logger) r.Use(middleware.Logger)
r.Use(middleware.Recoverer) r.Use(recoverJSON)
r.Use(secHeaders) r.Use(secHeaders)
r.Use(checkOrigin) r.Use(checkOrigin)
r.Route("/api", func(r chi.Router) {
r.NotFound(api(func(http.ResponseWriter, *http.Request) error {
return NotFound("api.not_found", "Unbekannter Endpunkt.")
}))
r.MethodNotAllowed(api(func(http.ResponseWriter, *http.Request) error {
return MethodNotAllowed("api.method_not_allowed", "Methode nicht erlaubt.")
}))
// --- Auth (öffentlich) --- // --- Auth (öffentlich) ---
// Login und Registrierung pro IP drosseln (Brute-Force-Bremse). bcrypt bremst // Login und Registrierung pro IP drosseln (Brute-Force-Bremse). bcrypt
// zusätzlich, aber das Limit kappt automatisiertes Durchprobieren früh. // bremst zusätzlich, aber das Limit kappt automatisiertes Durchprobieren
// früh.
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(httprate.LimitByIP(20, time.Minute)) r.Use(limitByIP(20, time.Minute))
// handleLogin bedient JSON- und Formular-Clients (siehe wantsJSON) und
// schreibt HTML-Antworten selbst -- api() greift nur noch für den
// JSON-Zweig.
r.Post("/auth/login", api(handleLogin)) r.Post("/auth/login", api(handleLogin))
r.Post("/auth/newuser", api(handleNewUser)) r.Post("/auth/newuser", api(handleNewUser))
r.Post("/auth/reset/check", api(handleResetCheck))
r.Post("/auth/reset", api(handleReset))
}) })
// Logout als POST: SameSite=Lax schickt Cookies bei Top-Level-GET- // Logout als POST: SameSite=Lax schickt Cookies bei Top-Level-GET-
// Navigationen mit -- als GET könnte eine fremde Seite Nutzer per Link // Navigationen mit -- als GET könnte eine fremde Seite Nutzer per Link
// ausloggen (CSRF). POST ist durch Lax + checkOrigin gedeckt. // ausloggen (CSRF). POST ist durch Lax + checkOrigin gedeckt.
r.Post("/auth/logout", handleLogout) r.Post("/auth/logout", api(handleLogout))
// Serverseitig gerenderte Auth-Seiten (noscript-Weg). Kein Rate-Limit: das
// bloße Anzeigen der Formulare ist kein Brute-Force-Vektor, die POSTs oben
// sind gedrosselt.
r.Get("/auth/login", page(showLogin))
r.Get("/auth/register", page(showRegister))
// Serverseitig gerenderte Feeds. Eigene Pfade, solange "/" und "/u/{name}"
// noch die JS-Varianten ausliefern -- so bleibt die Seite während des
// Umbaus durchgehend benutzbar. Sobald das Frontend auf gerendertem Markup
// aufsetzt, übernehmen diese Handler die ursprünglichen Pfade.
r.With(trackImpression).Get("/feed", page(showFeed))
r.With(trackImpression).Get("/u/{username}/beitraege", page(showUserFeed))
r.With(trackImpression).Get("/beitrag/{pid}", page(showThread))
r.Get("/auth/sessioninfo", api(handleSessionInfo)) r.Get("/auth/sessioninfo", api(handleSessionInfo))
r.Get("/auth/headerbar", handleHeaderbar) r.Get("/stats", api(handleStats))
r.Get("/stats", handleStats) r.Get("/stats/detail", api(handleStatsDetail))
r.Get("/stats/detail", handleStatsDetail) r.Get("/stats/charts", api(handleStatsCharts))
// Moderations-Queue (Meldungen). Basic-Auth direkt in der App (modBasicAuth), // --- Entries (öffentlich) ---
// 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", page(handleModeration))
// HTML-Seiten: mit Impression-Tracking (anonymisiert, siehe stats.go). Nur
// echte Seitenaufrufe, nicht API/Assets.
r.With(trackImpression).Get("/", serveIndex)
// --- Entries ---
r.Get("/entry/feed/{page}", api(handleFeed)) r.Get("/entry/feed/{page}", api(handleFeed))
r.Get("/entry/{pid}/votes", api(handleVotes)) r.Get("/entry/{pid}/votes", api(handleVotes))
r.Get("/entry/{pid}/thread", api(handleThread)) r.Get("/entry/{pid}/thread", api(handleThread))
r.With(trackImpression).Get("/e/{pid}", serveEntryPage) // Melden darf jeder (anonym ohne Session), gedrosselt pro IP, damit der
// offene Endpunkt kein Spam-Kanal wird.
// Melden: öffentlich (jeder darf melden, Moderationshilfe), aber pro IP r.With(limitByIP(10, time.Minute)).Post("/entry/{pid}/report", api(handleReport))
// gedrosselt, damit der auth-lose Endpunkt nicht zum Spam-Kanal wird.
r.With(httprate.LimitByIP(10, time.Minute)).Post("/entry/{pid}/report", api(handleReport))
// Bestätigungsseite nach einer Meldung ohne JavaScript. Reines Anzeigen,
// deshalb ohne Rate-Limit -- gemeldet wird im POST darüber.
r.Get("/entry/{pid}/gemeldet", page(showReported))
// --- User (öffentlich) --- // --- User (öffentlich) ---
r.With(trackImpression).Get("/u/{username}", serveUserPage)
r.Get("/u/{username}/info", api(handleUserPage)) r.Get("/u/{username}/info", api(handleUserPage))
r.Get("/u/{username}/feed/{page}", api(handleUserFeed)) r.Get("/u/{username}/feed/{page}", api(handleUserFeed))
@@ -166,31 +169,72 @@ func routes() http.Handler {
r.Post("/entry/{pid}/vote", api(handleVote)) r.Post("/entry/{pid}/vote", api(handleVote))
r.Post("/entry/{pid}/bump", api(handleBump)) r.Post("/entry/{pid}/bump", api(handleBump))
r.Post("/entry/{pid}/edit", api(handleEditEntry)) r.Post("/entry/{pid}/edit", api(handleEditEntry))
// Bearbeitungsformular (noscript-Weg). In der requireAuth-Gruppe, weil
// nur der Autor den bisherigen Text zu sehen bekommen soll.
r.Get("/beitrag/{pid}/bearbeiten", page(showEditEntry))
// Löschbestätigung: das eigentliche Löschen bleibt ein POST, damit ein
// gefolgter Link (Linkvorschau, Crawler) nichts vernichtet.
r.Get("/beitrag/{pid}/loeschen", page(showDeleteEntry))
r.Post("/entry/{pid}/delete", api(handleDeleteEntry)) r.Post("/entry/{pid}/delete", api(handleDeleteEntry))
r.Get("/user/info", api(handleUserInfo)) r.Get("/user/info", api(handleUserInfo))
r.Post("/user/rename", api(handleUserRename)) r.Post("/user/rename", api(handleUserRename))
r.Post("/user/avatar", api(handleSetAvatar)) r.Post("/user/avatar", api(handleSetAvatar))
r.Post("/user/delete", api(handleUserDelete)) r.Post("/user/delete", api(handleUserDelete))
}) })
})
// Hochgeladene Medien + alte statische Assets. nosniff (global via // --- Frontend (React) ---
// secHeaders) verhindert, dass der Browser eine durchgereichte (nicht // Die Seitenrouten entsprechen denen des Client-Routers (frontend/src/
// re-kodierte) Datei als HTML/JS interpretiert -> schließt // main.tsx) und liefern alle dieselbe index.html. Impression-Tracking
// Polyglot/Stored-XSS über Bild-Uploads. // (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)
// Reset-Seite ohne Tracking; das Token steht im Fragment (#...) und
// erreicht den Server über diese Route nie, siehe reset.go.
r.Get("/reset", 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())
r.Get("/static/docs/{page}", serveDoc)
// Hochgeladene Medien + statische Dateien (Schriften, Bilder).
// 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")))) r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
// Neues JS-Frontend
r.Handle("/*", http.FileServer(http.Dir("web")))
return r return r
} }
// limitByIP ist httprate.LimitByIP mit JSON-Antwort statt des Klartext-429.
func limitByIP(n int, window time.Duration) func(http.Handler) http.Handler {
return httprate.Limit(n, window,
httprate.WithKeyFuncs(httprate.KeyByIP),
httprate.WithLimitHandler(api(func(http.ResponseWriter, *http.Request) error {
return RateLimited("rate.limited", "Zu viele Anfragen. Bitte kurz warten.")
})),
)
}
// recoverJSON ersetzt middleware.Recoverer: eine Panic wird geloggt (mit Stack)
// und als normaler 500 im JSON-Fehlerformat beantwortet. http.ErrAbortHandler
// ist ein gewollter Verbindungsabbruch und wird durchgereicht.
func recoverJSON(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
rec := recover()
if rec == nil {
return
}
if rec == http.ErrAbortHandler {
panic(rec)
}
log.Printf("panic: %v\n%s", rec, debug.Stack())
writeAPIError(w, Internal(fmt.Errorf("panic: %v", rec)))
}()
next.ServeHTTP(w, r)
})
}
// secHeaders setzt Standard-Security-Header auf alle Antworten: // secHeaders setzt Standard-Security-Header auf alle Antworten:
// - nosniff: Browser darf den deklarierten Content-Type nicht überstimmen // - nosniff: Browser darf den deklarierten Content-Type nicht überstimmen
// (wichtig für durchgereichte GIF-Uploads, siehe storeImage). // (wichtig für durchgereichte GIF-Uploads, siehe storeImage).
+3 -7
View File
@@ -16,7 +16,7 @@ const uidKey ctxKey = "uid"
// der Onion-Hidden-Service reicht über einen lokalen Proxy weiter, eine echte // der Onion-Hidden-Service reicht über einen lokalen Proxy weiter, eine echte
// Client-IP gibt es dabei nicht -> die Anfragen erscheinen mit einer IP aus // Client-IP gibt es dabei nicht -> die Anfragen erscheinen mit einer IP aus
// diesem Netz (siehe Impression-Log: 10.89.0.0). Beiträge von dort werden // diesem Netz (siehe Impression-Log: 10.89.0.0). Beiträge von dort werden
// vorerst komplett abgewiesen; eine Moderations-/Freigabeseite kommt später. // vorerst komplett abgewiesen.
// Per KVER_TOR_NET (CIDR) konfigurierbar; leer schaltet die Sperre ab. // Per KVER_TOR_NET (CIDR) konfigurierbar; leer schaltet die Sperre ab.
var torNet *net.IPNet var torNet *net.IPNet
@@ -51,9 +51,7 @@ var blockTOR = guard(func(_ http.ResponseWriter, r *http.Request) error {
var errTORBlocked = Forbidden("tor.blocked", "Beiträge über TOR sind derzeit nicht möglich.") var errTORBlocked = Forbidden("tor.blocked", "Beiträge über TOR sind derzeit nicht möglich.")
// isTOR erkennt Anfragen aus dem TOR-Netz. Ausgelagert, damit die // isTOR erkennt Anfragen aus dem TOR-Netz.
// serverseitig gerenderten Seiten das Beitragsformular erst gar nicht anbieten,
// statt es anzuzeigen und beim Absenden abzulehnen.
func isTOR(r *http.Request) bool { func isTOR(r *http.Request) bool {
if torNet == nil { if torNet == nil {
return false return false
@@ -71,9 +69,7 @@ func requireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s, ok := getSession(r) s, ok := getSession(r)
if !ok { if !ok {
// Über failRequest: ohne JavaScript ist die Session-Abfrage sonst writeAPIError(w, errAuthRequired)
// eine JSON-Zeile mitten im Browser.
failRequest(w, r, errAuthRequired)
return return
} }
ctx := context.WithValue(r.Context(), uidKey, s.UID) ctx := context.WithValue(r.Context(), uidKey, s.UID)
-196
View File
@@ -1,196 +0,0 @@
#!/usr/bin/env bash
#
# migrate.sh — liest kver-original.db und erzeugt daraus eine frische kver.db
# mit dem aktuellen Go-Schema. Die Quelldatenbank wird nie verändert.
# Idempotent: kver.db wird bei jedem Lauf neu erstellt.
#
# Verwendung:
# ./migrate.sh [quelldatei [zieldatei]] # Standard: kver-original.db -> kver.db
#
# Schalter (als Umgebungsvariablen):
# REPLIES=keep|drop Antworten (reply_to != 0) übernehmen oder verwerfen (Default: keep)
#
set -euo pipefail
SRC="${1:-original-kver.db}"
DST="${2:-kver.db}"
REPLIES="${REPLIES:-keep}"
if [[ ! -f "$SRC" ]]; then
echo "FEHLER: Quelldatenbank '$SRC' nicht gefunden." >&2
exit 1
fi
if [[ "$REPLIES" != "keep" && "$REPLIES" != "drop" ]]; then
echo "FEHLER: REPLIES muss 'keep' oder 'drop' sein (war: '$REPLIES')." >&2
exit 1
fi
if [[ "$SRC" -ef "$DST" ]]; then
echo "FEHLER: Quelle und Ziel sind dieselbe Datei." >&2
exit 1
fi
qs() { sqlite3 "$SRC" "$1"; }
qd() { sqlite3 "$DST" "$1"; }
echo "== kver-Migration =="
echo "Quelle: $SRC"
echo "Ziel: $DST"
echo "REPLIES: $REPLIES"
echo
echo "Quelle:"
echo " Einträge gesamt: $(qs 'SELECT COUNT(*) FROM entry;')"
echo " davon deleted=1: $(qs 'SELECT COUNT(*) FROM entry WHERE deleted=1;')"
echo " davon Antworten: $(qs 'SELECT COUNT(*) FROM entry WHERE reply_to!=0;')"
echo " User: $(qs 'SELECT COUNT(*) FROM user;')"
# Zieldatenbank immer frisch anlegen
rm -f "$DST"
echo
echo "Erstelle $DST mit aktuellem Schema..."
sqlite3 "$DST" <<'SCHEMA'
.bail on
CREATE TABLE user (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uid INTEGER UNIQUE,
username TEXT UNIQUE,
password BLOB,
created_at INTEGER,
last_login INTEGER,
avatar TEXT NOT NULL DEFAULT ''
);
CREATE TABLE session (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uid INTEGER,
value TEXT UNIQUE,
created_at INTEGER,
expires INTEGER,
description TEXT
);
CREATE TABLE entry (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pid INTEGER UNIQUE NOT NULL,
uid INTEGER NOT NULL,
created_at INTEGER NOT NULL,
content TEXT NOT NULL DEFAULT '',
filepath TEXT NOT NULL DEFAULT '',
reply_to INTEGER NOT NULL DEFAULT 0,
reply_count INTEGER NOT NULL DEFAULT 0,
last_activity INTEGER NOT NULL DEFAULT 0,
deleted INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE vote (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uid INTEGER,
pid INTEGER,
mode TEXT,
UNIQUE(uid, pid)
);
SCHEMA
# Prüfen, welche optionalen Spalten in der Quelle vorhanden sind.
# Ältere Python-DBs haben reply_count und last_activity noch nicht.
has_col() {
sqlite3 "$SRC" "SELECT COUNT(*) FROM pragma_table_info('$1') WHERE name='$2';"
}
reply_count_expr="0"
[[ "$(has_col entry reply_count)" == "1" ]] && reply_count_expr="COALESCE(e.reply_count, 0)"
last_activity_expr="e.created_at"
[[ "$(has_col entry last_activity)" == "1" ]] && last_activity_expr="COALESCE(e.last_activity, e.created_at)"
# Profilbild: alte DBs haben die Spalte nicht -> leerer String.
avatar_expr="''"
[[ "$(has_col user avatar)" == "1" ]] && avatar_expr="COALESCE(u.avatar, '')"
replies_filter=""
[[ "$REPLIES" == "drop" ]] && replies_filter="AND e.reply_to = 0"
has_vote_table=$(qs "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='vote';")
# Quell-Pfad für SQL-String-Literal escapen (einfache Anführungszeichen verdoppeln)
src_sql="${SRC//\'/\'\'}"
sqlite3 "$DST" <<SQL
.bail on
ATTACH '$src_sql' AS src;
BEGIN;
-- User übernehmen; nur Spalten des aktuellen Schemas (Quelle kann Extra-Spalten haben)
INSERT INTO user (id, uid, username, password, created_at, last_login, avatar)
SELECT u.id, u.uid, u.username, u.password, u.created_at, u.last_login, $avatar_expr FROM src.user u;
-- Einträge transformieren:
-- filepath NULL und 'none' -> '' (Go-Zero-Value; scanEntries erwartet string, nicht *string)
-- content NULL -> ''
-- deleted: nur nicht-gelöschte Einträge werden übernommen
-- orphans: Einträge ohne bekannten User werden verworfen
INSERT INTO entry (pid, uid, created_at, content, filepath, reply_to, reply_count, last_activity, deleted)
SELECT
e.pid,
e.uid,
e.created_at,
COALESCE(e.content, ''),
COALESCE(NULLIF(e.filepath, 'none'), ''),
COALESCE(e.reply_to, 0),
$reply_count_expr,
$last_activity_expr,
0
FROM src.entry e
WHERE e.deleted = 0
AND e.uid IN (SELECT uid FROM src.user)
$replies_filter;
-- Absolute /srv/kver/-Pfade aus der alten Python-Prod-Umgebung normalisieren.
UPDATE entry SET filepath = REPLACE(filepath, '/srv/kver/', '') WHERE filepath LIKE '/srv/kver/%';
-- Sessions leer lassen: Alt-Tokens sind Integer (Python-Format),
-- Go erzeugt crypto/rand-Hex-Tokens -> alle Alt-Sessions sind wertlos.
COMMIT;
DETACH src;
VACUUM;
SQL
# Votes separat, da die Tabelle in alten DBs fehlen kann
if [[ "$has_vote_table" == "1" ]]; then
sqlite3 "$DST" <<SQL2
.bail on
ATTACH '$src_sql' AS src;
INSERT OR IGNORE INTO vote (uid, pid, mode)
SELECT uid, pid, mode FROM src.vote
WHERE uid IN (SELECT uid FROM entry)
AND pid IN (SELECT pid FROM entry);
DETACH src;
SQL2
fi
echo
echo "Nachher:"
echo " Einträge gesamt: $(qd 'SELECT COUNT(*) FROM entry;')"
echo " davon Antworten: $(qd 'SELECT COUNT(*) FROM entry WHERE reply_to!=0;')"
echo " sichtbar im Feed: $(qd 'SELECT COUNT(*) FROM entry WHERE reply_to=0;')"
echo " User: $(qd 'SELECT COUNT(*) FROM user;')"
echo " Votes: $(qd 'SELECT COUNT(*) FROM vote;')"
# Medien-Check: referenzierte Bilder, die im Dateisystem fehlen
missing=0
total=0
while IFS= read -r fp; do
[[ -z "$fp" ]] && continue
total=$((total+1))
[[ -f "$fp" ]] || missing=$((missing+1))
done < <(qd "SELECT filepath FROM entry WHERE filepath != '';")
echo
echo "Medien:"
echo " referenziert: $total"
echo " fehlend im Dateisystem: $missing"
if [[ "$missing" -gt 0 ]]; then
echo " -> static/media/ aus der Produktion kopieren, sonst liefern diese Bilder 404."
fi
echo
echo "Fertig."
-146
View File
@@ -1,146 +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. 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 pageMiddleware(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 user 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
}
+112
View File
@@ -0,0 +1,112 @@
# Android-App (Plan, zurückgestellt)
Stand 2026-10-02. Noch nichts davon ist umgesetzt; das hier ist die
Entscheidungsgrundlage für später.
## Anforderungen
- PGP clientseitig: signieren, verifizieren, ver-/entschlüsseln
- private Schlüssel lokal auf dem Gerät speichern
- Push-Benachrichtigungen
- Kamera-Zugriff
## Entscheidung: Capacitor
Der bestehende React-Code wird mit Capacitor als APK gebündelt. Eine native
Kotlin-App ist nicht nötig; nativer Code beschränkt sich auf (fertige oder
kleine eigene) Plugins.
### Verworfen: PWA / Trusted Web Activity
Wäre fast ohne Umbau machbar (Manifest, Service Worker,
`/.well-known/assetlinks.json`, Bubblewrap), scheitert aber am PGP-Ziel:
Bei TWA/PWA liefert der Server den Code bei jedem Start neu aus. Wer den
Server kontrolliert (Angreifer, kompromittiertes PROD, Anordnung), kann
JavaScript nachschieben, das den privaten Schlüssel ausliest. Clientseitige
Kryptographie soll gerade das Vertrauen in den Server überflüssig machen.
Mit Capacitor steckt der Code signiert im APK; Updates laufen über den
App-Signaturschlüssel, nicht über einen PROD-Deploy.
### Verworfen: native App (Kotlin/Compose)
Kompletter Neubau des Frontends ohne Mehrwert für die Anforderungen; passt
außerdem schlecht zum bewusst rohen Design, das in der Web-Version schon
existiert.
## Umbau Frontend
- **API-Basis-URL:** `frontend/src/api.ts` ruft fest `/api…` auf. Das wird
konfigurierbar (z. B. `import.meta.env.VITE_API_BASE`), weil die App unter
`https://localhost` läuft, nicht auf der Domain.
- **Absolute URLs:** Bild-URLs aus der API und geteilte Links
(`/entry/123`) müssen die Domain enthalten.
- **Deep Links:** Android App Links für `https://<domain>/entry/…`.
- **Routing:** `createBrowserRouter` funktioniert unter Capacitor weiter.
## Umbau Backend
- **Bearer-Token:** Der Session-Cookie (`auth.go`, `HttpOnly`,
`SameSite=Lax`) kommt im WebView cross-origin nicht zuverlässig an.
Zweiter Login-Modus: Token im JSON-Body zurückgeben, Client schickt
`Authorization: Bearer …`, `getSession` akzeptiert beides. Die Web-Version
bleibt beim Cookie.
- **CORS:** Middleware für `Access-Control-Allow-Origin` mit den App-Origins
(`https://localhost`, `capacitor://localhost`) inkl. Preflight.
- **`checkOrigin`** (`main.go`): App-Origins zulassen bzw. Requests mit
Bearer-Token ausnehmen. Ohne Cookie gibt es dort kein CSRF-Risiko.
## PGP
- **Bibliothek:** OpenPGP.js im Bundle, kein nativer Code. Kommt mit der
CSP `default-src 'self'` aus.
- **Web-Version:** Derselbe Code kann dort laufen, aber mit der schwächeren
Garantie (servergelieferter Code, siehe oben). Sollte in der UI kenntlich
sein.
- **Schlüsselablage:** Der Android Keystore kann PGP-Schlüssel nicht direkt
halten (keine Curve25519/PGP-Formate, gibt Schlüssel nie heraus). Daher:
1. Im Keystore liegt ein hardwaregestützter AES-Schlüssel, optional an
Biometrie/PIN gebunden.
2. Mit ihm wird der PGP-Private-Key verschlüsselt.
3. Das Ergebnis liegt im App-Speicher.
Dafür gibt es Capacitor-Plugins (Secure Storage, Biometrie); notfalls
ein eigenes Plugin, ca. 100 Zeilen Kotlin. IndexedDB allein ist die
schwächere Alternative (nicht hardwaregebunden, XSS-lesbar).
- **Optional OpenKeychain:** Über dessen OpenPGP-API (Intents) kann die App
Signieren/Entschlüsseln delegieren und hält selbst nie einen Schlüssel.
Eigenes Capacitor-Plugin; nur als Zusatzoption, nicht als einziger Weg.
### Vorab klären (betrifft das Backend)
Signiert der Client Beiträge, muss der Server das abbilden:
- Tabelle mit öffentlichen Schlüsseln pro Account (Upload, Wechsel,
Widerruf)
- Signaturfeld an `entry`
- API liefert Signatur und Schlüssel mit, damit andere Clients
verifizieren können
Kann unabhängig von der App vorbereitet werden.
## Push
- Firebase Cloud Messaging über das Capacitor-Push-Plugin.
- Backend: Tabelle für Device-Tokens pro Account, FCM-Sender.
- Inhalt nur „es gibt etwas Neues“ oder PGP-verschlüsselt, sonst liest
Google mit.
## Kamera
Capacitor-Kamera-Plugin; alternativ reicht `<input type="file" capture>`
ganz ohne Plugin.
## Reihenfolge (Vorschlag)
1. Backend: Bearer-Token, CORS, `checkOrigin`
2. Frontend: API-Basis-URL, absolute URLs
3. Capacitor-Projekt, APK-Build, App Links
4. Kamera
5. Push (FCM, Device-Tokens)
6. PGP: Schlüsseltabelle/Signaturfeld im Backend, OpenPGP.js,
Keystore-gestützte Ablage
7. optional OpenKeychain-Plugin
+118 -224
View File
@@ -1,262 +1,156 @@
# 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, Registrierung und Passwort-Reset 20/min.
--- ---
## Auth ## Auth
### `POST /auth/login` | Methode | Pfad | Auth | Request | Erfolg |
|---------|------|------|---------|--------|
| POST | `/api/auth/login` | – | `user`, `pass`, `timeout` (s, Default 1 Tag, max. 1 Jahr) | `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` |
| POST | `/api/auth/reset/check` | – | `token` | `200 {"username"}`; ungültig/abgelaufen/benutzt: `404 reset.invalid` |
| POST | `/api/auth/reset` | – | `token`, `pass1` (min. 10), `pass2` | `200 {"username"}` + Cookie (eingeloggt); löscht alle alten Sessions, Link verbraucht |
Einloggen. Login-Fehler: `401 auth.bad_credentials` (gleich für unbekannten Nutzer und
falsches Passwort).
**Request (form-encoded):** Reset-Links erzeugt nur der Admin per CLI (`kver reset-link <nutzername>`,
| Feld | Typ | Beschreibung | siehe deploy.md). Sie zeigen auf `/reset#<token>`, gelten 72 h und einmal; ein
|------|-----|--------------| neuer Link macht ältere desselben Nutzers ungültig. Das Token steht im
| `user` | string | Nutzername | Fragment und geht nur im POST-Body an die API, damit es nicht in Logs landet.
| `pass` | string | Passwort |
| `timeout` | int | Session-Dauer in Sekunden |
**Response:**
- `200` + setzt `session`-Cookie → Redirect auf `/`
- `200` + Fehlertext bei falschen Credentials
--- ---
### `GET /auth/login` ## Beiträge
Login-Seite anzeigen (wird in der Go-API nicht benötigt, Frontend übernimmt das). Ein Beitrag (`Entry`):
---
### `POST /auth/newuser`
Neuen Account registrieren.
**Request (form-encoded):**
| Feld | Typ | Beschreibung |
|------|-----|--------------|
| `user` | string | Nutzername (3–32 Zeichen, `[a-zA-Z0-9._\s-]`) |
| `pass1` | string | Passwort (min. 10 Zeichen) |
| `pass2` | string | Passwort-Bestätigung |
**Response:**
- `200` bei Erfolg
- `200` + Fehlerliste bei Validierungsfehler
---
### `POST /auth/logout`
Session beenden und Cookie löschen. POST statt GET: SameSite=Lax schützt
GET-Navigationen nicht, ein fremder Link könnte Nutzer sonst ausloggen (CSRF).
**Response:** `200` `{"status": "ok"}`
---
### `GET /auth/headerbar`
Gibt zurück ob der Nutzer eingeloggt ist (für die UI-Headerleiste).
**Response:** HTML-Partial (in Go-API: JSON)
---
### `GET /auth/sessioninfo`
Infos zur aktuellen Session.
**Auth:** erforderlich
**Response:**
```json ```json
{ { "pid": 123456789, "uid": 42, "created_at": 1700000000, "content": "…",
"uid": 123456, "filepath": "static/media/…", "reply_to": 0, "reply_count": 3,
"created_at": 1700000000, "last_activity": 1700000500, "deleted": 0, "bump_count": 1,
"expires": 1700086400, "last_bump": 1700000400, "username": "max", "avatar": "static/media/…" }
"description": ""
}
``` ```
--- Enthält der Text einen Link, trägt der Beitrag `preview`, sobald der Server
die Vorschau des **ersten** Links geholt hat (asynchron nach dem Anlegen bzw.
Bearbeiten; vorher und ohne verwertbare Vorschau fehlt das Feld). `url` ist
die kanonische Adresse (YouTube-Links aller Formen als `watch?v=<id>`),
`kind` ist `youtube` oder `page`, `site` Kanal- bzw. Seitenname, `thumb` ein
lokales Bild (leer, wenn es keins gibt). Siehe `preview.go`.
## Entries (Beiträge)
### `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 ```json
[ "preview": { "url": "https://www.youtube.com/watch?v=…", "kind": "youtube",
{ "title": "…", "site": "…", "thumb": "static/media/preview/….jpg" }
"pid": 123456789,
"created_at": 1700000000,
"uid": 42,
"content": "...",
"filepath": "static/media/...",
"username": "max"
}
]
``` ```
Leeres Array `[]` wenn keine weiteren Einträge vorhanden.
--- In Hauptfeed und `replies` des Threads trägt jeder Beitrag mit Antworten
zusätzlich `latest`: den jüngsten nicht gelöschten Beitrag aus seinem ganzen
Teilbaum (nach `created_at`, höchstens 50 Ebenen tief). `skipped` zählt die
Beiträge auf dem Pfad dazwischen, gelöschte eingeschlossen (0 = direkte
Antwort). Ohne passenden Nachfahren fehlt das Feld.
### `POST /entry/create`
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
---
### `GET /entry/:pid/votes`
Abstimmungsstand eines Beitrags lesen (read-only).
**Response:**
```json ```json
{ "pid": 123, "left": 4, "right": 2, "selected": "left" } "latest": { "entry": Entry, "skipped": 2 }
```
`selected` ist `none`, wenn nicht eingeloggt oder keine Stimme abgegeben.
---
### `POST /entry/:pid/vote`
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
}
``` ```
--- | Methode | Pfad | Auth | Request | Erfolg |
|---------|------|------|---------|--------|
| GET | `/api/entry/feed/{page}` | – | – | `200 [Entry]` mit `latest`: Threads nach letzter Aktivität, 20 pro Seite, Seite 0–100, `[]` am Ende |
| GET | `/api/entry/{pid}/thread` | – | – | `200 {"entry", "ancestors": [Entry], "replies": [Entry]}`; `ancestors` Root zuerst, `replies` (max. 100) nach letzter Aktivität, mit `latest` |
| GET | `/api/entry/{pid}/votes` | – | – | `200 {"pid","yes","no","selected"}` (`selected`: `yes`/`no`/`none`) |
| POST | `/api/entry/{pid}/report` | – | `reason` (optional, max. 500) | `200 {"status":"reported"}`. Ohne Session anonym (uid 0); Angemeldete legen keine zweite offene Meldung an; gelöschte Beiträge `404`; 10/min pro IP |
| 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` = `yes`/`no` | `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"}` |
### `POST /user/delete` 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`).
Eigenen Account dauerhaft löschen. Beiträge, Votes und Sessions werden TOR-Anfragen auf `create` werden mit `403 tor.blocked` abgewiesen.
mitgelöscht, zugehörige Mediendateien best effort entfernt.
**Auth:** erforderlich
**Request (form-encoded):**
| Feld | Typ | Beschreibung |
|------|-----|--------------|
| `pass1` | string | Passwort zur Bestätigung |
**Response:**
- `200` `{"status": "deleted"}` + löscht den `session`-Cookie
- `403` bei falschem Passwort
--- ---
## Datenbankschema (SQLite) ## Nutzer
```sql | Methode | Pfad | Auth | Request | Erfolg |
CREATE TABLE user ( |---------|------|------|---------|--------|
id INTEGER PRIMARY KEY AUTOINCREMENT, | GET | `/api/u/{username}/info` | – | – | `200 {"uid","username","created_at","avatar"}` |
uid INTEGER UNIQUE, | GET | `/api/u/{username}/feed/{page}` | – | – | `200 [Entry]`: alle Beiträge inkl. Antworten, chronologisch |
username TEXT UNIQUE, | GET | `/api/user/info` | ✓ | – | `200 {"uid","username","created_at","last_login","avatar"}` |
password BLOB, | POST | `/api/user/rename` | ✓ | `user` | `200 {"username"}`; vergeben: `409 user.name_taken` |
created_at INTEGER, | POST | `/api/user/avatar` | ✓ | multipart: `avatar` | `200 {"avatar"}` |
last_login INTEGER | POST | `/api/user/delete` | ✓ | `pass1` | `200 {"status":"deleted"}`, löscht Konto, Beiträge, Votes, Sessions; falsches Passwort: `403 auth.bad_password` |
);
CREATE TABLE session (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uid INTEGER,
value TEXT UNIQUE,
created_at INTEGER,
expires INTEGER,
description TEXT
);
CREATE TABLE entry (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pid INTEGER UNIQUE,
uid INTEGER,
created_at INTEGER,
content TEXT,
filepath TEXT
);
CREATE TABLE vote (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uid INTEGER,
pid INTEGER,
mode TEXT -- 'left' | 'right'
);
```
--- ---
## Offene Punkte ## Statistik
- Kein Rate-Limiting außer dem zufälligen `sleep` beim Login | Methode | Pfad | Erfolg |
- `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 | GET | `/api/stats` | `200 {"users","entries"}` |
| GET | `/api/stats/detail` | `200 {"users","entries","toplevel","replies","votes":{"yes","no"},"impressions_total","visitors_total","entries_per_day_30"}` (Ø Beiträge pro Tag der letzten 30 vollen Tage) |
| GET | `/api/stats/charts` | `200 {"generated","charts":{"impressions","networks","entries","weekday","hour"}}`, je `{"narrow","wide"}` als fertiges SVG (600 bzw. 1200 breit), einmal täglich gerendert |
---
## Seiten (kein JSON)
`/`, `/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`.
+42 -13
View File
@@ -27,8 +27,11 @@ Beim ersten Deployment: DB und Medien einmalig in die Volumes kopieren
podman build -t kver . podman build -t kver .
``` ```
Multi-Stage-Build (`Containerfile`): statisches Go-Binary (CGO aus, modernc- Multi-Stage-Build (`Containerfile`): das React-Frontend wird in einer
SQLite ist pures Go), Laufzeit-Image ist Alpine mit unprivilegiertem Nutzer. 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: Die Warnung `HEALTHCHECK is not supported for OCI image format` ist erwartbar:
Podman baut OCI-Images, dort gibt es kein eingebettetes HEALTHCHECK. Der Podman baut OCI-Images, dort gibt es kein eingebettetes HEALTHCHECK. Der
@@ -46,8 +49,9 @@ podman run -d --name kver \
kver kver
``` ```
- `kver-data` hält die SQLite-DB (`/app/data/kver.db`), `kver-media` die - Die Daten liegen in Postgres (`KVER_DSN`). `kver-data` hält nur noch die
hochgeladenen Bilder. Beides Named Volumes → überleben Image-Updates. GeoLite2-ASN-DB (und die alte SQLite-Datei), `kver-media` die hochgeladenen
Bilder. Beides Named Volumes → überleben Image-Updates.
- Port nur an localhost binden; nach außen geht es über den Reverse-Proxy. - Port nur an localhost binden; nach außen geht es über den Reverse-Proxy.
- Der Container lauscht intern immer auf `:8080`; der externe Port (hier 7777) - Der Container lauscht intern immer auf `:8080`; der externe Port (hier 7777)
ist nur das Host-seitige Mapping. ist nur das Host-seitige Mapping.
@@ -57,8 +61,9 @@ Konfiguration über Umgebungsvariablen:
| Variable | Default | Bedeutung | | Variable | Default | Bedeutung |
|-------------|---------------------|----------------------| |-------------|---------------------|----------------------|
| `KVER_ADDR` | `:8080` | Listen-Adresse im Container | | `KVER_ADDR` | `:8080` | Listen-Adresse im Container |
| `KVER_DB` | `/app/data/kver.db` | Pfad zur SQLite-Datei | | `KVER_DSN` | _(Pflicht)_ | Postgres-Verbindung, z. B. `postgres://kver:pw@host:5432/kontrollverlust?sslmode=disable` |
| `KVER_GEOIP_ASN` | _(leer)_ | Pfad zur GeoLite2-ASN-`.mmdb`; leer = ASN-Auflösung aus | | `KVER_GEOIP_ASN` | _(leer)_ | Pfad zur GeoLite2-ASN-`.mmdb`; leer = ASN-Auflösung aus |
| `KVER_BASE_URL` | _(leer)_ | Öffentliche Adresse für `kver reset-link`; leer = nur Pfad |
## GeoLite2-ASN-Datenbank (optional) ## GeoLite2-ASN-Datenbank (optional)
@@ -77,9 +82,23 @@ Dann `KVER_GEOIP_ASN=/app/data/GeoLite2-ASN.mmdb` setzen. Die Datei wird beim St
einmalig gemappt; für aktualisierte Daten die Datei ersetzen und den Dienst neu einmalig gemappt; für aktualisierte Daten die Datei ersetzen und den Dienst neu
starten. starten.
## Bestehende Daten übernehmen ## Postgres
DB und Medien einmalig in die Volumes kopieren (Volumes müssen existieren; Die App erwartet eine Datenbank, die ihrem Login-User gehört. Einmalig als
Admin:
```sql
CREATE ROLE kver LOGIN PASSWORD '...';
CREATE DATABASE kontrollverlust OWNER kver;
REVOKE CONNECT, TEMPORARY ON DATABASE kontrollverlust FROM PUBLIC;
```
Tabellen und Indizes legt die App beim Start selbst an (`CREATE ... IF NOT
EXISTS`, als `kver` → gehören `kver`). Nicht als Admin anlegen.
## Bestehende Medien übernehmen
Medien einmalig ins Volume kopieren (Volumes müssen existieren;
Container kann laufen oder gestoppt sein): Container kann laufen oder gestoppt sein):
```sh ```sh
@@ -88,14 +107,11 @@ podman volume create kver-media
# Rootless: podman unshare sorgt dafür, dass die UID ins User-Namespace- # Rootless: podman unshare sorgt dafür, dass die UID ins User-Namespace-
# Mapping passt — ohne das bekommt der Container die Dateien nicht zu lesen. # Mapping passt — ohne das bekommt der Container die Dateien nicht zu lesen.
podman unshare cp kver.db \
"$(podman volume inspect kver-data --format '{{.Mountpoint}}')/"
podman unshare cp -r static/media/. \ podman unshare cp -r static/media/. \
"$(podman volume inspect kver-media --format '{{.Mountpoint}}')/" "$(podman volume inspect kver-media --format '{{.Mountpoint}}')/"
``` ```
Danach Container (neu) starten. Die App legt beim ersten Start alle fehlenden Danach Container (neu) starten.
Tabellen und Indizes automatisch per `CREATE ... IF NOT EXISTS` an.
## Reverse-Proxy (TLS) — Caddy ## Reverse-Proxy (TLS) — Caddy
@@ -167,7 +183,7 @@ Volume=kver-media:/app/static/media
Environment=KVER_GEOIP_ASN=/app/data/GeoLite2-ASN.mmdb Environment=KVER_GEOIP_ASN=/app/data/GeoLite2-ASN.mmdb
# Healthcheck hier statt im Containerfile: Podman baut OCI-Images, # Healthcheck hier statt im Containerfile: Podman baut OCI-Images,
# die kennen kein eingebettetes HEALTHCHECK. # die kennen kein eingebettetes HEALTHCHECK.
HealthCmd=wget -qO /dev/null http://localhost:8080/entry/feed/0 HealthCmd=wget -qO /dev/null http://localhost:8080/api/stats
HealthInterval=30s HealthInterval=30s
[Service] [Service]
@@ -201,4 +217,17 @@ systemctl --user restart kver
``` ```
Die App fährt bei SIGTERM sauber herunter (laufende Requests werden zu Ende Die App fährt bei SIGTERM sauber herunter (laufende Requests werden zu Ende
bedient, WAL-Checkpoint beim DB-Close). bedient, dann wird die DB-Verbindung geschlossen).
## Passwort zurücksetzen
Ohne E-Mail-System erzeugt der Admin einen Einmal-Link und gibt ihn dem
Nutzer persönlich:
```sh
podman exec kver kver reset-link <nutzername>
```
Der Link (`/reset#<token>`) gilt 72 Stunden und nur einmal; ein neuer Link
macht ältere desselben Nutzers ungültig. Beim Setzen des neuen Passworts
werden alle bestehenden Sessions des Nutzers abgemeldet.
+58
View File
@@ -0,0 +1,58 @@
# 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 .`.
Die Rechtstexte (`frontend/static/docs/*.html`) sind reine HTML-Seiten ohne JS,
aber weitere Einstiege des Builds (`vite.config.ts`). So bekommen sie dasselbe
gebaute `app.css`; ausgeliefert unter `/static/docs/…` (`serveDoc`).
### DEV-Container
`./dev-up.sh` baut das Image aus dem Arbeitsverzeichnis und startet den
Container `kver-dev` auf http://127.0.0.1:8081 neu (DSN aus `dev.env`, Daten
und Medien read-only aus `prod_backup/`). Mit `git config core.hooksPath
githooks` läuft das nach jedem Commit automatisch im Hintergrund (Log:
`.git/dev-up.log`); einmalig aussetzen mit `KVER_NO_DEV_UP=1 git commit …`.
## 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.
-120
View File
@@ -1,120 +0,0 @@
# Datenbank-Migrationen
Manuelle Schritte, die beim Deploy gegen eine **bestehende** Datenbank nötig sind.
Eine frische DB (Schema aus `db.go`) braucht keine davon.
---
## `avatar`-Spalte in `user`
**Wann:** beim Deploy der Profilbild-Version gegen eine bestehende `kver.db`.
**Hintergrund:** Profilbilder werden als Pfad (`static/media/...`) in der neuen Spalte
`user.avatar` gespeichert (leerer String = kein Bild, Frontend zeigt dann das
Platzhalterbild). `CREATE TABLE IF NOT EXISTS` ergänzt die Spalte bei einer
vorhandenen Tabelle nicht.
**Migration:**
```sql
ALTER TABLE user ADD COLUMN avatar TEXT NOT NULL DEFAULT '';
```
Bestehende Nutzer haben damit kein Bild (`''`). Nur einmalig ausführen.
---
## `filepath` NULL → leerer String
**Wann:** beim Umstieg auf die Go-Version, falls `entry.filepath` NULL-Werte enthält.
**Hintergrund:** `scanEntries` scannt `filepath` in ein Go-`string`-Feld (kein Pointer).
SQLite NULL in ein `string` scannen schlägt fehl; `scanEntries` überspringt diese Zeilen
still mit `continue`. Ergebnis: Einträge ohne Bild fehlen im Feed komplett.
Das aktuelle Go-Schema definiert `filepath TEXT NOT NULL DEFAULT ''`. `migrate.sh`
normalisiert NULL → `''` beim Kopieren aus der Quelldatenbank.
```sql
UPDATE entry SET filepath = '' WHERE filepath IS NULL;
```
---
## `filepath`-Sentinel `"none"` → leerer String
**Wann:** beim Umstieg auf die Go-Version, falls eine alte `kver.db` aus der
Python-Zeit weiterverwendet wird.
**Hintergrund:** Die Python-Version schrieb für Beiträge ohne Bild den
String `"none"` in `entry.filepath`. Die Go-Version nutzt stattdessen den
leeren String (Go-Zero-Value) und prüft nur noch auf `filepath == ""`.
Alte `"none"`-Zeilen würden sonst im Feed als Bildpfad interpretiert
(`<img src="/none">` → kaputtes Bild).
**Migration:**
```sql
UPDATE entry SET filepath = '' WHERE filepath = 'none';
```
Idempotent — kann gefahrlos mehrfach laufen.
---
## Threading-Spalten in `entry`
**Wann:** beim Deploy der Threading-Version gegen eine bestehende `kver.db`, die
die Tabelle `entry` noch ohne die Threading-Spalten hat.
**Hintergrund:** Das Threading führt `reply_to` (pid des Eltern-Posts, `0` = Root),
`reply_count` (Anzahl Antworten im gesamten Teilbaum) und `last_activity`
(Sortierschlüssel des Hauptfeeds) ein. `CREATE TABLE IF NOT EXISTS` ergänzt diese
Spalten bei einer vorhandenen Tabelle nicht.
**Migration:**
```sql
ALTER TABLE entry ADD COLUMN reply_to INTEGER NOT NULL DEFAULT 0;
ALTER TABLE entry ADD COLUMN reply_count INTEGER NOT NULL DEFAULT 0;
ALTER TABLE entry ADD COLUMN last_activity INTEGER;
UPDATE entry SET last_activity = created_at WHERE last_activity IS NULL;
```
Bestehende Zeilen werden so zu Root-Beiträgen (`reply_to = 0`, `reply_count = 0`)
mit `last_activity = created_at`. Nicht idempotent — die `ALTER TABLE`-Zeilen nur
einmalig ausführen.
---
## Soft-Delete-Spalte in `entry`
**Wann:** beim Deploy der Soft-Delete-Version gegen eine bestehende `kver.db`.
**Hintergrund:** Statt Beiträge hart zu löschen werden sie als `[deleted]`-Platzhalter
markiert (`deleted = 1`, Inhalt/Bild/Autor geleert), damit Antworten nicht verwaisen.
Das Frontend und die Feeds erkennen gelöschte Beiträge an dieser Spalte.
**Migration:**
```sql
ALTER TABLE entry ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0;
```
Bestehende Beiträge bleiben damit sichtbar (`deleted = 0`). Nur einmalig ausführen.
---
## `asn`-Spalte in `impression`
**Wann:** beim Deploy der ASN-Auswertung gegen eine bestehende `impression`-Tabelle.
**Hintergrund:** Pro Impression wird das autonome System (Netzbetreiber) gespeichert,
aufgelöst aus der vollen Client-IP *vor* dem Maskieren (siehe `lookupASN`). Die Spalte
liegt bewusst **nicht** im Primärschlüssel — pro `anon_ip` ist das ASN deterministisch,
also entstehen keine Zeilen-Duplikate, und ein simples `ADD COLUMN` genügt (kein
Tabellen-Rebuild). `CREATE TABLE IF NOT EXISTS` ergänzt die Spalte bei einer
vorhandenen Tabelle nicht.
**Migration:**
```sql
ALTER TABLE impression ADD COLUMN asn TEXT NOT NULL DEFAULT '';
```
Bestehende Zeilen haben damit kein ASN (`''`, in der Auswertung „(unbekannt)"). Nur
einmalig ausführen.
+5 -5
View File
@@ -1,8 +1,7 @@
# TODO # TODO
- Moderation: handled setzen - Moderation (Melden, Queue, Abhilfe) als eigenständiges Projekt
- Moderation: Löschen aus Queue - Passwort ändern im Profil; Reset per E-Mail (bisher nur Admin-Link, `kver reset-link`)
- Passwort-Reset
- Suche - Suche
- Hashtags / Erwähnungen - Hashtags / Erwähnungen
- Block / Mute - Block / Mute
@@ -10,6 +9,7 @@
- Direct Messages - Direct Messages
- Multi-Account-Management - Multi-Account-Management
- RSS / Atom Feed - RSS / Atom Feed
- Feed ohne JS - Feed ohne JS (früherer SSR-Stand: Tag `ssr-archiv`)
- robots.txt / Sitemap - robots.txt / Sitemap
- Links/Rechts Auswertung - Ja/Nein Auswertung
- Android-App mit clientseitigem PGP, Push, Kamera (Plan: `notes/android.md`)
+567
View File
@@ -0,0 +1,567 @@
package main
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"image"
"image/jpeg"
"io"
"log"
"mime"
"net"
"net/http"
"net/netip"
"net/url"
"os"
"regexp"
"strings"
"syscall"
"time"
"golang.org/x/image/webp"
"golang.org/x/net/html"
"golang.org/x/net/html/charset"
xdraw "golang.org/x/image/draw"
)
// Linkvorschau: Zum ersten Link eines Beitrags holt der Server Titel, Seiten-
// bzw. Kanalname und Vorschaubild und speichert das Bild lokal. Der Browser
// lädt die Vorschau damit ausschließlich von uns -- Besucher-IPs gehen nie an
// die verlinkte Seite (kein Einwilligungsbedarf, CSP bleibt 'self').
//
// Ablauf: Beim Anlegen/Bearbeiten wird der erste Link kanonisiert (previewKey)
// und als entry.link_url gespeichert. requestPreview legt dazu eine Zeile in
// link_preview an und reicht sie an den Worker; der holt die Daten im
// Hintergrund (das Posten wartet nie). Mehrere Beiträge mit demselben Link
// teilen sich eine Zeile und ein Bild. entrySelect joint die fertige Vorschau
// an jeden Beitrag.
//
// Provider: YouTube (oEmbed, liefert auch ohne Cookie-Banner Titel und Kanal)
// und allgemein OpenGraph/<title> für alle übrigen Seiten. Weitere Spezialfälle
// kommen als eigener Zweig in previewKey/fetchPreview dazu.
// Preview ist die fertige Vorschau eines Links, wie die API sie ausliefert.
type Preview struct {
URL string `json:"url"` // Linkziel der Vorschau (kanonisch)
Kind string `json:"kind"` // "youtube" | "page"
Title string `json:"title"` // Video- bzw. Seitentitel
Site string `json:"site"` // Kanal- bzw. Seitenname
Thumb string `json:"thumb"` // lokales Bild unter static/media/preview, ggf. ""
}
const (
previewRefreshOK = 30 * 24 * 60 * 60 // erfolgreiche Vorschau nach 30 Tagen auffrischen
previewRetryFailed = 24 * 60 * 60 // fehlgeschlagene frühestens nach einem Tag erneut
maxPreviewURL = 2048
maxPageBytes = 1 << 20 // gelesener HTML-Anfang
maxThumbBytes = 8 << 20
thumbSize = 320 // Zielkante des gespeicherten Vorschaubilds (klein, steht neben dem Text)
previewDir = "static/media/preview"
)
// linkPattern findet Links wie linkify im Frontend (format.tsx), damit die
// Vorschau exakt zum ersten dort verlinkten Text gehört. \p{Z} und U+FEFF
// gleichen Gos ASCII-\s an das Unicode-\s von JavaScript an.
var linkPattern = regexp.MustCompile(`https?://[^\s\p{Z}\x{feff}<]+`)
var youtubeIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{11}$`)
// previewKey liefert die kanonische URL des ersten Links in content oder "".
// YouTube-Links aller Formen werden auf watch?v=<id> gebracht, damit dasselbe
// Video nur einmal geholt wird.
func previewKey(content string) string {
raw := linkPattern.FindString(content)
if raw == "" || len(raw) > maxPreviewURL {
return ""
}
u, err := url.Parse(raw)
if err != nil || u.Hostname() == "" {
return ""
}
if id := youtubeID(u); id != "" {
return "https://www.youtube.com/watch?v=" + id
}
return raw
}
// youtubeID erkennt die Video-ID in watch?v=, youtu.be/, /shorts/, /live/,
// /embed/ und /v/ auf youtube.com, m./music./www.-Varianten und
// youtube-nocookie.com. "" für alles andere (Kanäle, Playlists, ...).
func youtubeID(u *url.URL) string {
host := strings.ToLower(u.Hostname())
for _, p := range []string{"www.", "m.", "music."} {
host = strings.TrimPrefix(host, p)
}
seg := strings.Split(strings.Trim(u.Path, "/"), "/")
var id string
switch host {
case "youtu.be":
id = seg[0]
case "youtube.com", "youtube-nocookie.com":
switch {
case u.Path == "/watch":
id = u.Query().Get("v")
case len(seg) >= 2 && (seg[0] == "shorts" || seg[0] == "live" || seg[0] == "embed" || seg[0] == "v"):
id = seg[1]
}
}
if !youtubeIDPattern.MatchString(id) {
return ""
}
return id
}
// requestPreview sorgt dafür, dass zu key eine Vorschau existiert: neue Links
// werden angelegt, veraltete oder vor über einem Tag gescheiterte erneut
// angestoßen. Nur dann geht der Key an den Worker. Ist dessen Puffer voll,
// bleibt die Zeile auf pending und wird beim nächsten Start nachgeholt.
func requestPreview(key string) {
if key == "" {
return
}
now := time.Now().Unix()
res, err := db.Exec(
`INSERT INTO link_preview (url) VALUES ($1)
ON CONFLICT (url) DO UPDATE SET status = 'pending'
WHERE (link_preview.status = 'ok' AND link_preview.fetched_at < $2)
OR (link_preview.status = 'none' AND link_preview.fetched_at < $3)`,
key, now-previewRefreshOK, now-previewRetryFailed)
if err != nil {
log.Printf("linkvorschau %s: %v", key, err)
return
}
if n, _ := res.RowsAffected(); n == 0 {
return
}
select {
case previewCh <- key:
default:
}
}
// Ein einzelner Worker holt die Vorschauen nacheinander, wie beim Impression-
// Worker deckelt das die ausgehenden Requests (Link-Spam erzeugt keine Flut).
// Beim Shutdown bricht previewCtx einen laufenden Abruf ab; was noch im Puffer
// liegt, bleibt pending und wird beim nächsten Start nachgeholt.
var (
previewCh = make(chan string, 64)
previewDone = make(chan struct{})
previewCtx, previewShutdown = context.WithCancel(context.Background())
)
// startPreviewWorker startet den Worker (nach initDB) und holt zuerst die
// liegengebliebenen pending-Zeilen nach.
func startPreviewWorker() {
go func() {
defer close(previewDone)
for _, key := range pendingPreviews(64) {
fetchPreview(previewCtx, key)
}
for key := range previewCh {
fetchPreview(previewCtx, key)
}
}()
}
// stopPreviewWorker bricht laufende Abrufe ab und wartet auf den Worker (vor
// db.Close rufen).
func stopPreviewWorker() {
previewShutdown()
close(previewCh)
<-previewDone
}
func pendingPreviews(limit int) []string {
rows, err := db.Query(`SELECT url FROM link_preview WHERE status = 'pending' LIMIT $1`, limit)
if err != nil {
log.Printf("linkvorschau: %v", err)
return nil
}
defer rows.Close()
var keys []string
for rows.Next() {
var k string
if rows.Scan(&k) == nil {
keys = append(keys, k)
}
}
return keys
}
// fetchPreview holt die Vorschau zu key und speichert das Ergebnis: status ok
// mit Daten oder none (Seite ohne verwertbaren Titel, Video privat/gelöscht,
// Fehler). Bei abgebrochenem ctx (Shutdown) bleibt die Zeile pending.
func fetchPreview(ctx context.Context, key string) {
if ctx.Err() != nil {
return
}
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
var p Preview
var imgs []string // Bildkandidaten, der erste ladbare gewinnt
var err error
if u, _ := url.Parse(key); u != nil && youtubeID(u) != "" {
p, imgs, err = fetchYouTube(ctx, youtubeID(u))
} else {
p, imgs, err = fetchPage(ctx, key)
}
if errors.Is(err, context.Canceled) && previewCtx.Err() != nil {
return
}
now := time.Now().Unix()
thumb := thumbPath(key)
if err != nil {
log.Printf("linkvorschau %s: %v", key, err)
os.Remove(thumb)
db.Exec(`UPDATE link_preview SET status = 'none', title = '', site = '', thumb = '', fetched_at = $2 WHERE url = $1`, key, now)
return
}
stored := false
for _, img := range imgs {
if err := storeThumb(ctx, img, thumb); err != nil {
log.Printf("linkvorschau %s: bild %s: %v", key, img, err)
continue
}
stored = true
break
}
if !stored {
os.Remove(thumb)
thumb = ""
}
if previewCtx.Err() != nil {
return // Shutdown mitten im Bildabruf: pending lassen, nicht ohne Bild speichern
}
db.Exec(
`UPDATE link_preview SET status = 'ok', kind = $2, title = $3, site = $4, thumb = $5, fetched_at = $6 WHERE url = $1`,
key, p.Kind, truncateRunes(p.Title, 300), truncateRunes(p.Site, 100), thumb, now)
}
// thumbPath leitet den Bildnamen aus dem Key ab: deterministisch (Auffrischen
// überschreibt dasselbe Bild) und ohne Nutzereingabe im Dateinamen.
func thumbPath(key string) string {
sum := sha256.Sum256([]byte(key))
return previewDir + "/" + hex.EncodeToString(sum[:10]) + ".jpg"
}
// YouTube-Endpunkte; Variablen, damit Tests sie auf einen lokalen Server lenken.
// mqdefault (320x180) gibt es für jedes Video, in 16:9 ohne schwarze Balken
// und genau in der Größe, in der wir es zeigen.
var (
youtubeOEmbed = "https://www.youtube.com/oembed"
youtubeThumbs = []string{"https://i.ytimg.com/vi/%s/mqdefault.jpg"}
)
// fetchYouTube fragt den öffentlichen oEmbed-Endpunkt (ohne API-Key). Das
// Bild kommt von der festen Thumbnail-Adresse statt aus thumbnail_url, so
// bestimmt allein die geprüfte ID, welche Hosts wir ansprechen.
func fetchYouTube(ctx context.Context, id string) (Preview, []string, error) {
watch := "https://www.youtube.com/watch?v=" + id
resp, err := previewGet(ctx, youtubeOEmbed+"?format=json&url="+url.QueryEscape(watch), "application/json")
if err != nil {
return Preview{}, nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
// 401/403: privat oder nicht einbettbar, 404: gelöscht.
return Preview{}, nil, fmt.Errorf("oembed: status %d", resp.StatusCode)
}
var o struct {
Title string `json:"title"`
AuthorName string `json:"author_name"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 64<<10)).Decode(&o); err != nil {
return Preview{}, nil, fmt.Errorf("oembed: %w", err)
}
if o.Title == "" {
return Preview{}, nil, errors.New("oembed: kein Titel")
}
imgs := make([]string, len(youtubeThumbs))
for i, f := range youtubeThumbs {
imgs[i] = fmt.Sprintf(f, id)
}
return Preview{URL: watch, Kind: "youtube", Title: o.Title, Site: o.AuthorName}, imgs, nil
}
// fetchPage liest den Kopf einer HTML-Seite und nimmt OpenGraph- bzw.
// Twitter-Angaben, ersatzweise <title>. Ohne Titel gibt es keine Vorschau.
func fetchPage(ctx context.Context, pageURL string) (Preview, []string, error) {
resp, err := previewGet(ctx, pageURL, "text/html,application/xhtml+xml")
if err != nil {
return Preview{}, nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return Preview{}, nil, fmt.Errorf("status %d", resp.StatusCode)
}
ct := resp.Header.Get("Content-Type")
if mt, _, _ := mime.ParseMediaType(ct); mt != "text/html" && mt != "application/xhtml+xml" {
return Preview{}, nil, fmt.Errorf("kein HTML: %q", ct)
}
body, err := charset.NewReader(io.LimitReader(resp.Body, maxPageBytes), ct)
if err != nil {
return Preview{}, nil, err
}
m := parsePageMeta(body)
title := firstNonEmpty(m["og:title"], m["twitter:title"], m["title"])
if title == "" {
return Preview{}, nil, errors.New("kein Titel")
}
// Nach Redirects gilt die endgültige Adresse als Basis für relative Bild-URLs.
base := resp.Request.URL
site := firstNonEmpty(m["og:site_name"], strings.TrimPrefix(base.Hostname(), "www."))
var imgs []string
if ref := firstNonEmpty(m["og:image:secure_url"], m["og:image"], m["og:image:url"], m["twitter:image"], m["twitter:image:src"]); ref != "" {
if u, err := base.Parse(ref); err == nil && (u.Scheme == "http" || u.Scheme == "https") {
imgs = []string{u.String()}
}
}
return Preview{URL: pageURL, Kind: "page", Title: title, Site: site}, imgs, nil
}
// parsePageMeta sammelt <meta property|name=... content=...> und den
// <title>-Text aus dem Dokumentkopf. Kleingeschriebene Schlüssel, jeweils der
// erste Treffer zählt; Schluss beim <body> bzw. </head>.
func parsePageMeta(r io.Reader) map[string]string {
m := map[string]string{}
set := func(k, v string) {
v = strings.Join(strings.Fields(strings.ToValidUTF8(v, "")), " ")
if _, ok := m[k]; !ok && v != "" {
m[k] = v
}
}
z := html.NewTokenizer(r)
inTitle := false
for {
switch z.Next() {
case html.ErrorToken:
return m
case html.StartTagToken, html.SelfClosingTagToken:
t := z.Token()
switch t.Data {
case "body":
return m
case "title":
inTitle = true
case "meta":
var key, content string
for _, a := range t.Attr {
switch a.Key {
case "property", "name":
if key == "" {
key = strings.ToLower(a.Val)
}
case "content":
content = a.Val
}
}
if key != "" && key != "title" {
set(key, content)
}
}
case html.TextToken:
if inTitle {
set("title", string(z.Text()))
}
case html.EndTagToken:
switch z.Token().Data {
case "title":
inTitle = false
case "head":
return m
}
}
}
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}
// storeThumb lädt ein Bild, verkleinert es auf thumbSize und speichert es als
// JPEG unter name. Das Re-Encode verwirft Metadaten und alles, was kein Bild
// ist; animierte GIFs werden zum ersten Frame.
func storeThumb(ctx context.Context, imgURL, name string) error {
resp, err := previewGet(ctx, imgURL, "image/*")
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("status %d", resp.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, maxThumbBytes+1))
if err != nil {
return err
}
if len(data) > maxThumbBytes {
return errors.New("bild zu groß")
}
// WebP gezielt dekodieren statt global zu registrieren -- sonst nähme
// auch der Upload (storeImage) plötzlich WebP an.
decodeConfig, decode := image.DecodeConfig, func(r io.Reader) (image.Image, error) {
img, _, err := image.Decode(r)
return img, err
}
if len(data) >= 12 && string(data[:4]) == "RIFF" && string(data[8:12]) == "WEBP" {
decodeConfig = func(r io.Reader) (image.Config, string, error) {
c, err := webp.DecodeConfig(r)
return c, "webp", err
}
decode = webp.Decode
}
cfg, _, err := decodeConfig(bytes.NewReader(data))
if err != nil {
return err
}
if cfg.Width <= 0 || cfg.Height <= 0 || cfg.Width*cfg.Height > maxImagePixels {
return fmt.Errorf("bild zu groß: %dx%d", cfg.Width, cfg.Height)
}
img, err := decode(bytes.NewReader(data))
if err != nil {
return err
}
b := img.Bounds()
nw, nh := b.Dx(), b.Dy()
if nw > thumbSize || nh > thumbSize {
if nw > nh {
nw, nh = thumbSize, max(nh*thumbSize/nw, 1)
} else {
nw, nh = max(nw*thumbSize/nh, 1), thumbSize
}
}
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
// Weißer Grund für transparente PNG/WebP, JPEG kennt keinen Alphakanal.
xdraw.Draw(dst, dst.Bounds(), image.White, image.Point{}, xdraw.Src)
xdraw.CatmullRom.Scale(dst, dst.Bounds(), img, b, xdraw.Over, nil)
if err := os.MkdirAll(previewDir, 0o755); err != nil {
return err
}
// Erst in eine Temp-Datei, dann umbenennen: ein Auffrischen ersetzt das
// alte Bild atomar, Besucher sehen nie ein halb geschriebenes.
tmp, err := os.CreateTemp(previewDir, ".tmp-*")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
if err := jpeg.Encode(tmp, dst, &jpeg.Options{Quality: 80}); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Chmod(tmp.Name(), 0o644); err != nil {
return err
}
return os.Rename(tmp.Name(), name)
}
// previewGet ist ein GET über previewClient mit ehrlichem User-Agent.
func previewGet(ctx context.Context, target, accept string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "kver-linkvorschau/1.0")
req.Header.Set("Accept", accept)
req.Header.Set("Accept-Language", "de,en;q=0.5")
return previewClient.Do(req)
}
// previewClient ruft beliebige, von Nutzern gepostete URLs ab und muss daher
// gegen SSRF gehärtet sein: Wer "http://postgres:5432/" oder eine Adresse im
// internen Netz postet, darf unseren Server nicht dorthin schicken. Geprüft
// wird in Dialer.Control, also NACH der DNS-Auflösung auf der tatsächlich
// angewählten IP -- das deckt auch DNS-Rebinding und jeden Redirect-Schritt ab.
// Kein Proxy aus der Umgebung (der würde die Prüfung umgehen).
var previewClient = &http.Client{
Timeout: 20 * time.Second,
Transport: &http.Transport{
Proxy: nil,
DialContext: (&net.Dialer{
Timeout: 5 * time.Second,
Control: func(network, address string, _ syscall.RawConn) error {
return previewDialGuard(address)
},
}).DialContext,
TLSHandshakeTimeout: 5 * time.Second,
ResponseHeaderTimeout: 10 * time.Second,
MaxIdleConns: 4,
IdleConnTimeout: 30 * time.Second,
},
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return errors.New("zu viele Weiterleitungen")
}
if req.URL.Scheme != "http" && req.URL.Scheme != "https" {
return fmt.Errorf("weiterleitung auf %q", req.URL.Scheme)
}
return nil
},
}
// previewDialGuard lässt nur öffentliche Adressen auf Port 80/443 zu.
// Variable, damit Tests gegen einen lokalen httptest-Server laufen können.
var previewDialGuard = func(address string) error {
host, port, err := net.SplitHostPort(address)
if err != nil {
return err
}
if port != "80" && port != "443" {
return fmt.Errorf("port %s nicht erlaubt", port)
}
ip, err := netip.ParseAddr(host)
if err != nil {
return err
}
if !isPublicIP(ip) {
return fmt.Errorf("adresse %s nicht öffentlich", ip)
}
return nil
}
// Nicht-öffentliche Bereiche, die IsGlobalUnicast/IsPrivate nicht abdecken.
var reservedNets = []netip.Prefix{
netip.MustParsePrefix("0.0.0.0/8"), // "dieses Netz"
netip.MustParsePrefix("100.64.0.0/10"), // Carrier-Grade-NAT
netip.MustParsePrefix("192.0.0.0/24"), // IETF-Protokollzuweisungen
netip.MustParsePrefix("192.0.2.0/24"), // Dokumentation
netip.MustParsePrefix("198.18.0.0/15"), // Benchmarking
netip.MustParsePrefix("198.51.100.0/24"), // Dokumentation
netip.MustParsePrefix("203.0.113.0/24"), // Dokumentation
netip.MustParsePrefix("240.0.0.0/4"), // reserviert, inkl. Broadcast
netip.MustParsePrefix("64:ff9b::/96"), // NAT64: bildet IPv4 inkl. interner Netze ab
netip.MustParsePrefix("64:ff9b:1::/48"), // lokales NAT64
netip.MustParsePrefix("2001:db8::/32"), // Dokumentation
netip.MustParsePrefix("2002::/16"), // 6to4: kann interne IPv4 einbetten
}
func isPublicIP(ip netip.Addr) bool {
ip = ip.Unmap()
if !ip.IsGlobalUnicast() || ip.IsPrivate() {
return false
}
for _, p := range reservedNets {
if p.Contains(ip) {
return false
}
}
return true
}
+273
View File
@@ -0,0 +1,273 @@
package main
import (
"context"
"encoding/json"
"image"
"image/png"
"net/http"
"net/http/httptest"
"net/netip"
"net/url"
"os"
"strings"
"testing"
)
func TestPreviewKey(t *testing.T) {
yt := "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
cases := map[string]string{
"guck mal https://youtu.be/dQw4w9WgXcQ?t=42": yt,
"https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=x": yt,
"https://m.youtube.com/watch?feature=share&v=dQw4w9WgXcQ": yt,
"https://youtube.com/shorts/dQw4w9WgXcQ": yt,
"https://music.youtube.com/watch?v=dQw4w9WgXcQ": yt,
"https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ": yt,
"https://www.youtube.com/@kanal": "https://www.youtube.com/@kanal",
"https://www.youtube.com/watch?v=kurz": "https://www.youtube.com/watch?v=kurz",
"erst https://example.org/a dann https://youtu.be/dQw4w9WgXcQ": "https://example.org/a",
"ohne Link": "",
"ftp://example.org": "",
"https://example.org/x danach": "https://example.org/x",
"https://example.org/<b>": "https://example.org/",
}
for in, want := range cases {
if got := previewKey(in); got != want {
t.Errorf("previewKey(%q) = %q, erwartet %q", in, got, want)
}
}
if got := previewKey("https://example.org/" + strings.Repeat("a", maxPreviewURL)); got != "" {
t.Errorf("überlange URL ergab %q", got)
}
}
func TestIsPublicIP(t *testing.T) {
cases := map[string]bool{
"93.184.216.34": true,
"2a00:1450:4001::200e": true,
"127.0.0.1": false,
"10.1.2.3": false,
"172.16.0.1": false,
"192.168.161.4": false,
"169.254.169.254": false, // Cloud-Metadaten
"100.64.0.1": false,
"0.1.2.3": false,
"0.0.0.0": false,
"255.255.255.255": false,
"::1": false,
"fd00::1": false,
"fe80::1": false,
"::ffff:127.0.0.1": false,
"::ffff:93.184.216.34": true,
"64:ff9b::a00:1": false,
"2002:c0a8:101::1": false,
"224.0.0.1": false,
}
for in, want := range cases {
if got := isPublicIP(netip.MustParseAddr(in)); got != want {
t.Errorf("isPublicIP(%s) = %v, erwartet %v", in, got, want)
}
}
}
// Der echte Wächter muss interne Ziele abweisen, ohne dass ein Request das
// Netz verlässt -- auch nach einer Weiterleitung.
func TestPreviewClientBlocksInternal(t *testing.T) {
internal := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("interner Server wurde erreicht")
}))
defer internal.Close()
if _, err := previewGet(context.Background(), internal.URL, "text/html"); err == nil {
t.Fatal("Abruf von 127.0.0.1 wurde nicht blockiert")
}
if err := previewDialGuard("8.8.8.8:5432"); err == nil {
t.Error("Port 5432 wurde nicht blockiert")
}
}
func TestParsePageMeta(t *testing.T) {
doc := `<!doctype html><html><head>
<title> Seiten
titel </title>
<meta property="og:title" content="OG-Titel">
<meta property="og:title" content="zweiter OG-Titel">
<meta name="twitter:image" content="/bild.png">
<meta property="OG:Site_Name" content="Beispiel">
</head><body><meta property="og:image" content="/zu-spaet.png"></body></html>`
m := parsePageMeta(strings.NewReader(doc))
want := map[string]string{
"title": "Seiten titel",
"og:title": "OG-Titel",
"twitter:image": "/bild.png",
"og:site_name": "Beispiel",
}
for k, v := range want {
if m[k] != v {
t.Errorf("%s = %q, erwartet %q", k, m[k], v)
}
}
if _, ok := m["og:image"]; ok {
t.Error("Meta aus dem body wurde übernommen")
}
}
// fakeRemote stellt eine Seite mit og-Tags, ein Bild und einen oEmbed-
// Endpunkt bereit und lenkt die Vorschau-Abrufe für die Testdauer dorthin.
func fakeRemote(t *testing.T) *httptest.Server {
t.Helper()
var buf strings.Builder
img := image.NewRGBA(image.Rect(0, 0, 1280, 720))
png.Encode(&buf, img)
pngData := buf.String()
mux := http.NewServeMux()
mux.HandleFunc("/seite", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(`<html><head><meta property="og:title" content="Ein Artikel">
<meta property="og:image" content="/bild.png"></head><body></body></html>`))
})
mux.HandleFunc("/umleitung", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/seite", http.StatusFound)
})
mux.HandleFunc("/ohne-titel", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(`<html><head></head><body>nix</body></html>`))
})
mux.HandleFunc("/bild.png", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
w.Write([]byte(pngData))
})
mux.HandleFunc("/oembed", func(w http.ResponseWriter, r *http.Request) {
u, _ := url.Parse(r.URL.Query().Get("url"))
if u == nil || u.Query().Get("v") != "dQw4w9WgXcQ" {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
json.NewEncoder(w).Encode(map[string]string{"title": "Ein Video", "author_name": "Ein Kanal"})
})
mux.HandleFunc("/vi/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
w.Write([]byte(pngData))
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
oldGuard, oldOEmbed, oldThumbs := previewDialGuard, youtubeOEmbed, youtubeThumbs
previewDialGuard = func(string) error { return nil }
youtubeOEmbed = srv.URL + "/oembed"
// Erster Kandidat fehlt -> der nächste greift.
youtubeThumbs = []string{srv.URL + "/fehlt/%s.jpg", srv.URL + "/vi/%s/hqdefault.jpg"}
t.Cleanup(func() {
previewDialGuard, youtubeOEmbed, youtubeThumbs = oldGuard, oldOEmbed, oldThumbs
})
// Bilder landen relativ zum CWD unter static/media/preview.
dir := t.TempDir()
wd, _ := os.Getwd()
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.Chdir(wd) })
return srv
}
func TestFetchPage(t *testing.T) {
remote := fakeRemote(t)
p, img, err := fetchPage(context.Background(), remote.URL+"/umleitung")
if err != nil {
t.Fatal(err)
}
if p.Title != "Ein Artikel" || p.Site != "127.0.0.1" || p.URL != remote.URL+"/umleitung" {
t.Errorf("unerwartete Vorschau %+v", p)
}
// Relatives Bild gegen die Adresse NACH der Weiterleitung aufgelöst.
if len(img) != 1 || img[0] != remote.URL+"/bild.png" {
t.Errorf("bild = %q", img)
}
if _, _, err := fetchPage(context.Background(), remote.URL+"/ohne-titel"); err == nil {
t.Error("Seite ohne Titel ergab eine Vorschau")
}
if _, _, err := fetchPage(context.Background(), remote.URL+"/bild.png"); err == nil {
t.Error("Bild als Seite ergab eine Vorschau")
}
name := thumbPath("x")
if err := storeThumb(context.Background(), img[0], name); err != nil {
t.Fatal(err)
}
f, err := os.Open(name)
if err != nil {
t.Fatal(err)
}
defer f.Close()
cfg, format, err := image.DecodeConfig(f)
if err != nil || format != "jpeg" || cfg.Width != thumbSize || cfg.Height != 180 {
t.Errorf("thumb: %s %dx%d %v", format, cfg.Width, cfg.Height, err)
}
}
// Ende-zu-Ende: Beitrag mit YouTube-Link anlegen, Vorschau holen, im Feed
// sehen; Bearbeiten ohne Link nimmt sie wieder weg.
func TestLinkPreviewInFeed(t *testing.T) {
srv := newTestServer(t)
fakeRemote(t)
c := registerAndLogin(t, srv, "vorschau")
pid := createEntry(t, c, srv, "schau https://youtu.be/dQw4w9WgXcQ und https://example.org")
key := "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
feedPreview := func() *Preview {
t.Helper()
resp, err := http.Get(srv.URL + "/api/entry/feed/0")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var entries []Entry
if err := json.NewDecoder(resp.Body).Decode(&entries); err != nil {
t.Fatal(err)
}
for _, e := range entries {
if e.PID == pid {
return e.Preview
}
}
t.Fatalf("Beitrag %d fehlt im Feed", pid)
return nil
}
if p := feedPreview(); p != nil {
t.Errorf("Vorschau vor dem Abruf: %+v", p)
}
fetchPreview(context.Background(), key)
p := feedPreview()
if p == nil {
t.Fatal("keine Vorschau nach dem Abruf")
}
if p.URL != key || p.Kind != "youtube" || p.Title != "Ein Video" || p.Site != "Ein Kanal" || p.Thumb != thumbPath(key) {
t.Errorf("unerwartete Vorschau %+v", p)
}
if _, err := os.Stat(p.Thumb); err != nil {
t.Errorf("thumb fehlt: %v", err)
}
// Ein privates/gelöschtes Video ergibt keine Vorschau.
other := "https://www.youtube.com/watch?v=aaaaaaaaaaa"
requestPreview(other)
fetchPreview(context.Background(), other)
var status string
db.QueryRow(`SELECT status FROM link_preview WHERE url = $1`, other).Scan(&status)
if status != "none" {
t.Errorf("status = %q, erwartet none", status)
}
resp := postForm(t, c, srv.URL+"/api/entry/"+itoa(pid)+"/edit", url.Values{"content": {"jetzt ohne Link"}})
resp.Body.Close()
if p := feedPreview(); p != nil {
t.Errorf("Vorschau nach Bearbeiten ohne Link: %+v", p)
}
}
func itoa(n int64) string {
b, _ := json.Marshal(n)
return string(b)
}
+172
View File
@@ -0,0 +1,172 @@
package main
import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"net/http"
"time"
"golang.org/x/crypto/bcrypt"
)
// Passwort-Reset ohne E-Mail: der Admin erzeugt per CLI (kver reset-link) einen
// Einmal-Link und gibt ihn dem Nutzer persönlich. Der Link zeigt auf
// /reset#<token> -- im Fragment, damit das Token weder im Access-Log noch in
// der Impression-Statistik noch im Referer landet. Die Seite schickt es per
// POST an die API.
// resetTTL ist die Gültigkeit eines Reset-Links. Großzügig, weil er von Hand
// übergeben wird; er gilt ohnehin nur einmal.
const resetTTL = 72 * time.Hour
// errResetInvalid deckt unbekannt, abgelaufen und schon benutzt ab -- für den
// Nutzer ist das dasselbe: einen neuen Link holen.
var errResetInvalid = NotFound("reset.invalid", "Der Link ist ungültig oder abgelaufen.")
// hashResetToken: in der DB steht nur der Hash. SHA-256 statt bcrypt genügt,
// das Token ist 256 Bit Zufall und nicht zu erraten.
func hashResetToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
// createResetToken legt für username einen neuen Reset-Link an und liefert das
// Token im Klartext (nur hier sichtbar). Ältere Links desselben Nutzers werden
// ungültig, abgelaufene aller Nutzer weggeräumt.
func createResetToken(username string) (string, time.Time, error) {
var uid int64
err := db.QueryRow(`SELECT uid FROM account WHERE username = $1`, username).Scan(&uid)
if err == sql.ErrNoRows {
return "", time.Time{}, errUserNotFound
} else if err != nil {
return "", time.Time{}, err
}
token, err := newToken()
if err != nil {
return "", time.Time{}, err
}
now := time.Now()
expires := now.Add(resetTTL)
tx, err := db.Begin()
if err != nil {
return "", time.Time{}, err
}
defer tx.Rollback()
if _, err := tx.Exec(`DELETE FROM password_reset WHERE uid = $1 OR expires < $2`, uid, now.Unix()); err != nil {
return "", time.Time{}, err
}
if _, err := tx.Exec(
`INSERT INTO password_reset (uid, token_hash, created_at, expires) VALUES ($1, $2, $3, $4)`,
uid, hashResetToken(token), now.Unix(), expires.Unix(),
); err != nil {
return "", time.Time{}, err
}
return token, expires, tx.Commit()
}
// handleResetCheck prüft einen Link vorab, damit die Seite den Namen zeigen
// (oder gleich "abgelaufen" melden) kann, bevor jemand ein Passwort tippt.
// POST statt GET, damit das Token nicht in der URL und damit im Log steht.
func handleResetCheck(w http.ResponseWriter, r *http.Request) error {
username, err := resetUsername(r.FormValue("token"))
if err != nil {
return err
}
writeJSON(w, http.StatusOK, map[string]string{"username": username})
return nil
}
// resetUsername liefert den Nutzer zu einem gültigen Link, ohne ihn zu
// verbrauchen.
func resetUsername(token string) (string, error) {
var username string
err := db.QueryRow(
`SELECT a.username FROM password_reset p JOIN account a ON a.uid = p.uid
WHERE p.token_hash = $1 AND p.expires >= $2`,
hashResetToken(token), time.Now().Unix(),
).Scan(&username)
if err == sql.ErrNoRows {
return "", errResetInvalid
} else if err != nil {
return "", Internal(err)
}
return username, nil
}
// handleReset setzt das neue Passwort (pass1/pass2 wie bei der Registrierung),
// verbraucht den Link, meldet alle bestehenden Sessions ab und loggt den
// Nutzer direkt neu ein.
func handleReset(w http.ResponseWriter, r *http.Request) error {
// Erst die Eingabe prüfen: ein Tippfehler soll den Link nicht verbrauchen.
pass1 := r.FormValue("pass1")
fields := map[string]string{}
checkNewPassword(pass1, r.FormValue("pass2"), fields)
if len(fields) > 0 {
e := Invalid("user.invalid", "Die Angaben sind unvollständig oder ungültig.")
e.Fields = fields
return e
}
// Vorab ohne Verbrauch prüfen: bcrypt kostet spürbar CPU und soll nicht
// für jedes beliebige Token anlaufen. Verbindlich ist erst das DELETE unten.
token := r.FormValue("token")
if _, err := resetUsername(token); err != nil {
return err
}
hash, err := bcrypt.GenerateFromPassword([]byte(pass1), bcrypt.DefaultCost)
if err != nil {
return Internal(err)
}
tx, err := db.Begin()
if err != nil {
return Internal(err)
}
defer tx.Rollback()
// DELETE ... RETURNING verbraucht den Link atomar: zwei gleichzeitige
// Requests mit demselben Token können nicht beide durchkommen.
var uid int64
err = tx.QueryRow(
`DELETE FROM password_reset WHERE token_hash = $1 AND expires >= $2 RETURNING uid`,
hashResetToken(token), time.Now().Unix(),
).Scan(&uid)
if err == sql.ErrNoRows {
return errResetInvalid
} else if err != nil {
return Internal(err)
}
var username string
if err := tx.QueryRow(
`UPDATE account SET password = $1 WHERE uid = $2 RETURNING username`, hash, uid,
).Scan(&username); err != nil {
// Konto inzwischen gelöscht: der Link führt ins Leere.
if err == sql.ErrNoRows {
return errResetInvalid
}
return Internal(err)
}
// Wer das alte Passwort kannte, soll mit dessen Sessions nicht drin bleiben.
if _, err := tx.Exec(`DELETE FROM session WHERE uid = $1`, uid); err != nil {
return Internal(err)
}
if err := tx.Commit(); err != nil {
return Internal(err)
}
// War im Browser noch jemand anderes angemeldet, wird dessen Cookie gleich
// überschrieben -- die Session dazu soll dann nicht verwaist gültig bleiben.
if s, ok := getSession(r); ok {
db.Exec(`DELETE FROM session WHERE value = $1`, s.Value)
}
if err := startSession(w, r, uid); err != nil {
return err
}
writeJSON(w, http.StatusOK, map[string]string{"username": username})
return nil
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

-36
View File
@@ -1,36 +0,0 @@
$(() => {
// $('#auth-visible').hide()
// Startdatum festlegen (Beispiel: 1. Januar 2025, 00:00:00 Uhr)
const startDate = new Date('2025-12-13T12:44:00').getTime();
function updateCounter() {
const now = new Date().getTime();
const diff = now - startDate;
// Berechne Tage, Stunden, Minuten
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
// Zeige den Counter an
$('#uptime-counter').html(`<strong>${days}</strong> Tage, <strong>${hours}</strong> Stunden, <strong>${minutes}</strong> Minuten und <strong>${seconds}</strong> Sekunden ohne Datenverlust!`);
}
// Aktualisiere den Counter sofort und dann jede Minute
updateCounter();
setInterval(updateCounter, 1000);
let agreedToWarning = document.cookie.includes("agreedToWarning=True")
if(agreedToWarning) {
$("#contentnotice").hide()
}
})
function setCookie(cname, cvalue, exdays) {
const d = new Date();
d.setTime(d.getTime() + (exdays*24*60*60*1000));
let expires = "expires="+ d.toUTCString();
document.cookie = cname + "=" + cvalue + ";" + expires + ";path=/";
}
+46 -57
View File
@@ -192,7 +192,7 @@ func stopImpressionWorker() {
// Netz. Gezielt nur auf den HTML-Seiten-Routen, nicht auf API oder Assets. // Netz. Gezielt nur auf den HTML-Seiten-Routen, nicht auf API oder Assets.
// //
// Die Erfassung läuft asynchron über den Impression-Worker: der Seitenaufruf // Die Erfassung läuft asynchron über den Impression-Worker: der Seitenaufruf
// darf nicht auf den DB-Schreib warten (SQLite serialisiert Schreiber). r ist // darf nicht auf den DB-Schreibvorgang warten. r ist
// nach Rückkehr des Handlers nicht mehr sicher nutzbar -> wir kopieren die zwei // nach Rückkehr des Handlers nicht mehr sicher nutzbar -> wir kopieren die zwei
// Werte vorher heraus. // Werte vorher heraus.
func trackImpression(next http.Handler) http.Handler { func trackImpression(next http.Handler) http.Handler {
@@ -214,62 +214,58 @@ func recordImpression(path, anonIP, asn string) {
} }
day := time.Now().UTC().Format("2006-01-02") day := time.Now().UTC().Format("2006-01-02")
db.Exec( db.Exec(
`INSERT INTO impression (day, anon_ip, path, asn, hits) VALUES (?, ?, ?, ?, 1) `INSERT INTO impression (day, anon_ip, path, asn, hits) VALUES ($1, $2, $3, $4, 1)
ON CONFLICT(day, anon_ip, path) DO UPDATE SET hits = hits + 1`, ON CONFLICT (day, anon_ip, path) DO UPDATE SET hits = impression.hits + 1`,
day, anonIP, path, asn, day, anonIP, path, asn,
) )
} }
// handleStatsDetail liefert die Kennzahlen für die Statistik-Seite. Anders als // 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) error {
var users, entries int64
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
}
// handleStatsDetail liefert die Kennzahlen für die Statistik-Seite (die
// Diagramme kommen getrennt aus handleStatsCharts, charts.go). Anders als
// /stats (Tagline, bei jedem Aufruf) darf das hier ruhig mehrere Aggregate // /stats (Tagline, bei jedem Aufruf) darf das hier ruhig mehrere Aggregate
// rechnen. Öffentlich, kein Auth. // rechnen. Öffentlich, kein Auth.
func handleStatsDetail(w http.ResponseWriter, r *http.Request) { func handleStatsDetail(w http.ResponseWriter, r *http.Request) error {
var users, entries, toplevel, votesLeft, votesRight int64 var users, entries, toplevel, votesYes, votesNo int64
db.QueryRow(`SELECT COUNT(*) FROM user`).Scan(&users) if err := db.QueryRow(
db.QueryRow(`SELECT COUNT(*) FROM entry WHERE deleted = 0`).Scan(&entries) `SELECT (SELECT COUNT(*) FROM account),
db.QueryRow(`SELECT COUNT(*) FROM entry WHERE deleted = 0 AND reply_to = 0`).Scan(&toplevel) (SELECT COUNT(*) FROM entry WHERE deleted = 0),
db.QueryRow(`SELECT COUNT(*) FROM vote WHERE mode = 'left'`).Scan(&votesLeft) (SELECT COUNT(*) FROM entry WHERE deleted = 0 AND reply_to = 0),
db.QueryRow(`SELECT COUNT(*) FROM vote WHERE mode = 'right'`).Scan(&votesRight) (SELECT COUNT(*) FROM vote WHERE mode = 'yes'),
(SELECT COUNT(*) FROM vote WHERE mode = 'no')`).
Scan(&users, &entries, &toplevel, &votesYes, &votesNo); err != nil {
return Internal(err)
}
var imprTotal, visitorsTotal int64 var imprTotal, visitorsTotal int64
db.QueryRow(`SELECT COALESCE(SUM(hits), 0), COUNT(DISTINCT anon_ip) FROM impression`). if err := db.QueryRow(`SELECT COALESCE(SUM(hits), 0)::bigint, COUNT(DISTINCT anon_ip) FROM impression`).
Scan(&imprTotal, &visitorsTotal) Scan(&imprTotal, &visitorsTotal); err != nil {
return Internal(err)
// Tägliche Impressions + grobe Unique-Netze, jüngste 30 Tage zuerst. Das
// Frontend dreht für die Achse auf aufsteigend.
daily := []map[string]any{}
if rows, err := db.Query(
`SELECT day, SUM(hits), 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})
}
} }
// Netze nach Betreiber (autonomes System). Leere asn ('') -> "(unbekannt)". // Laufender Durchschnitt: Beiträge (wie in den Diagrammen einschließlich
topASNs := []map[string]any{} // gelöschter) der letzten 30 vollen Tage deutscher Zeit, ohne heute.
if rows, err := db.Query( now := time.Now().In(berlin)
`SELECT CASE WHEN asn = '' THEN '(unbekannt)' ELSE asn END AS a, midnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, berlin)
SUM(hits) AS h, var recent int64
COUNT(DISTINCT anon_ip) if err := db.QueryRow(`SELECT COUNT(*) FROM entry WHERE created_at >= $1 AND created_at < $2`,
FROM impression midnight.AddDate(0, 0, -30).Unix(), midnight.Unix()).Scan(&recent); err != nil {
GROUP BY a return Internal(err)
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})
}
} }
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
@@ -277,17 +273,10 @@ func handleStatsDetail(w http.ResponseWriter, r *http.Request) {
"entries": entries, "entries": entries,
"toplevel": toplevel, "toplevel": toplevel,
"replies": entries - toplevel, "replies": entries - toplevel,
"votes": map[string]any{"left": votesLeft, "right": votesRight}, "votes": map[string]any{"yes": votesYes, "no": votesNo},
"impressions_total": imprTotal, "impressions_total": imprTotal,
"visitors_total": visitorsTotal, "visitors_total": visitorsTotal,
"daily": daily, "entries_per_day_30": float64(recent) / 30,
"top_asns": topASNs,
}) })
} return nil
// 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")
} }
-25
View File
@@ -1,25 +0,0 @@
{{define "base"}}<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.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="/feed"><h1>Kontrollverlust</h1></a>
</header>
<main>
{{template "content" .}}
</main>
</body>
</html>
{{end}}
-30
View File
@@ -1,30 +0,0 @@
{{define "content"}}
{{with .Form}}
<section id="confirm-delete">
<h2>Beitrag löschen?</h2>
<article class="card">
<div class="threadline-wrapper">
<div class="threadline"></div>
<div class="body">
<div class="content">{{content .Content}}</div>
{{if .HasImage}}<p class="muted">Das angehängte Bild wird mit gelöscht.</p>{{end}}
</div>
</div>
</article>
<p>Der Text und das Bild werden entfernt. Der Beitrag bleibt als
<span class="muted">[deleted]</span>-Platzhalter stehen, damit
{{if .IsRoot}}der Thread{{else}}die Antworten darunter{{end}} nicht
verwaisen.
{{if .Replies}}Die {{.Replies}} Antwort(en) darauf bleiben erhalten.{{end}}</p>
<p><strong>Das lässt sich nicht rückgängig machen.</strong></p>
<form method="post" action="/entry/{{.PID}}/delete">
<input type="hidden" name="return_to" value="{{.BackURL}}">
<button type="submit">Endgültig löschen</button>
</form>
<p class="muted"><a href="{{.BackURL}}">Abbrechen</a></p>
</section>
{{end}}
{{end}}
-19
View File
@@ -1,19 +0,0 @@
{{define "content"}}
{{with .Form}}
<section id="compose">
{{/* Kein enctype/Dateifeld: das angehängte Bild bleibt beim Bearbeiten
unverändert (siehe handleEditEntry). Ein Dateifeld hier würde
versprechen, was der Endpunkt nicht tut. */}}
<form method="post" action="/entry/{{.PID}}/edit">
<input type="hidden" name="return_to" value="{{.BackURL}}">
<h2>Beitrag bearbeiten</h2>
{{with $.Err}}<p class="msg">{{.Msg}}</p>{{end}}
<label for="content">Text</label>
<textarea id="content" name="content" rows="6" maxlength="1000"
required>{{.Content}}</textarea>
<button type="submit">Speichern</button>
</form>
<p class="muted"><a href="{{.BackURL}}">Abbrechen</a></p>
</section>
{{end}}
{{end}}
-75
View File
@@ -1,75 +0,0 @@
{{/*
Ein Beitrag und die Blätternavigation -- als eigene Blöcke, damit Hauptfeed
und Nutzerfeed dieselbe Darstellung benutzen. Das Gegenstück zu renderEntry
in web/js/feed.js.
Jede Aktion ist ein echtes Formular mit return_to: ohne JavaScript ist eine
Interaktion immer ein Seitenwechsel, und der Anker #e{{.PID}} bringt den
Nutzer danach an genau den Beitrag zurück, den er bedient hat.
*/}}
{{define "entry"}}
<article class="card" id="e{{.PID}}">
{{if .Deleted}}
<div class="muted byline">
<span>[deleted] · <time datetime="{{timeAttr .CreatedAt}}">{{time .CreatedAt}}</time></span>
</div>
<div class="threadline-wrapper">
<div class="threadline"></div>
<div class="body"><div class="content">[deleted]</div></div>
</div>
{{else}}
<div class="muted byline">
<a href="{{profile .Username}}"><img class="avatar avatar-sm" src="{{avatar .Avatar}}" alt="" loading="lazy"></a>
<span><a href="{{profile .Username}}">{{.Username}}</a> ·
<time datetime="{{timeAttr .CreatedAt}}">{{time .CreatedAt}}</time></span>
<details class="report">
<summary>Melden</summary>
<form class="report-form" method="post" action="/entry/{{.PID}}/report">
<input type="hidden" name="return_to" value="{{.ReturnTo}}#e{{.PID}}">
<label for="reason-{{.PID}}">Grund <span class="muted">(optional)</span></label>
<input id="reason-{{.PID}}" name="reason" type="text" maxlength="500" autocomplete="off">
<button type="submit">Absenden</button>
</form>
</details>
</div>
<div class="threadline-wrapper">
<div class="threadline"></div>
<div class="body">
<div class="content">{{content .Content}}</div>
{{if .Filepath}}<img src="/{{.Filepath}}" alt="" loading="lazy">{{end}}
<div class="interactions">
<a class="button reply" href="/beitrag/{{.PID}}">Antworten ({{.ReplyCount}})</a>
{{if .EditURL}}<a class="button" href="{{.EditURL}}">Bearbeiten</a>{{end}}
{{if .DeleteURL}}<a class="button" href="{{.DeleteURL}}">Löschen</a>{{end}}
{{if .LoggedIn}}
<form class="row" method="post" action="/entry/{{.PID}}/vote">
<input type="hidden" name="return_to" value="{{.ReturnTo}}#e{{.PID}}">
<button class="ghost{{if eq .Selected "left"}} selected{{end}}"
name="mode" value="left">Links</button>
<span class="muted">{{.Left}} / {{.Right}}</span>
<button class="ghost{{if eq .Selected "right"}} selected{{end}}"
name="mode" value="right">Rechts</button>
</form>
{{else}}
{{/* Ohne Anmeldung nur der Stand -- ein Formular hier würde
ausschließlich 401 liefern. */}}
<div class="row">
<span class="muted">{{.Left}} / {{.Right}} ·
<a href="/auth/login">anmelden zum Abstimmen</a></span>
</div>
{{end}}
</div>
</div>
</div>
{{end}}
</article>
{{end}}
{{define "pager"}}
<nav class="pager">
{{if .PrevURL}}<a class="button" href="{{.PrevURL}}">← Neuere</a>{{end}}
<span class="muted">Seite {{.HumanPage}} von {{.Pages}}</span>
{{if .NextURL}}<a class="button" href="{{.NextURL}}">Ältere →</a>{{end}}
</nav>
{{end}}
-34
View File
@@ -1,34 +0,0 @@
{{define "content"}}
{{with .Form}}
{{if .CanPost}}
<section id="compose">
{{/* enctype ist Pflicht, sonst schickt der Browser nur den Dateinamen
statt der Datei -- ohne JavaScript gibt es keinen zweiten Versuch. */}}
<form method="post" action="/entry/create" enctype="multipart/form-data">
<input type="hidden" name="page" value="{{.Page}}">
{{with $.Err}}<p class="msg">{{.Msg}}</p>{{end}}
<label for="content">Neuer Beitrag</label>
<textarea id="content" name="content" rows="4" maxlength="1000"
placeholder="Was gibt's Neues?">{{.Compose.Content}}</textarea>
<label for="file">Bild <span class="muted">(optional)</span></label>
<input id="file" name="file" type="file" accept="image/*">
{{if .Compose.HadFile}}
<p class="msg">Dein Bild musst du erneut auswählen – Browser können
Dateifelder aus Sicherheitsgründen nicht vorbelegen.</p>
{{end}}
<button type="submit">Eintrag erstellen</button>
</form>
</section>
{{else if .IsTOR}}
<p class="muted">Über TOR ist das Schreiben von Beiträgen derzeit nicht
möglich. Lesen, Abstimmen und Melden funktionieren.</p>
{{end}}
<section id="feed">
{{range .Entries}}{{template "entry" .}}{{else}}
<p class="muted">Noch keine Beiträge.</p>
{{end}}
</section>
{{template "pager" .}}
{{end}}
{{end}}
-16
View File
@@ -1,16 +0,0 @@
{{define "content"}}
<section id="auth-box">
<form method="post" action="/auth/login">
<h2>Login</h2>
{{with .Err}}<p class="msg">{{.Msg}}</p>{{end}}
<label for="user">Nutzername</label>
<input id="user" name="user" autocomplete="username" required
{{if .Form}}value="{{.Form.User}}"{{end}}>
<label for="pass">Passwort</label>
<input id="pass" name="pass" type="password" autocomplete="current-password" required>
<input name="timeout" type="hidden" value="86400">
<button type="submit">Einloggen</button>
</form>
<p class="muted">Noch kein Konto? <a href="/auth/register">Registrieren</a></p>
</section>
{{end}}
-23
View File
@@ -1,23 +0,0 @@
{{define "content"}}
<section id="auth-box">
<form method="post" action="/auth/newuser">
<h2>Registrieren</h2>
<label for="user">Nutzername</label>
<input id="user" name="user" autocomplete="username" required minlength="3" maxlength="32"
{{if .Form}}value="{{.Form.User}}"{{end}}>
{{with fieldErr .Err "user"}}<p class="msg">{{.}}</p>{{end}}
<label for="pass1">Passwort <span class="muted">(mindestens 10 Zeichen)</span></label>
<input id="pass1" name="pass1" type="password" autocomplete="new-password" required minlength="10">
{{with fieldErr .Err "pass1"}}<p class="msg">{{.}}</p>{{end}}
<label for="pass2">Passwort wiederholen</label>
<input id="pass2" name="pass2" type="password" autocomplete="new-password" required minlength="10">
{{with fieldErr .Err "pass2"}}<p class="msg">{{.}}</p>{{end}}
<button type="submit">Konto erstellen</button>
</form>
<p class="muted">Schon registriert? <a href="/auth/login">Zum Login</a></p>
</section>
{{end}}
-15
View File
@@ -1,15 +0,0 @@
{{define "content"}}
{{with .Form}}
<section id="report-done">
<h2>Meldung eingegangen</h2>
<p>Danke – deine Meldung zu Beitrag
<a href="/beitrag/{{.PID}}">#{{.PID}}</a> ist eingegangen und wird geprüft.</p>
{{if .Reason}}
<p class="muted">Dein Grund: {{.Reason}}</p>
{{else}}
<p class="muted">Du hast keinen Grund angegeben.</p>
{{end}}
<p><a class="button" href="{{.BackURL}}">Zurück zum Beitrag</a></p>
</section>
{{end}}
{{end}}
-41
View File
@@ -1,41 +0,0 @@
{{define "content"}}
{{with .Form}}
{{if .Ancestors}}
<section id="ancestors" class="muted">
{{range .Ancestors}}{{template "entry" .}}{{end}}
</section>
{{end}}
<section id="focus">
{{template "entry" .Entry}}
</section>
{{if .CanPost}}
<section id="compose">
<form method="post" action="/entry/create" enctype="multipart/form-data">
<input type="hidden" name="reply_to" value="{{.Entry.PID}}">
{{with $.Err}}<p class="msg">{{.Msg}}</p>{{end}}
<label for="content">Antworten</label>
<textarea id="content" name="content" rows="4" maxlength="1000"
placeholder="Deine Antwort">{{.Compose.Content}}</textarea>
<label for="file">Bild <span class="muted">(optional)</span></label>
<input id="file" name="file" type="file" accept="image/*">
{{if .Compose.HadFile}}
<p class="msg">Dein Bild musst du erneut auswählen – Browser können
Dateifelder aus Sicherheitsgründen nicht vorbelegen.</p>
{{end}}
<button type="submit">Antwort abschicken</button>
</form>
</section>
{{else if .IsTOR}}
<p class="muted">Über TOR ist das Schreiben von Beiträgen derzeit nicht
möglich. Lesen, Abstimmen und Melden funktionieren.</p>
{{end}}
<section id="replies">
{{range .Replies}}{{template "entry" .}}{{else}}
<p class="muted">Noch keine Antworten.</p>
{{end}}
</section>
{{end}}
{{end}}
-11
View File
@@ -1,11 +0,0 @@
{{define "content"}}
{{with .Form}}
<h2>Beiträge von {{.Username}}</h2>
<section id="feed">
{{range .Entries}}{{template "entry" .}}{{else}}
<p class="muted">Dieser Nutzer hat noch nichts geschrieben.</p>
{{end}}
</section>
{{template "pager" .}}
{{end}}
{{end}}
+31 -59
View File
@@ -5,7 +5,6 @@ import (
"math/rand/v2" "math/rand/v2"
"net/http" "net/http"
"os" "os"
"strings"
"time" "time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@@ -22,9 +21,7 @@ var errUsernameTaken = Conflict("user.name_taken", "Der Nutzername wird bereits
// nur die Existenz von Accounts verraten. // nur die Existenz von Accounts verraten.
var errUserNotFound = NotFound("user.not_found", "Nutzer nicht gefunden") var errUserNotFound = NotFound("user.not_found", "Nutzer nicht gefunden")
// createUser legt den Account an und liefert dessen uid zurück -- der Aufrufer // createUser legt den Account an und liefert dessen uid zurück.
// braucht sie, um direkt eine Session zu eröffnen (Registrierung ohne
// JavaScript loggt in einem Schritt ein).
func createUser(username, password string) (int64, error) { func createUser(username, password string) (int64, error) {
now := time.Now().Unix() now := time.Now().Unix()
@@ -39,7 +36,7 @@ func createUser(username, password string) (int64, error) {
for i := 0; i < 5; i++ { for i := 0; i < 5; i++ {
uid := int64(rand.IntN(99999999)) + 1 uid := int64(rand.IntN(99999999)) + 1
_, lastErr = db.Exec( _, lastErr = db.Exec(
`INSERT INTO user (uid, created_at, username, password) VALUES (?, ?, ?, ?)`, `INSERT INTO account (uid, created_at, username, password) VALUES ($1, $2, $3, $4)`,
uid, now, username, hash, uid, now, username, hash,
) )
if lastErr == nil { if lastErr == nil {
@@ -47,26 +44,20 @@ func createUser(username, password string) (int64, error) {
} }
// username ist ebenfalls UNIQUE: hat DER kollidiert (Rennen mit der // username ist ebenfalls UNIQUE: hat DER kollidiert (Rennen mit der
// Vorab-Prüfung des Aufrufers), hilft kein Retry -> gezielt melden. // Vorab-Prüfung des Aufrufers), hilft kein Retry -> gezielt melden.
if strings.Contains(lastErr.Error(), "user.username") { if isUniqueViolation(lastErr, "account_username_key") {
return 0, errUsernameTaken return 0, errUsernameTaken
} }
if !isUniqueViolation(lastErr, "account_uid_key") {
return 0, lastErr
}
} }
return 0, lastErr return 0, lastErr
} }
// idFromUsername liefert die interne id oder 0, wenn der Nutzer nicht existiert. func usernameTaken(username string) bool {
func idFromUsername(username string) int64 { var taken bool
var id int64 db.QueryRow(`SELECT EXISTS (SELECT 1 FROM account WHERE username = $1)`, username).Scan(&taken)
if err := db.QueryRow(`SELECT id FROM user WHERE username = ?`, username).Scan(&id); err != nil { return taken
return 0
}
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 { func handleUserPage(w http.ResponseWriter, r *http.Request) error {
@@ -74,7 +65,7 @@ func handleUserPage(w http.ResponseWriter, r *http.Request) error {
var uid, createdAt int64 var uid, createdAt int64
var avatar string var avatar string
err := db.QueryRow(`SELECT uid, created_at, avatar FROM user WHERE username = ?`, username).Scan(&uid, &createdAt, &avatar) err := db.QueryRow(`SELECT uid, created_at, avatar FROM account WHERE username = $1`, username).Scan(&uid, &createdAt, &avatar)
if err != nil { if err != nil {
return errUserNotFound return errUserNotFound
} }
@@ -87,25 +78,13 @@ func handleUserPage(w http.ResponseWriter, r *http.Request) error {
return nil return nil
} }
// 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) {
var users, entries int64
db.QueryRow(`SELECT COUNT(*) FROM user`).Scan(&users)
db.QueryRow(`SELECT COUNT(*) FROM entry WHERE deleted = 0`).Scan(&entries)
writeJSON(w, http.StatusOK, map[string]any{
"users": users,
"entries": entries,
})
}
func handleUserInfo(w http.ResponseWriter, r *http.Request) error { func handleUserInfo(w http.ResponseWriter, r *http.Request) error {
uid := uidFromContext(r.Context()) uid := uidFromContext(r.Context())
var createdAt, lastLogin sql.NullInt64 var createdAt, lastLogin sql.NullInt64
var username, avatar string var username, avatar string
err := db.QueryRow( err := db.QueryRow(
`SELECT created_at, last_login, username, avatar FROM user WHERE uid = ?`, uid, `SELECT created_at, last_login, username, avatar FROM account WHERE uid = $1`, uid,
).Scan(&createdAt, &lastLogin, &username, &avatar) ).Scan(&createdAt, &lastLogin, &username, &avatar)
if err != nil { if err != nil {
return errUserNotFound return errUserNotFound
@@ -131,19 +110,19 @@ func handleUserRename(w http.ResponseWriter, r *http.Request) error {
} }
var current string var current string
if err := db.QueryRow(`SELECT username FROM user WHERE uid = ?`, uid).Scan(&current); err != nil { if err := db.QueryRow(`SELECT username FROM account WHERE uid = $1`, uid).Scan(&current); err != nil {
return errUserNotFound return errUserNotFound
} }
if name == current { if name == current {
return Invalid("user.name_unchanged", "Das ist bereits dein Name.").At("user") return Invalid("user.name_unchanged", "Das ist bereits dein Name.").At("user")
} }
if idFromUsername(name) != 0 { if usernameTaken(name) {
return errUsernameTaken return errUsernameTaken
} }
if _, err := db.Exec(`UPDATE user SET username = ? WHERE uid = ?`, name, uid); err != nil { if _, err := db.Exec(`UPDATE account SET username = $1 WHERE uid = $2`, name, uid); err != nil {
// Rennen zwischen Vorab-Prüfung und UPDATE: UNIQUE-Constraint -> 409. // Rennen zwischen Vorab-Prüfung und UPDATE: UNIQUE-Constraint -> 409.
if strings.Contains(err.Error(), "user.username") { if isUniqueViolation(err, "account_username_key") {
return errUsernameTaken return errUsernameTaken
} }
return Internal(err) return Internal(err)
@@ -173,9 +152,9 @@ func handleSetAvatar(w http.ResponseWriter, r *http.Request) error {
} }
var old string var old string
db.QueryRow(`SELECT avatar FROM user WHERE uid = ?`, uid).Scan(&old) db.QueryRow(`SELECT avatar FROM account WHERE uid = $1`, uid).Scan(&old)
if _, err := db.Exec(`UPDATE user SET avatar = ? WHERE uid = ?`, stored, uid); err != nil { if _, err := db.Exec(`UPDATE account SET avatar = $1 WHERE uid = $2`, stored, uid); err != nil {
os.Remove(stored) os.Remove(stored)
return Internal(err) return Internal(err)
} }
@@ -187,15 +166,16 @@ func handleSetAvatar(w http.ResponseWriter, r *http.Request) error {
} }
// handleUserDelete löscht den eingeloggten Account dauerhaft, nach Bestätigung // handleUserDelete löscht den eingeloggten Account dauerhaft, nach Bestätigung
// durch das Passwort (Formularfeld pass1). Sessions und Votes werden in einer // durch das Passwort (Formularfeld pass1). Sessions, Reset-Links und Votes
// Transaktion mitgelöscht; die eigenen Beiträge werden soft-gelöscht (als // werden in einer Transaktion mitgelöscht; die eigenen Beiträge werden
// [deleted]-Platzhalter erhalten), damit fremde Antworten nicht verwaisen. // soft-gelöscht (als [deleted]-Platzhalter erhalten), damit fremde Antworten
// nicht verwaisen.
// Zugehörige Mediendateien werden best effort entfernt. // Zugehörige Mediendateien werden best effort entfernt.
func handleUserDelete(w http.ResponseWriter, r *http.Request) error { func handleUserDelete(w http.ResponseWriter, r *http.Request) error {
uid := uidFromContext(r.Context()) uid := uidFromContext(r.Context())
var hash []byte var hash []byte
if err := db.QueryRow(`SELECT password FROM user WHERE uid = ?`, uid).Scan(&hash); err != nil { if err := db.QueryRow(`SELECT password FROM account WHERE uid = $1`, uid).Scan(&hash); err != nil {
return errUserNotFound return errUserNotFound
} }
@@ -206,7 +186,7 @@ func handleUserDelete(w http.ResponseWriter, r *http.Request) error {
// Mediendateien der Beiträge (und das Profilbild) einsammeln, bevor die // Mediendateien der Beiträge (und das Profilbild) einsammeln, bevor die
// Pfade geleert bzw. die user-Zeile gelöscht wird. // Pfade geleert bzw. die user-Zeile gelöscht wird.
var media []string var media []string
if rows, err := db.Query(`SELECT filepath FROM entry WHERE uid = ?`, uid); err == nil { if rows, err := db.Query(`SELECT filepath FROM entry WHERE uid = $1`, uid); err == nil {
for rows.Next() { for rows.Next() {
var fp string var fp string
if rows.Scan(&fp) == nil && fp != "" { if rows.Scan(&fp) == nil && fp != "" {
@@ -216,7 +196,7 @@ func handleUserDelete(w http.ResponseWriter, r *http.Request) error {
rows.Close() rows.Close()
} }
var avatar string var avatar string
if db.QueryRow(`SELECT avatar FROM user WHERE uid = ?`, uid).Scan(&avatar) == nil && avatar != "" { if db.QueryRow(`SELECT avatar FROM account WHERE uid = $1`, uid).Scan(&avatar) == nil && avatar != "" {
media = append(media, avatar) media = append(media, avatar)
} }
@@ -225,10 +205,11 @@ func handleUserDelete(w http.ResponseWriter, r *http.Request) error {
return Internal(err) return Internal(err)
} }
for _, q := range []string{ for _, q := range []string{
`DELETE FROM session WHERE uid = ?`, `DELETE FROM session WHERE uid = $1`,
`DELETE FROM vote WHERE uid = ?`, `DELETE FROM password_reset WHERE uid = $1`,
`UPDATE entry SET deleted = 1, content = '', filepath = '', uid = 0 WHERE uid = ?`, `DELETE FROM vote WHERE uid = $1`,
`DELETE FROM user WHERE uid = ?`, `UPDATE entry SET deleted = 1, content = '', filepath = '', link_url = '', uid = 0 WHERE uid = $1`,
`DELETE FROM account WHERE uid = $1`,
} { } {
if _, err := tx.Exec(q, uid); err != nil { if _, err := tx.Exec(q, uid); err != nil {
tx.Rollback() tx.Rollback()
@@ -244,16 +225,7 @@ func handleUserDelete(w http.ResponseWriter, r *http.Request) error {
os.Remove(fp) os.Remove(fp)
} }
http.SetCookie(w, &http.Cookie{ clearSessionCookie(w, r)
Name: "session",
Value: "",
Path: "/",
Expires: time.Unix(0, 0),
MaxAge: -1,
HttpOnly: true,
Secure: isHTTPS(r),
SameSite: http.SameSiteLaxMode,
})
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
return nil return nil
} }
-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>
-72
View File
@@ -1,72 +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 Startseite braucht derzeit noch JavaScript. Ohne JS
nutzbar ist bereits alles Wesentliche – Lesen, Schreiben, Antworten,
Abstimmen und Melden: <a href="/feed">zum Feed</a> ·
<a href="/auth/login">Login</a> · <a href="/auth/register">Registrieren</a>.</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>
-124
View File
@@ -1,124 +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}`,
{ adEvery: 5 }
);
// --- 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();
-346
View File
@@ -1,346 +0,0 @@
"use strict";
// Accept: application/json markiert diese Aufrufe als API-Zugriffe. Endpunkte,
// die auch ein Browser-Formular bedienen (siehe wantsJSON im Backend),
// antworten daraufhin mit JSON statt mit einem Redirect auf eine HTML-Seite.
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. Das Formular ist ein echtes method=post-Form, damit es
// später auch ohne JS funktioniert; hier fangen wir den Submit nur ab, 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");
});
}
// renderAd baut einen In-Feed-Werbeslot: das Banner (selbst gehostet, sonst
// würde die CSP default-src 'self' es blocken), darunter der Pflicht-Hinweis
// "Werbung" und der Link zur Eigenvermarktung. rel="sponsored nofollow" macht
// den bezahlten Charakter für Suchmaschinen kenntlich.
function renderAd() {
const el = document.createElement("aside");
el.className = "ad";
el.innerHTML = `
<a class="ad-banner" href="/werben" rel="sponsored nofollow noopener">
<img src="/static/ads/placeholder.png" alt="Werbung" loading="lazy">
</a>
<div class="ad-meta muted">
<span>Werbung</span>
<a href="/werben">Auf dem Kontrollverlust werben</a>
</div>
`;
return el;
}
// createFeed hängt Infinite-Scroll an feedEl/sentinelEl und lädt Seiten über
// urlFor(page). opts.adEvery (>0) blendet nach je N Beiträgen einen
// Werbebanner ein — nur im Top-Level-Feed, nicht in User-/Thread-Feeds.
// Gibt { reset } zurück, um den Feed neu zu laden.
function createFeed(feedEl, sentinelEl, urlFor, opts = {}) {
const adEvery = opts.adEvery || 0;
let page = 0;
let loading = false;
let done = false;
let shown = 0; // fortlaufende Beitragszahl über Seitengrenzen hinweg
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));
shown++;
if (adEvery && shown % adEvery === 0) feedEl.appendChild(renderAd());
});
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;
shown = 0;
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>