Compare commits

...
3 Commits
Author SHA1 Message Date
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
36 changed files with 468 additions and 2761 deletions
+4 -7
View File
@@ -1,13 +1,10 @@
# Build-Stage: statisches Binary, CGO aus (modernc.org/sqlite ist pures Go). # Build-Stage: statisches Binary, CGO aus (pgx und modernc.org/sqlite für den
# einmaligen Import sind pures Go).
FROM docker.io/library/golang:1.26-alpine AS build 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.
@@ -17,10 +14,10 @@ WORKDIR /app
COPY --from=build /kver /usr/local/bin/kver COPY --from=build /kver /usr/local/bin/kver
COPY web/ web/ COPY web/ web/
COPY static/ static/ COPY static/ static/
# data: SQLite-DB; static/media: Uploads. Beide als Volume persistieren. # data: GeoLite2-ASN-DB (und die alte SQLite-DB für den Import);
# 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"]
+14 -98
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,87 +58,41 @@ 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 // Gleiche Meldung für "Nutzer existiert nicht" und "Passwort falsch": sonst
// wird der Endpunkt zum Nutzernamen-Orakel. // 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
@@ -158,14 +112,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",
@@ -181,7 +135,7 @@ func startSession(w http.ResponseWriter, r *http.Request, uid int64) error {
func handleLogout(w http.ResponseWriter, r *http.Request) { func handleLogout(w http.ResponseWriter, r *http.Request) {
if s, ok := getSession(r); ok { if s, ok := getSession(r); ok {
db.Exec(`DELETE FROM session WHERE value = ?`, s.Value) db.Exec(`DELETE FROM session WHERE value = $1`, s.Value)
} }
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: "session", Name: "session",
@@ -200,7 +154,6 @@ func handleLogout(w http.ResponseWriter, r *http.Request) {
// 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"))
@@ -224,62 +177,25 @@ func handleNewUser(w http.ResponseWriter, r *http.Request) error {
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 { if len(fields) > 0 {
e := Invalid("user.invalid", "Die Angaben sind unvollständig oder ungültig.") e := Invalid("user.invalid", "Die Angaben sind unvollständig oder ungültig.")
e.Fields = fields e.Fields = fields
return fail(e) return e
} }
uid, err := createUser(username, pass1) _, err := createUser(username, pass1)
if errors.Is(err, errUsernameTaken) { if errors.Is(err, errUsernameTaken) {
// Rennen zwischen Vorab-Prüfung und INSERT: der UNIQUE-Constraint hat // Rennen zwischen Vorab-Prüfung und INSERT: der UNIQUE-Constraint hat
// gegriffen -> als normale Validierungsmeldung statt 500 melden. // gegriffen -> als normale Validierungsmeldung statt 500 melden.
return fail(errUsernameTaken) return errUsernameTaken
} else if err != nil { } else if err != nil {
return Internal(err) 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}) writeJSON(w, http.StatusCreated, map[string]string{"username": username})
return nil 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 {
s, ok := getSession(r) s, ok := getSession(r)
if !ok { if !ok {
+46 -57
View File
@@ -2,53 +2,57 @@ 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
); );
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,7 +67,7 @@ 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)
); );
@@ -73,12 +77,12 @@ CREATE TABLE IF NOT EXISTS impression (
-- Absender nachziehen zu können, ohne das Schema zu ändern. handled: 0 offen, -- Absender nachziehen zu können, ohne das Schema zu ändern. handled: 0 offen,
-- 1 von der Moderation erledigt. reason ist ein optionaler Freitext. -- 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
); );
-- 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 +99,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
} }
+28 -963
View File
File diff suppressed because it is too large Load Diff
+65 -227
View File
@@ -13,7 +13,6 @@ import (
"math/rand/v2" "math/rand/v2"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"net/url"
"os" "os"
"strconv" "strconv"
"strings" "strings"
@@ -46,7 +45,7 @@ const entrySelect = `
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` FROM entry e LEFT JOIN account u ON u.uid = e.uid`
// scanEntries liest Entry-Zeilen aus einem Query mit entrySelect-Spalten. // scanEntries liest Entry-Zeilen aus einem Query mit entrySelect-Spalten.
func scanEntries(rows *sql.Rows) ([]Entry, error) { func scanEntries(rows *sql.Rows) ([]Entry, error) {
@@ -67,7 +66,7 @@ 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 var e Entry
err := db.QueryRow(entrySelect+` WHERE e.pid = ?`, pid).Scan( err := db.QueryRow(entrySelect+` WHERE e.pid = $1`, pid).Scan(
&e.PID, &e.UID, &e.CreatedAt, &e.Content, &e.Filepath, &e.PID, &e.UID, &e.CreatedAt, &e.Content, &e.Filepath,
&e.ReplyTo, &e.ReplyCount, &e.LastActivity, &e.Deleted, &e.ReplyTo, &e.ReplyCount, &e.LastActivity, &e.Deleted,
&e.BumpCount, &e.LastBump, &e.Username, &e.Avatar) &e.BumpCount, &e.LastBump, &e.Username, &e.Avatar)
@@ -76,20 +75,18 @@ func entryByPID(pid int64) (Entry, error) {
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 // feedWhere baut die Filterbedingung für Feed-Abfragen.
// feedTotal gemeinsam benutzt -- zwei getrennte Kopien würden auseinander
// laufen und die Seitenzahl gegenüber dem Inhalt verfälschen.
func feedWhere(uid int64, rootsOnly bool) (string, []any) { func feedWhere(uid int64, rootsOnly bool) (string, []any) {
args := []any{} args := []any{}
where := []string{} where := []string{}
if uid != 0 { if uid != 0 {
where = append(where, "e.uid = ?")
args = append(args, uid) args = append(args, uid)
where = append(where, fmt.Sprintf("e.uid = $%d", len(args)))
} }
if rootsOnly { if rootsOnly {
where = append(where, "e.reply_to = 0") where = append(where, "e.reply_to = 0")
@@ -100,17 +97,6 @@ func feedWhere(uid int64, rootsOnly bool) (string, []any) {
return " WHERE " + strings.Join(where, " AND "), 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 // 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; // dieses Nutzers. rootsOnly blendet Antworten aus und sortiert nach Aktivität;
// sonst werden alle Beiträge chronologisch geliefert. // sonst werden alle Beiträge chronologisch geliefert.
@@ -129,7 +115,7 @@ func feedPage(page int, uid int64, rootsOnly bool) ([]Entry, error) {
} else { } else {
query += ` ORDER BY e.created_at DESC` query += ` ORDER BY e.created_at DESC`
} }
query += ` LIMIT ? OFFSET ?` query += fmt.Sprintf(` LIMIT $%d OFFSET $%d`, len(args)+1, len(args)+2)
args = append(args, feedPageSize, page*feedPageSize) args = append(args, feedPageSize, page*feedPageSize)
rows, err := db.Query(query, args...) rows, err := db.Query(query, args...)
@@ -152,7 +138,7 @@ func handleFeed(w http.ResponseWriter, r *http.Request) error {
// handleUserFeed liefert den Feed eines einzelnen Nutzers (öffentlich, inkl. Antworten). // handleUserFeed liefert den Feed eines einzelnen Nutzers (öffentlich, inkl. Antworten).
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
} }
@@ -215,9 +201,7 @@ type thread struct {
Replies []Entry Replies []Entry
} }
// threadData holt einen Thread aus der Datenbank. Von beiden Adaptern benutzt // threadData holt einen Thread aus der Datenbank.
// (JSON über handleThread, HTML über showThread) -- die rekursive CTE gibt es
// deshalb nur einmal.
func threadData(pid int64) (thread, error) { func threadData(pid int64) (thread, error) {
entry, err := entryByPID(pid) entry, err := entryByPID(pid)
if err != nil { if err != nil {
@@ -232,11 +216,11 @@ func threadData(pid int64) (thread, error) {
if entry.ReplyTo != 0 { if entry.ReplyTo != 0 {
rows, err := db.Query( rows, err := db.Query(
`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)
@@ -248,7 +232,7 @@ func threadData(pid int64) (thread, error) {
} }
} }
rows, err := db.Query(entrySelect+` WHERE e.reply_to = ? ORDER BY e.last_activity DESC LIMIT 100`, pid) rows, err := db.Query(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 thread{}, Internal(err)
} }
@@ -260,7 +244,6 @@ func threadData(pid int64) (thread, error) {
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 +251,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 +258,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,13 +270,13 @@ 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()
@@ -317,22 +286,28 @@ func handleCreateEntry(w http.ResponseWriter, r *http.Request) error {
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)
VALUES (?, ?, ?, ?, ?, ?, 0, ?)`, VALUES ($1, $2, $3, $4, $5, $6, 0, $7)
ON CONFLICT (pid) DO NOTHING`,
pid, uid, now, content, filepath, replyTo, now, pid, uid, now, content, filepath, replyTo, now,
) )
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 +316,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 {
@@ -360,18 +335,6 @@ func handleCreateEntry(w http.ResponseWriter, r *http.Request) error {
return Internal(err) return Internal(err)
} }
// 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,
"content": content, "content": content,
@@ -385,41 +348,43 @@ 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 { if _, err := db.Exec(`UPDATE entry SET content = $1 WHERE pid = $2`, content, pid); err != nil {
return Internal(err) return Internal(err)
} }
// 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 +393,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 = '', uid = 0 WHERE pid = $1`, pid,
); err != nil { ); err != nil {
return Internal(err) return Internal(err)
} }
@@ -444,12 +409,6 @@ 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,
// der Anker bleibt also gültig.
if !wantsJSON(r) {
redirectBack(w, r, "/feed")
return nil
}
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
return nil return nil
} }
@@ -477,16 +436,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 +455,9 @@ 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 // Bewusst kein Taxonomie-Fehler -- die Antwort trägt
// (retry_after, bump_count), die das Frontend zum Sperren des Buttons // Nutzdaten (retry_after, bump_count), die das Frontend zum Sperren des
// braucht -- ein reiner Fehlerkörper kann die nicht transportieren. // Buttons braucht, und der Cooldown ist ein regulärer Ausgang.
// 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 +469,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,
@@ -528,12 +486,12 @@ func voteTally(pid, uid int64) (left, right int, selected string) {
selected = "none" selected = "none"
if uid != 0 { if uid != 0 {
var cur string var cur string
if err := db.QueryRow(`SELECT mode FROM vote WHERE uid = ? AND pid = ?`, uid, pid).Scan(&cur); err == nil { if err := db.QueryRow(`SELECT mode FROM vote WHERE uid = $1 AND pid = $2`, uid, pid).Scan(&cur); err == nil {
selected = cur selected = cur
} }
} }
// Beide Zähler in einem Query statt zwei COUNTs. // Beide Zähler in einem Query statt zwei COUNTs.
rows, err := db.Query(`SELECT mode, COUNT(*) FROM vote WHERE pid = ? GROUP BY mode`, pid) rows, err := db.Query(`SELECT mode, COUNT(*) FROM vote WHERE pid = $1 GROUP BY mode`, pid)
if err != nil { if err != nil {
log.Printf("voteTally pid=%d: %v", pid, err) log.Printf("voteTally pid=%d: %v", pid, err)
return return
@@ -556,82 +514,6 @@ func voteTally(pid, uid int64) (left, right int, selected string) {
return 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) { func writeTally(w http.ResponseWriter, pid, uid int64) {
left, right, selected := voteTally(pid, uid) left, right, selected := voteTally(pid, uid)
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
@@ -673,54 +555,16 @@ func handleReport(w http.ResponseWriter, r *http.Request) error {
} }
reason := truncateRunes(strings.TrimSpace(r.FormValue("reason")), 500) reason := truncateRunes(strings.TrimSpace(r.FormValue("reason")), 500)
if _, err := db.Exec( if _, err := db.Exec(
`INSERT INTO report (pid, uid, reason, created_at, handled) VALUES (?, 0, ?, ?, 0)`, `INSERT INTO report (pid, uid, reason, created_at, handled) VALUES ($1, 0, $2, $3, 0)`,
pid, reason, time.Now().Unix(), pid, reason, time.Now().Unix(),
); err != nil { ); err != nil {
return Internal(err) 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}) writeJSON(w, http.StatusOK, map[string]any{"ok": true})
return nil 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:
// - keine bisherige Stimme -> neue Stimme // - keine bisherige Stimme -> neue Stimme
// - gleiche Stimme erneut -> Stimme zurückziehen (Toggle) // - gleiche Stimme erneut -> Stimme zurückziehen (Toggle)
@@ -746,13 +590,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,12 +604,6 @@ func handleVote(w http.ResponseWriter, r *http.Request) error {
} }
} }
// Ohne JavaScript zurück an den Beitrag, auf dem abgestimmt wurde -- der
// Anker in return_to hält die Position im Feed.
if !wantsJSON(r) {
redirectBack(w, r, "/feed")
return nil
}
writeTally(w, pid, uid) writeTally(w, pid, uid)
return nil return nil
} }
-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)
}
+11 -5
View File
@@ -5,22 +5,28 @@ go 1.25.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.52.0
golang.org/x/image v0.41.0 golang.org/x/image v0.41.0
modernc.org/sqlite v1.51.0 modernc.org/sqlite v1.59.0
) )
require ( require (
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.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/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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.22.0 // indirect
modernc.org/libc v1.72.3 // indirect golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.37.0 // indirect
modernc.org/libc v1.75.7 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.12.1 // indirect
) )
+42 -27
View File
@@ -1,3 +1,4 @@
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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
@@ -6,16 +7,24 @@ 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/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
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.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= 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/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=
@@ -24,8 +33,11 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
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=
@@ -34,41 +46,44 @@ golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
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/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
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.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ= modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w=
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A= modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= 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 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= 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.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= modernc.org/libc v1.75.7 h1:o3DTP9/0p9pKmY2WCKQaySW6wIiZhNM7wc2lUoyhfew=
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= modernc.org/libc v1.75.7/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= 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.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= 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 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= 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.59.0 h1:X1es1GpqBlS/5T+vbM4HLUdaa8OtQx468DF2vrx+38A=
modernc.org/sqlite v1.51.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= modernc.org/sqlite v1.59.0/go.mod h1:+paeT2A3iPRHkQDwG7oA6Tk0zQd5woMEI8q7orfry8k=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= 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 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+4 -8
View File
@@ -9,16 +9,12 @@ import "net/http"
// ResponseWriter. Mit "return err" kann das strukturell nicht mehr passieren. // 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 +26,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)
}
}
+135
View File
@@ -0,0 +1,135 @@
package main
import (
"database/sql"
"fmt"
"strings"
_ "modernc.org/sqlite"
)
// importTable beschreibt eine Tabelle für den einmaligen Umzug SQLite -> Postgres.
// Die Spalten heißen in beiden Datenbanken gleich; nur user heißt jetzt account.
type importTable struct {
src, dst string
cols []string
identity bool // hat eine id-Identity-Spalte, deren Sequenz nachgezogen werden muss
}
var importTables = []importTable{
{"user", "account", []string{"id", "uid", "username", "password", "created_at", "last_login", "avatar"}, true},
{"session", "session", []string{"id", "uid", "value", "created_at", "expires", "description"}, true},
{"entry", "entry", []string{"id", "pid", "uid", "created_at", "content", "filepath", "reply_to",
"reply_count", "last_activity", "deleted", "bump_count", "last_bump"}, true},
{"vote", "vote", []string{"id", "uid", "pid", "mode"}, true},
{"impression", "impression", []string{"day", "anon_ip", "path", "asn", "hits"}, false},
{"report", "report", []string{"id", "pid", "uid", "reason", "created_at", "handled"}, true},
}
// importSQLite kopiert alle Daten aus der SQLite-Datei path in die (per initDB
// bereits verbundene und mit Schema versehene) Postgres-Datenbank. Alles in einer
// Transaktion: entweder ist der Umzug vollständig oder es ist nichts passiert.
// Bricht ab, wenn Zieltabellen schon Daten enthalten -- ein zweiter Lauf soll
// nichts doppeln.
func importSQLite(path string) error {
src, err := sql.Open("sqlite", "file:"+path+"?mode=ro&immutable=1")
if err != nil {
return err
}
defer src.Close()
if err := src.Ping(); err != nil {
return fmt.Errorf("sqlite öffnen: %w", err)
}
var check string
if err := src.QueryRow(`PRAGMA integrity_check`).Scan(&check); err != nil || check != "ok" {
return fmt.Errorf("sqlite integrity_check: %q %v", check, err)
}
for _, t := range importTables {
var n int64
if err := db.QueryRow(`SELECT COUNT(*) FROM ` + t.dst).Scan(&n); err != nil {
return err
}
if n > 0 {
return fmt.Errorf("zieltabelle %s ist nicht leer (%d zeilen) -- abbruch", t.dst, n)
}
}
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, t := range importTables {
var exists int
src.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?`, t.src).Scan(&exists)
if exists == 0 {
fmt.Printf("%-10s -> %-10s fehlt in sqlite, übersprungen\n", t.src, t.dst)
continue
}
n, err := copyTable(src, tx, t)
if err != nil {
return fmt.Errorf("%s: %w", t.src, err)
}
var got int64
if err := tx.QueryRow(`SELECT COUNT(*) FROM ` + t.dst).Scan(&got); err != nil {
return err
}
if got != n {
return fmt.Errorf("%s: %d gelesen, aber %d in postgres", t.src, n, got)
}
if t.identity {
if _, err := tx.Exec(fmt.Sprintf(
`SELECT setval(pg_get_serial_sequence('%s', 'id'), COALESCE(MAX(id), 0) + 1, false) FROM %s`,
t.dst, t.dst)); err != nil {
return fmt.Errorf("%s: sequenz: %w", t.dst, err)
}
}
fmt.Printf("%-10s -> %-10s %6d zeilen\n", t.src, t.dst, n)
}
return tx.Commit()
}
func copyTable(src *sql.DB, tx *sql.Tx, t importTable) (int64, error) {
cols := strings.Join(t.cols, ", ")
rows, err := src.Query(`SELECT ` + cols + ` FROM "` + t.src + `"`)
if err != nil {
return 0, err
}
defer rows.Close()
ph := make([]string, len(t.cols))
for i := range ph {
ph[i] = fmt.Sprintf("$%d", i+1)
}
override := ""
if t.identity {
override = " OVERRIDING SYSTEM VALUE"
}
stmt, err := tx.Prepare(`INSERT INTO ` + t.dst + ` (` + cols + `)` + override +
` VALUES (` + strings.Join(ph, ", ") + `)`)
if err != nil {
return 0, err
}
defer stmt.Close()
var n int64
vals := make([]any, len(t.cols))
ptrs := make([]any, len(t.cols))
for i := range vals {
ptrs[i] = &vals[i]
}
for rows.Next() {
if err := rows.Scan(ptrs...); err != nil {
return n, err
}
if _, err := stmt.Exec(vals...); err != nil {
return n, fmt.Errorf("zeile %d: %w", n+1, err)
}
n++
}
return n, rows.Err()
}
+3 -1
View File
@@ -9,6 +9,9 @@
# 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.
# Postgres-Verbindung. PFLICHT. Sonderzeichen im Passwort URL-kodieren.
KVER_DSN=postgres://kver:passwort@host:5432/kontrollverlust?sslmode=disable
# Basic-Auth für die Moderationsseite /moderation. PFLICHT: ohne diese beiden # Basic-Auth für die Moderationsseite /moderation. PFLICHT: ohne diese beiden
# Werte bleibt /moderation gesperrt (HTTP 503, fail-closed). Langes Zufalls- # Werte bleibt /moderation gesperrt (HTTP 503, fail-closed). Langes Zufalls-
# passwort verwenden. # passwort verwenden.
@@ -20,7 +23,6 @@ KVER_MOD_PASS=hier-ein-langes-zufallspasswort
# 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.
+17 -29
View File
@@ -18,19 +18,32 @@ import (
func main() { func main() {
// KVER_DIR: Verzeichnis mit web/ und static/. Per Chdir werden alle // KVER_DIR: Verzeichnis mit web/ und static/. Per Chdir werden alle
// relativen Pfade (auch Uploads und ein relativer KVER_DB-Pfad) auf einmal // relativen Pfade (auch Uploads) auf einmal verankert -- die App ist damit
// verankert -- die App ist damit nicht mehr an das Start-CWD gebunden. // 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()
// Einmaliger Umzug: kver import-sqlite /pfad/zur/kver.db
if len(os.Args) == 3 && os.Args[1] == "import-sqlite" {
if err := importSQLite(os.Args[2]); err != nil {
log.Fatalf("import: %v", err)
}
log.Print("import abgeschlossen")
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()
@@ -103,9 +116,6 @@ func routes() http.Handler {
// zusätzlich, aber das Limit kappt automatisiertes Durchprobieren früh. // 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(httprate.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))
}) })
@@ -113,19 +123,6 @@ func routes() http.Handler {
// 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", 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("/auth/headerbar", handleHeaderbar)
r.Get("/stats", handleStats) r.Get("/stats", handleStats)
@@ -135,7 +132,7 @@ func routes() http.Handler {
// weil TOR-Zugriffe die Go-App direkt erreichen und NICHT über Caddy laufen -- // 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 // ein rein proxy-seitiger Schutz hätte diese Lücke offen gelassen. Caddy
// erzwingt zusätzlich TLS für den Clearnet-Weg. // erzwingt zusätzlich TLS für den Clearnet-Weg.
r.With(modBasicAuth).Get("/moderation", page(handleModeration)) r.With(modBasicAuth).Get("/moderation", api(handleModeration))
// HTML-Seiten: mit Impression-Tracking (anonymisiert, siehe stats.go). Nur // HTML-Seiten: mit Impression-Tracking (anonymisiert, siehe stats.go). Nur
// echte Seitenaufrufe, nicht API/Assets. // echte Seitenaufrufe, nicht API/Assets.
@@ -150,9 +147,6 @@ func routes() http.Handler {
// Melden: öffentlich (jeder darf melden, Moderationshilfe), aber pro IP // Melden: öffentlich (jeder darf melden, Moderationshilfe), aber pro IP
// gedrosselt, damit der auth-lose Endpunkt nicht zum Spam-Kanal wird. // gedrosselt, damit der auth-lose Endpunkt nicht zum Spam-Kanal wird.
r.With(httprate.LimitByIP(10, time.Minute)).Post("/entry/{pid}/report", api(handleReport)) 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.With(trackImpression).Get("/u/{username}", serveUserPage)
@@ -166,12 +160,6 @@ 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))
+2 -6
View File
@@ -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)
+4 -3
View File
@@ -14,7 +14,8 @@ import (
// laufen -- der proxy-seitige Schutz greift dort also nicht. Credentials kommen // laufen -- der proxy-seitige Schutz greift dort also nicht. Credentials kommen
// aus KVER_MOD_USER/KVER_MOD_PASS und werden einmalig beim Routing-Aufbau // 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), // gelesen. Fail-closed: sind sie nicht gesetzt, bleibt die Seite gesperrt (503),
// damit sie nie versehentlich offen ist. Vergleich über SHA-256 + ConstantTime, // damit sie nie versehentlich offen ist. Fehler gehen wie überall als JSON
// hinaus; der Browser zeigt den Login-Dialog trotzdem (WWW-Authenticate). Vergleich über SHA-256 + ConstantTime,
// damit weder Inhalt noch Länge der Credentials über Timing durchsickern. // damit weder Inhalt noch Länge der Credentials über Timing durchsickern.
func modBasicAuth(next http.Handler) http.Handler { func modBasicAuth(next http.Handler) http.Handler {
wantUser := envOr("KVER_MOD_USER", "") wantUser := envOr("KVER_MOD_USER", "")
@@ -26,7 +27,7 @@ func modBasicAuth(next http.Handler) http.Handler {
if wantUser == "" || wantPass == "" { if wantUser == "" || wantPass == "" {
log.Printf("moderation gesperrt: KVER_MOD_USER/KVER_MOD_PASS nicht gesetzt") log.Printf("moderation gesperrt: KVER_MOD_USER/KVER_MOD_PASS nicht gesetzt")
} }
return pageMiddleware(func(w http.ResponseWriter, r *http.Request) error { return guard(func(w http.ResponseWriter, r *http.Request) error {
if wantUser == "" || wantPass == "" { if wantUser == "" || wantPass == "" {
return Unavailable("mod.not_configured", "Die Moderationsseite ist nicht konfiguriert.") return Unavailable("mod.not_configured", "Die Moderationsseite ist nicht konfiguriert.")
} }
@@ -108,7 +109,7 @@ func handleModeration(w http.ResponseWriter, r *http.Request) error {
COALESCE(u.username, ''), COALESCE(e.content, '') COALESCE(u.username, ''), COALESCE(e.content, '')
FROM report r FROM report r
LEFT JOIN entry e ON e.pid = r.pid LEFT JOIN entry e ON e.pid = r.pid
LEFT JOIN user u ON u.uid = e.uid LEFT JOIN account u ON u.uid = e.uid
ORDER BY r.handled ASC, r.created_at DESC`) ORDER BY r.handled ASC, r.created_at DESC`)
if err != nil { if err != nil {
return Internal(err) return Internal(err)
+2 -36
View File
@@ -215,43 +215,9 @@ mitgelöscht, zugehörige Mediendateien best effort entfernt.
--- ---
## Datenbankschema (SQLite) ## Datenbankschema
```sql Postgres; maßgeblich ist die Konstante `schema` in `db.go`.
CREATE TABLE user (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uid INTEGER UNIQUE,
username TEXT UNIQUE,
password BLOB,
created_at INTEGER,
last_login INTEGER
);
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'
);
```
--- ---
+41 -12
View File
@@ -27,8 +27,8 @@ 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`): statisches Go-Binary (CGO aus), Laufzeit-
SQLite ist pures Go), Laufzeit-Image ist Alpine mit unprivilegiertem Nutzer. Image ist Alpine mit unprivilegiertem Nutzer.
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 +46,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,7 +58,7 @@ 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 |
## GeoLite2-ASN-Datenbank (optional) ## GeoLite2-ASN-Datenbank (optional)
@@ -77,9 +78,40 @@ 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.
## Umzug SQLite → Postgres (einmalig)
Dienst stoppen, dann mit dem neuen Image die alte Datei aus dem Volume
importieren. Der Import läuft in einer Transaktion, prüft die Zeilenzahlen und
bricht ab, wenn in Postgres schon Daten liegen:
```sh
systemctl --user stop kver
podman run --rm --env-file /etc/kver/kver.env \
-v kver-data:/app/data:ro \
kver import-sqlite /app/data/kver.db
systemctl --user start kver
```
Der Container braucht dabei (und im Betrieb) dieselbe Netzwerkanbindung an
Postgres wie der Dienst selbst.
## 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 +120,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
@@ -201,4 +230,4 @@ 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).
+1 -1
View File
@@ -10,6 +10,6 @@
- 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 - Links/Rechts Auswertung
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

+7 -7
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,8 +214,8 @@ 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,
) )
} }
@@ -225,21 +225,21 @@ func recordImpression(path, anonIP, asn string) {
// rechnen. Öffentlich, kein Auth. // rechnen. Öffentlich, kein Auth.
func handleStatsDetail(w http.ResponseWriter, r *http.Request) { func handleStatsDetail(w http.ResponseWriter, r *http.Request) {
var users, entries, toplevel, votesLeft, votesRight int64 var users, entries, toplevel, votesLeft, votesRight int64
db.QueryRow(`SELECT COUNT(*) FROM user`).Scan(&users) db.QueryRow(`SELECT COUNT(*) FROM account`).Scan(&users)
db.QueryRow(`SELECT COUNT(*) FROM entry WHERE deleted = 0`).Scan(&entries) db.QueryRow(`SELECT COUNT(*) FROM entry WHERE deleted = 0`).Scan(&entries)
db.QueryRow(`SELECT COUNT(*) FROM entry WHERE deleted = 0 AND reply_to = 0`).Scan(&toplevel) db.QueryRow(`SELECT COUNT(*) FROM entry WHERE deleted = 0 AND reply_to = 0`).Scan(&toplevel)
db.QueryRow(`SELECT COUNT(*) FROM vote WHERE mode = 'left'`).Scan(&votesLeft) db.QueryRow(`SELECT COUNT(*) FROM vote WHERE mode = 'left'`).Scan(&votesLeft)
db.QueryRow(`SELECT COUNT(*) FROM vote WHERE mode = 'right'`).Scan(&votesRight) db.QueryRow(`SELECT COUNT(*) FROM vote WHERE mode = 'right'`).Scan(&votesRight)
var imprTotal, visitorsTotal int64 var imprTotal, visitorsTotal int64
db.QueryRow(`SELECT COALESCE(SUM(hits), 0), COUNT(DISTINCT anon_ip) FROM impression`). db.QueryRow(`SELECT COALESCE(SUM(hits), 0)::bigint, COUNT(DISTINCT anon_ip) FROM impression`).
Scan(&imprTotal, &visitorsTotal) Scan(&imprTotal, &visitorsTotal)
// Tägliche Impressions + grobe Unique-Netze, jüngste 30 Tage zuerst. Das // Tägliche Impressions + grobe Unique-Netze, jüngste 30 Tage zuerst. Das
// Frontend dreht für die Achse auf aufsteigend. // Frontend dreht für die Achse auf aufsteigend.
daily := []map[string]any{} daily := []map[string]any{}
if rows, err := db.Query( if rows, err := db.Query(
`SELECT day, SUM(hits), COUNT(DISTINCT anon_ip) `SELECT day, SUM(hits)::bigint, COUNT(DISTINCT anon_ip)
FROM impression FROM impression
GROUP BY day GROUP BY day
ORDER BY day DESC ORDER BY day DESC
@@ -257,7 +257,7 @@ func handleStatsDetail(w http.ResponseWriter, r *http.Request) {
topASNs := []map[string]any{} topASNs := []map[string]any{}
if rows, err := db.Query( if rows, err := db.Query(
`SELECT CASE WHEN asn = '' THEN '(unbekannt)' ELSE asn END AS a, `SELECT CASE WHEN asn = '' THEN '(unbekannt)' ELSE asn END AS a,
SUM(hits) AS h, SUM(hits)::bigint AS h,
COUNT(DISTINCT anon_ip) COUNT(DISTINCT anon_ip)
FROM impression FROM impression
GROUP BY a GROUP BY a
-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}}
+22 -22
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,9 +44,12 @@ 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
} }
@@ -57,7 +57,7 @@ func createUser(username, password string) (int64, error) {
// idFromUsername liefert die interne id oder 0, wenn der Nutzer nicht existiert. // idFromUsername liefert die interne id oder 0, wenn der Nutzer nicht existiert.
func idFromUsername(username string) int64 { func idFromUsername(username string) int64 {
var id int64 var id int64
if err := db.QueryRow(`SELECT id FROM user WHERE username = ?`, username).Scan(&id); err != nil { if err := db.QueryRow(`SELECT id FROM account WHERE username = $1`, username).Scan(&id); err != nil {
return 0 return 0
} }
return id return id
@@ -74,7 +74,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
} }
@@ -91,7 +91,7 @@ func handleUserPage(w http.ResponseWriter, r *http.Request) error {
// registrierte Nutzer und nicht-gelöschte Beiträge. // registrierte Nutzer und nicht-gelöschte Beiträge.
func handleStats(w http.ResponseWriter, r *http.Request) { func handleStats(w http.ResponseWriter, r *http.Request) {
var users, entries int64 var users, entries int64
db.QueryRow(`SELECT COUNT(*) FROM user`).Scan(&users) db.QueryRow(`SELECT COUNT(*) FROM account`).Scan(&users)
db.QueryRow(`SELECT COUNT(*) FROM entry WHERE deleted = 0`).Scan(&entries) db.QueryRow(`SELECT COUNT(*) FROM entry WHERE deleted = 0`).Scan(&entries)
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"users": users, "users": users,
@@ -105,7 +105,7 @@ func handleUserInfo(w http.ResponseWriter, r *http.Request) error {
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,7 +131,7 @@ 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 {
@@ -141,9 +141,9 @@ func handleUserRename(w http.ResponseWriter, r *http.Request) error {
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 +173,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)
} }
@@ -195,7 +195,7 @@ 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 +206,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 +216,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 +225,10 @@ 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 vote WHERE uid = $1`,
`UPDATE entry SET deleted = 1, content = '', filepath = '', uid = 0 WHERE uid = ?`, `UPDATE entry SET deleted = 1, content = '', filepath = '', uid = 0 WHERE uid = $1`,
`DELETE FROM user WHERE uid = ?`, `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()
+1 -18
View File
@@ -305,8 +305,7 @@ img.avatar-lg { width: 72px; height: 72px; }
/* Melden sitzt rechtsbündig in der Meta-Zeile (weit weg von den Interaktionen). /* 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 margin-left:auto schiebt es im Byline-Flex ganz nach rechts; position:relative
verankert das aufklappende Grund-Feld. Der <details>-Zweischritt (erst öffnen, verankert das aufklappende Grund-Feld. Der <details>-Zweischritt (erst öffnen,
dann absenden) verhindert versehentliche Meldungen; nativ (details/summary + dann absenden) verhindert versehentliche Meldungen. */
echtes Formular), damit es später auch ohne JS funktioniert. */
details.report { details.report {
margin-left: auto; margin-left: auto;
position: relative; position: relative;
@@ -440,19 +439,3 @@ hr {
flex-basis: 13rem; flex-basis: 13rem;
white-space: normal; 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;
}
+1 -4
View File
@@ -21,10 +21,7 @@
<main> <main>
<noscript> <noscript>
<p class="msg">Diese Startseite braucht derzeit noch JavaScript. Ohne JS <p class="msg">Diese Seite braucht JavaScript.</p>
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> </noscript>
<!-- Login / Registrierung --> <!-- Login / Registrierung -->
+1 -2
View File
@@ -78,8 +78,7 @@ registerForm.addEventListener("submit", async (e) => {
const feed = createFeed( const feed = createFeed(
document.getElementById("feed"), document.getElementById("feed"),
document.getElementById("sentinel"), document.getElementById("sentinel"),
(page) => `/entry/feed/${page}`, (page) => `/entry/feed/${page}`
{ adEvery: 5 }
); );
// --- Neuer Beitrag --- // --- Neuer Beitrag ---
+6 -37
View File
@@ -1,8 +1,6 @@
"use strict"; "use strict";
// Accept: application/json markiert diese Aufrufe als API-Zugriffe. Endpunkte, // Die API antwortet ausschließlich mit JSON; der Accept-Header sagt das explizit.
// 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 JSON_HEADERS = { Accept: "application/json" };
const api = { const api = {
@@ -230,9 +228,8 @@ function setupBump(el, e) {
// setupReport verdrahtet den Melden-Aufklapper. Der <details>-Zweischritt // setupReport verdrahtet den Melden-Aufklapper. Der <details>-Zweischritt
// (öffnen -> absenden) verhindert versehentliche Meldungen und hält das Grund- // (öffnen -> absenden) verhindert versehentliche Meldungen und hält das Grund-
// Feld eingeklappt. Das Formular ist ein echtes method=post-Form, damit es // Feld eingeklappt. Der Submit wird abgefangen, um Inline-Feedback zu geben
// später auch ohne JS funktioniert; hier fangen wir den Submit nur ab, um // statt die Seite neu zu laden. stopPropagation, weil
// Inline-Feedback zu geben statt die Seite neu zu laden. stopPropagation, weil
// die Karte sonst bei jedem Klick nach /e/{pid} navigieren würde. // die Karte sonst bei jedem Klick nach /e/{pid} navigieren würde.
function setupReport(el, pid) { function setupReport(el, pid) {
const details = el.querySelector("details.report"); const details = el.querySelector("details.report");
@@ -261,35 +258,12 @@ function setupReport(el, pid) {
}); });
} }
// 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 // 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 // urlFor(page). Gibt { reset } zurück, um den Feed neu zu laden.
// Werbebanner ein — nur im Top-Level-Feed, nicht in User-/Thread-Feeds. function createFeed(feedEl, sentinelEl, urlFor) {
// 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 page = 0;
let loading = false; let loading = false;
let done = false; let done = false;
let shown = 0; // fortlaufende Beitragszahl über Seitengrenzen hinweg
async function loadMore() { async function loadMore() {
if (loading || done) return; if (loading || done) return;
@@ -303,11 +277,7 @@ function createFeed(feedEl, sentinelEl, urlFor, opts = {}) {
end.textContent = "YOU REACHED THE END!"; end.textContent = "YOU REACHED THE END!";
feedEl.appendChild(end); feedEl.appendChild(end);
} else { } else {
entries.forEach((e) => { entries.forEach((e) => feedEl.appendChild(renderEntry(e)));
feedEl.appendChild(renderEntry(e));
shown++;
if (adEvery && shown % adEvery === 0) feedEl.appendChild(renderAd());
});
page++; page++;
} }
loading = false; loading = false;
@@ -326,7 +296,6 @@ function createFeed(feedEl, sentinelEl, urlFor, opts = {}) {
function reset() { function reset() {
page = 0; page = 0;
done = false; done = false;
shown = 0;
feedEl.innerHTML = ""; feedEl.innerHTML = "";
loadMore(); loadMore();
} }