Compare commits
3
Commits
c66df283d1
...
2714fdb1a5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2714fdb1a5 | ||
|
|
c6ab082f8c | ||
|
|
eccddcebe6 |
+4
-7
@@ -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"]
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 |
@@ -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
|
||||||
|
|||||||
@@ -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}}
|
|
||||||
@@ -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}}
|
|
||||||
@@ -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}}
|
|
||||||
@@ -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}}
|
|
||||||
@@ -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}}
|
|
||||||
@@ -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}}
|
|
||||||
@@ -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}}
|
|
||||||
@@ -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}}
|
|
||||||
@@ -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}}
|
|
||||||
@@ -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}}
|
|
||||||
@@ -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(¤t); err != nil {
|
if err := db.QueryRow(`SELECT username FROM account WHERE uid = $1`, uid).Scan(¤t); 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
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user