Umbau-Projekt (SSR und Rest)
This commit is contained in:
@@ -10,6 +10,7 @@ kver
|
|||||||
*.db-shm
|
*.db-shm
|
||||||
*.db-wal
|
*.db-wal
|
||||||
*.db.bak
|
*.db.bak
|
||||||
|
*.env
|
||||||
static/media/
|
static/media/
|
||||||
migrate.sh
|
migrate.sh
|
||||||
Containerfile
|
Containerfile
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ 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.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
crand "crypto/rand"
|
crand "crypto/rand"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -53,34 +54,92 @@ func getSession(r *http.Request) (*Session, bool) {
|
|||||||
return &s, true
|
return &s, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleLogin(w http.ResponseWriter, r *http.Request) {
|
// errBadCredentials ist für "Nutzer existiert nicht" und "Passwort falsch"
|
||||||
|
// absichtlich derselbe Fehler -- sonst wird der Endpunkt zum Nutzernamen-Orakel.
|
||||||
|
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 {
|
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 {
|
||||||
|
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 !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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
username := sanitizeUsername(r.FormValue("user"))
|
username := sanitizeUsername(r.FormValue("user"))
|
||||||
if len(username) < 3 {
|
if len(username) < 3 {
|
||||||
writeError(w, http.StatusBadRequest, "Der Nutzername ist zu kurz.")
|
return fail(username, Invalid("user.name_short", "Der Nutzername ist zu kurz.").At("user"))
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Gleiche Meldung für "Nutzer existiert nicht" und "Passwort falsch": sonst
|
||||||
|
// wird der Endpunkt zum Nutzernamen-Orakel.
|
||||||
var uid int64
|
var uid int64
|
||||||
var hash []byte
|
var hash []byte
|
||||||
err := db.QueryRow(`SELECT uid, password FROM user WHERE username = ?`, username).Scan(&uid, &hash)
|
err := db.QueryRow(`SELECT uid, password FROM user WHERE username = ?`, username).Scan(&uid, &hash)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
writeError(w, http.StatusUnauthorized, "Nutzername oder Passwort ist falsch.")
|
return fail(username, errBadCredentials)
|
||||||
return
|
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "Datenbankfehler")
|
return Internal(err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if bcrypt.CompareHashAndPassword(hash, []byte(r.FormValue("pass"))) != nil {
|
if bcrypt.CompareHashAndPassword(hash, []byte(r.FormValue("pass"))) != nil {
|
||||||
writeError(w, http.StatusUnauthorized, "Nutzername oder Passwort ist falsch.")
|
return fail(username, errBadCredentials)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := startSession(w, r, uid); err != nil {
|
||||||
|
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"})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// startSession legt eine Session an und setzt den Cookie. Von Login und
|
||||||
|
// 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 {
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
// timeout ist clientgesteuert -> serverseitig deckeln, damit niemand eine
|
// timeout ist clientgesteuert -> serverseitig deckeln, damit niemand eine
|
||||||
// quasi-unbegrenzte Session anlegen kann. Default 1 Tag, Maximum 30 Tage.
|
// quasi-unbegrenzte Session anlegen kann. Default 1 Tag, Maximum 30 Tage.
|
||||||
@@ -96,8 +155,7 @@ func handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
token, err := newToken()
|
token, err := newToken()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "Token konnte nicht erzeugt werden")
|
return Internal(err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
db.Exec(`DELETE FROM session WHERE expires < ?`, now)
|
db.Exec(`DELETE FROM session WHERE expires < ?`, now)
|
||||||
@@ -105,8 +163,7 @@ func handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
`INSERT INTO session (uid, created_at, expires, description, value) VALUES (?, ?, ?, ?, ?)`,
|
`INSERT INTO session (uid, created_at, expires, description, value) VALUES (?, ?, ?, ?, ?)`,
|
||||||
uid, now, expires, "", token,
|
uid, now, expires, "", token,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "Session konnte nicht erstellt werden")
|
return Internal(err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
db.Exec(`UPDATE user SET last_login = ? WHERE uid = ?`, now, uid)
|
db.Exec(`UPDATE user SET last_login = ? WHERE uid = ?`, now, uid)
|
||||||
|
|
||||||
@@ -119,7 +176,7 @@ func handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
Secure: isHTTPS(r),
|
Secure: isHTTPS(r),
|
||||||
SameSite: http.SameSiteLaxMode,
|
SameSite: http.SameSiteLaxMode,
|
||||||
})
|
})
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleLogout(w http.ResponseWriter, r *http.Request) {
|
func handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -139,43 +196,94 @@ func handleLogout(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleNewUser(w http.ResponseWriter, r *http.Request) {
|
// handleNewUser sammelt alle Validierungsfehler ein, statt beim ersten
|
||||||
var errs []string
|
// abzubrechen -- der Nutzer soll nicht Feld für Feld durchprobieren müssen.
|
||||||
|
// Deshalb hier Fields (Feld -> Meldung) statt eines einzelnen Field.
|
||||||
|
func handleNewUser(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
json := wantsJSON(r)
|
||||||
|
fields := map[string]string{}
|
||||||
|
|
||||||
username := sanitizeUsername(r.FormValue("user"))
|
username := sanitizeUsername(r.FormValue("user"))
|
||||||
if len(username) < 3 {
|
if len(username) < 3 {
|
||||||
errs = append(errs, "Der Nutzername ist zu kurz.")
|
fields["user"] = "Der Nutzername ist zu kurz."
|
||||||
}
|
} else if idFromUsername(username) != 0 {
|
||||||
if idFromUsername(username) != 0 {
|
fields["user"] = "Der Nutzername wird bereits verwendet."
|
||||||
errs = append(errs, "Der Nutzername wird bereits verwendet.")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pass1 := r.FormValue("pass1")
|
pass1 := r.FormValue("pass1")
|
||||||
pass2 := r.FormValue("pass2")
|
pass2 := r.FormValue("pass2")
|
||||||
|
switch {
|
||||||
|
case len(pass1) < 10:
|
||||||
|
fields["pass1"] = "Das Passwort ist kürzer als 10 Zeichen."
|
||||||
|
// bcrypt verarbeitet maximal 72 Bytes; GenerateFromPassword gäbe darüber
|
||||||
|
// einen Fehler -> hier abfangen statt als generischer 500 zu enden.
|
||||||
|
case len(pass1) > 72:
|
||||||
|
fields["pass1"] = "Das Passwort ist länger als 72 Bytes."
|
||||||
|
}
|
||||||
if pass1 != pass2 {
|
if pass1 != pass2 {
|
||||||
errs = append(errs, "Passwort und Passwortbestätigung sind ungleich.")
|
fields["pass2"] = "Passwort und Passwortbestätigung sind ungleich."
|
||||||
}
|
|
||||||
if len(pass1) < 10 {
|
|
||||||
errs = append(errs, "Das Passwort ist kürzer als 10 Zeichen.")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(errs) > 0 {
|
// fail wählt die zum Client passende Fehlerdarstellung -- wie bei
|
||||||
writeJSON(w, http.StatusBadRequest, map[string]any{"errors": errs})
|
// handleLogin: JSON-Clients bekommen den Fehler über den Wrapper, ein
|
||||||
return
|
// 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 err := createUser(username, pass1); err != nil {
|
if len(fields) > 0 {
|
||||||
writeError(w, http.StatusInternalServerError, "Account konnte nicht erstellt werden")
|
e := Invalid("user.invalid", "Die Angaben sind unvollständig oder ungültig.")
|
||||||
return
|
e.Fields = fields
|
||||||
|
return fail(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
uid, err := createUser(username, pass1)
|
||||||
|
if errors.Is(err, errUsernameTaken) {
|
||||||
|
// Rennen zwischen Vorab-Prüfung und INSERT: der UNIQUE-Constraint hat
|
||||||
|
// gegriffen -> als normale Validierungsmeldung statt 500 melden.
|
||||||
|
return fail(errUsernameTaken)
|
||||||
|
} else if err != nil {
|
||||||
|
return Internal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ohne JavaScript direkt einloggen und weiterleiten: das JS-Frontend macht
|
||||||
|
// genau das (zweiter Aufruf auf /auth/login), und ein zweites Formular nur
|
||||||
|
// zum Anmelden wäre eine unnötige Hürde.
|
||||||
|
if !json {
|
||||||
|
if err := startSession(w, r, uid); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusCreated, map[string]string{"username": username})
|
writeJSON(w, http.StatusCreated, map[string]string{"username": username})
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleSessionInfo(w http.ResponseWriter, r *http.Request) {
|
// 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 {
|
||||||
s, ok := getSession(r)
|
s, ok := getSession(r)
|
||||||
if !ok {
|
if !ok {
|
||||||
writeError(w, http.StatusUnauthorized, "Invalid session")
|
return Unauth("auth.no_session", "Keine gültige Sitzung.")
|
||||||
return
|
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"uid": s.UID,
|
"uid": s.UID,
|
||||||
@@ -183,6 +291,7 @@ func handleSessionInfo(w http.ResponseWriter, r *http.Request) {
|
|||||||
"expires": s.Expires,
|
"expires": s.Expires,
|
||||||
"description": s.Description,
|
"description": s.Description,
|
||||||
})
|
})
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleHeaderbar(w http.ResponseWriter, r *http.Request) {
|
func handleHeaderbar(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -67,6 +67,20 @@ CREATE TABLE IF NOT EXISTS impression (
|
|||||||
PRIMARY KEY (day, anon_ip, path)
|
PRIMARY KEY (day, anon_ip, path)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Meldungen zu Beiträgen (DSA-Melde-/Abhilfeverfahren). Bewusst ohne Auth:
|
||||||
|
-- jeder darf melden, weil es eine Moderationshilfe ist. uid ist der Melder,
|
||||||
|
-- vorerst immer 0 (anonym) -- die Spalte existiert nur, um bei Spam später den
|
||||||
|
-- Absender nachziehen zu können, ohne das Schema zu ändern. handled: 0 offen,
|
||||||
|
-- 1 von der Moderation erledigt. reason ist ein optionaler Freitext.
|
||||||
|
CREATE TABLE IF NOT EXISTS report (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
pid INTEGER NOT NULL,
|
||||||
|
uid INTEGER NOT NULL DEFAULT 0,
|
||||||
|
reason TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
handled INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
-- Indizes für die häufigen Zugriffspfade; ohne sie werden Feed (ORDER BY
|
-- Indizes für die häufigen Zugriffspfade; ohne sie werden Feed (ORDER BY
|
||||||
-- last_activity), Thread (WHERE reply_to) und Vote-Zähler (WHERE pid) mit
|
-- last_activity), Thread (WHERE reply_to) und Vote-Zähler (WHERE pid) mit
|
||||||
-- wachsender Tabelle zu Full-Table-Scans. username/session.value/vote(uid,pid)
|
-- wachsender Tabelle zu Full-Table-Scans. username/session.value/vote(uid,pid)
|
||||||
@@ -76,6 +90,7 @@ CREATE INDEX IF NOT EXISTS idx_entry_reply_to ON entry(reply_to);
|
|||||||
CREATE INDEX IF NOT EXISTS idx_entry_uid ON entry(uid);
|
CREATE INDEX IF NOT EXISTS idx_entry_uid ON entry(uid);
|
||||||
CREATE INDEX IF NOT EXISTS idx_vote_pid ON vote(pid);
|
CREATE INDEX IF NOT EXISTS idx_vote_pid ON vote(pid);
|
||||||
CREATE INDEX IF NOT EXISTS idx_session_uid ON session(uid);
|
CREATE INDEX IF NOT EXISTS idx_session_uid ON session(uid);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_report_open ON report(handled, created_at);
|
||||||
`
|
`
|
||||||
|
|
||||||
func initDB(dsn string) error {
|
func initDB(dsn string) error {
|
||||||
|
|||||||
+1068
-4
File diff suppressed because it is too large
Load Diff
@@ -2,15 +2,18 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
"image/gif"
|
"image/gif"
|
||||||
"image/jpeg"
|
"image/jpeg"
|
||||||
_ "image/png"
|
_ "image/png"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -71,19 +74,17 @@ func entryByPID(pid int64) (Entry, error) {
|
|||||||
return e, err
|
return e, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// feedPage liefert eine Feed-Seite (20 Beiträge). Ist uid != 0, nur die Beiträge
|
const (
|
||||||
// dieses Nutzers. rootsOnly blendet Antworten aus und sortiert nach Aktivität;
|
feedPageSize = 20
|
||||||
// sonst werden alle Beiträge chronologisch geliefert.
|
// maxFeedPage deckelt den Offset: sehr große Seitenzahlen zwingen SQLite
|
||||||
func feedPage(page int, uid int64, rootsOnly bool) ([]Entry, error) {
|
// sonst dazu, entsprechend viele Zeilen zu überspringen.
|
||||||
if page < 0 {
|
maxFeedPage = 100
|
||||||
page = 0
|
)
|
||||||
}
|
|
||||||
if page > 100 {
|
|
||||||
page = 100
|
|
||||||
}
|
|
||||||
const count = 20
|
|
||||||
|
|
||||||
query := entrySelect
|
// feedWhere baut die Filterbedingung für Feed-Abfragen. Von feedPage und
|
||||||
|
// 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) {
|
||||||
args := []any{}
|
args := []any{}
|
||||||
where := []string{}
|
where := []string{}
|
||||||
if uid != 0 {
|
if uid != 0 {
|
||||||
@@ -93,16 +94,43 @@ func feedPage(page int, uid int64, rootsOnly bool) ([]Entry, error) {
|
|||||||
if rootsOnly {
|
if rootsOnly {
|
||||||
where = append(where, "e.reply_to = 0")
|
where = append(where, "e.reply_to = 0")
|
||||||
}
|
}
|
||||||
if len(where) > 0 {
|
if len(where) == 0 {
|
||||||
query += " WHERE " + strings.Join(where, " AND ")
|
return "", args
|
||||||
}
|
}
|
||||||
|
return " WHERE " + strings.Join(where, " AND "), args
|
||||||
|
}
|
||||||
|
|
||||||
|
// feedTotal zählt die Beiträge, die feedPage mit denselben Filtern liefern
|
||||||
|
// würde -- Grundlage für "Seite n von m" im serverseitig gerenderten Feed.
|
||||||
|
func feedTotal(uid int64, rootsOnly bool) (int, error) {
|
||||||
|
where, args := feedWhere(uid, rootsOnly)
|
||||||
|
var n int
|
||||||
|
err := db.QueryRow(
|
||||||
|
`SELECT COUNT(*) FROM entry e LEFT JOIN user u ON u.uid = e.uid`+where, args...,
|
||||||
|
).Scan(&n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// feedPage liefert eine Feed-Seite (feedPageSize Beiträge). Ist uid != 0, nur die Beiträge
|
||||||
|
// dieses Nutzers. rootsOnly blendet Antworten aus und sortiert nach Aktivität;
|
||||||
|
// sonst werden alle Beiträge chronologisch geliefert.
|
||||||
|
func feedPage(page int, uid int64, rootsOnly bool) ([]Entry, error) {
|
||||||
|
if page < 0 {
|
||||||
|
page = 0
|
||||||
|
}
|
||||||
|
if page > maxFeedPage {
|
||||||
|
page = maxFeedPage
|
||||||
|
}
|
||||||
|
|
||||||
|
where, args := feedWhere(uid, rootsOnly)
|
||||||
|
query := entrySelect + where
|
||||||
if rootsOnly {
|
if rootsOnly {
|
||||||
query += ` ORDER BY e.last_activity DESC`
|
query += ` ORDER BY e.last_activity DESC`
|
||||||
} else {
|
} else {
|
||||||
query += ` ORDER BY e.created_at DESC`
|
query += ` ORDER BY e.created_at DESC`
|
||||||
}
|
}
|
||||||
query += ` LIMIT ? OFFSET ?`
|
query += ` LIMIT ? OFFSET ?`
|
||||||
args = append(args, count, page*count)
|
args = append(args, feedPageSize, page*feedPageSize)
|
||||||
|
|
||||||
rows, err := db.Query(query, args...)
|
rows, err := db.Query(query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -111,31 +139,30 @@ func feedPage(page int, uid int64, rootsOnly bool) ([]Entry, error) {
|
|||||||
return scanEntries(rows)
|
return scanEntries(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleFeed(w http.ResponseWriter, r *http.Request) {
|
func handleFeed(w http.ResponseWriter, r *http.Request) error {
|
||||||
page, _ := strconv.Atoi(chi.URLParam(r, "page"))
|
page, _ := strconv.Atoi(chi.URLParam(r, "page"))
|
||||||
entries, err := feedPage(page, 0, true)
|
entries, err := feedPage(page, 0, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "Datenbankfehler")
|
return Internal(err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, entries)
|
writeJSON(w, http.StatusOK, entries)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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) {
|
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 user WHERE username = ?`, chi.URLParam(r, "username")).Scan(&uid); err != nil {
|
||||||
writeError(w, http.StatusNotFound, "Nutzer nicht gefunden")
|
return errUserNotFound
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
page, _ := strconv.Atoi(chi.URLParam(r, "page"))
|
page, _ := strconv.Atoi(chi.URLParam(r, "page"))
|
||||||
entries, err := feedPage(page, uid, false)
|
entries, err := feedPage(page, uid, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "Datenbankfehler")
|
return Internal(err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, entries)
|
writeJSON(w, http.StatusOK, entries)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// serveEntryPage liefert die statische Focus-Seite; das Frontend lädt die Daten
|
// serveEntryPage liefert die statische Focus-Seite; das Frontend lädt die Daten
|
||||||
@@ -144,127 +171,205 @@ func serveEntryPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.ServeFile(w, r, "web/entry.html")
|
http.ServeFile(w, r, "web/entry.html")
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleThread liefert einen Beitrag mit seiner Ahnenkette (Root zuerst) und den
|
var (
|
||||||
// direkten Antworten (öffentlich).
|
errEntryNotFound = NotFound("entry.not_found", "Beitrag nicht gefunden")
|
||||||
func handleThread(w http.ResponseWriter, r *http.Request) {
|
errEntryNotOwned = Forbidden("entry.not_owner", "Das ist nicht dein Beitrag.")
|
||||||
|
)
|
||||||
|
|
||||||
|
// pidParam liest den {pid}-Routenparameter. Eigene Funktion, weil praktisch
|
||||||
|
// jeder Entry-Handler damit anfängt und die Fehlermeldung überall dieselbe ist.
|
||||||
|
func pidParam(r *http.Request) (int64, error) {
|
||||||
pid, err := strconv.ParseInt(chi.URLParam(r, "pid"), 10, 64)
|
pid, err := strconv.ParseInt(chi.URLParam(r, "pid"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "Ungültige pid")
|
return 0, Invalid("entry.bad_pid", "Ungültige pid").WithCause(err)
|
||||||
return
|
}
|
||||||
|
return pid, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleThread liefert einen Beitrag mit seiner Ahnenkette (Root zuerst) und den
|
||||||
|
// direkten Antworten (öffentlich).
|
||||||
|
func handleThread(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
pid, err := pidParam(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
t, err := threadData(pid)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"entry": t.Entry,
|
||||||
|
"ancestors": t.Ancestors,
|
||||||
|
"replies": t.Replies,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// thread ist ein Beitrag mit seinem Kontext: Ahnenkette (Root zuerst) und
|
||||||
|
// direkte Antworten.
|
||||||
|
type thread struct {
|
||||||
|
Entry Entry
|
||||||
|
Ancestors []Entry
|
||||||
|
Replies []Entry
|
||||||
|
}
|
||||||
|
|
||||||
|
// threadData holt einen Thread aus der Datenbank. Von beiden Adaptern benutzt
|
||||||
|
// (JSON über handleThread, HTML über showThread) -- die rekursive CTE gibt es
|
||||||
|
// deshalb nur einmal.
|
||||||
|
func threadData(pid int64) (thread, error) {
|
||||||
entry, err := entryByPID(pid)
|
entry, err := entryByPID(pid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusNotFound, "Beitrag nicht gefunden")
|
return thread{}, errEntryNotFound
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
t := thread{Entry: entry, Ancestors: []Entry{}, Replies: []Entry{}}
|
||||||
|
|
||||||
// Ahnenkette von Eltern bis Root hochlaufen, dann umdrehen (Root zuerst).
|
// Ahnenkette (Root zuerst) per rekursiver CTE in einem Query statt bis zu
|
||||||
ancestors := []Entry{}
|
// 50 Einzelqueries. depth zählt vom Beitrag aufwärts; ORDER BY depth DESC
|
||||||
for cur := entry.ReplyTo; cur != 0 && len(ancestors) < 50; {
|
// dreht die Kette auf Root-zuerst. Das Limit deckelt die Kettenlänge und
|
||||||
a, err := entryByPID(cur)
|
// bricht auch einen (theoretischen) Zyklus ab.
|
||||||
|
if entry.ReplyTo != 0 {
|
||||||
|
rows, err := db.Query(
|
||||||
|
`WITH RECURSIVE chain(pid, depth) AS (
|
||||||
|
SELECT ?, 1
|
||||||
|
UNION ALL
|
||||||
|
SELECT e.reply_to, c.depth + 1
|
||||||
|
FROM entry e JOIN chain c ON e.pid = c.pid
|
||||||
|
WHERE e.reply_to != 0 AND c.depth < 50
|
||||||
|
)`+entrySelect+`
|
||||||
|
JOIN chain c ON c.pid = e.pid
|
||||||
|
ORDER BY c.depth DESC`, entry.ReplyTo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
break
|
return thread{}, Internal(err)
|
||||||
}
|
}
|
||||||
ancestors = append(ancestors, a)
|
if t.Ancestors, err = scanEntries(rows); err != nil {
|
||||||
cur = a.ReplyTo
|
return thread{}, Internal(err)
|
||||||
}
|
}
|
||||||
for i, j := 0, len(ancestors)-1; i < j; i, j = i+1, j-1 {
|
|
||||||
ancestors[i], ancestors[j] = ancestors[j], ancestors[i]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 = ? ORDER BY e.last_activity DESC LIMIT 100`, pid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "Datenbankfehler")
|
return thread{}, Internal(err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
replies, err := scanEntries(rows)
|
if t.Replies, err = scanEntries(rows); err != nil {
|
||||||
if err != nil {
|
return thread{}, Internal(err)
|
||||||
writeError(w, http.StatusInternalServerError, "Datenbankfehler")
|
}
|
||||||
return
|
return t, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
func handleCreateEntry(w http.ResponseWriter, r *http.Request) error {
|
||||||
"entry": entry,
|
|
||||||
"ancestors": ancestors,
|
|
||||||
"replies": replies,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleCreateEntry(w http.ResponseWriter, r *http.Request) {
|
|
||||||
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
|
||||||
// Temp-Dateien auf die Platte.
|
// Temp-Dateien auf die Platte.
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes)
|
r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes)
|
||||||
if err := r.ParseMultipartForm(16 << 20); err != nil && err != http.ErrNotMultipart {
|
parseErr := r.ParseMultipartForm(16 << 20)
|
||||||
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
|
if parseErr != nil && parseErr != http.ErrNotMultipart {
|
||||||
return
|
// 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)
|
||||||
content := strings.TrimSpace(r.FormValue("content"))
|
|
||||||
if len(content) > 1000 {
|
|
||||||
content = content[:1000]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
content := truncateRunes(strings.TrimSpace(r.FormValue("content")), 1000)
|
||||||
|
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 {
|
||||||
writeError(w, http.StatusBadRequest, "Elternbeitrag nicht gefunden")
|
return fail(Invalid("entry.parent_missing", "Elternbeitrag nicht gefunden").At("reply_to"))
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
filepath := ""
|
filepath := ""
|
||||||
if r.MultipartForm != nil {
|
if hadFile {
|
||||||
if files := r.MultipartForm.File["file"]; len(files) > 0 {
|
stored, err := storeImage(r.MultipartForm.File["file"][0])
|
||||||
if stored, err := storeImage(files[0]); err == nil {
|
if err != nil {
|
||||||
|
// Nicht stillschweigend ohne Bild posten -- der Nutzer soll
|
||||||
|
// erfahren, dass sein Bild abgelehnt wurde.
|
||||||
|
return fail(Invalid("image.rejected", "Bild konnte nicht verarbeitet werden.").At("file").WithCause(err))
|
||||||
|
}
|
||||||
filepath = stored
|
filepath = stored
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if filepath == "" && content == "" {
|
if filepath == "" && content == "" {
|
||||||
writeError(w, http.StatusBadRequest, "Leerer Beitrag!")
|
return fail(Invalid("entry.empty", "Leerer Beitrag!").At("content"))
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pid := int64(rand.IntN(999999999999))
|
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
|
|
||||||
tx, err := db.Begin()
|
tx, err := db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "Beitrag konnte nicht gespeichert werden")
|
return Internal(err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if _, err := tx.Exec(
|
// 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.
|
||||||
|
var pid int64
|
||||||
|
for try := 0; ; try++ {
|
||||||
|
pid = int64(rand.IntN(999999999999)) + 1
|
||||||
|
_, 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 (?, ?, ?, ?, ?, ?, 0, ?)`,
|
||||||
pid, uid, now, content, filepath, replyTo, now,
|
pid, uid, now, content, filepath, replyTo, now,
|
||||||
); err != nil {
|
)
|
||||||
tx.Rollback()
|
if err == nil {
|
||||||
writeError(w, http.StatusInternalServerError, "Beitrag konnte nicht gespeichert werden")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Antwort: reply_count und last_activity aller Vorfahren bis zum Root anheben.
|
|
||||||
for cur, i := replyTo, 0; cur != 0 && i < 50; i++ {
|
|
||||||
if _, err := tx.Exec(
|
|
||||||
`UPDATE entry SET reply_count = reply_count + 1,
|
|
||||||
last_activity = MAX(COALESCE(last_activity, 0), ?) WHERE pid = ?`,
|
|
||||||
now, cur,
|
|
||||||
); err != nil {
|
|
||||||
tx.Rollback()
|
|
||||||
writeError(w, http.StatusInternalServerError, "Beitrag konnte nicht gespeichert werden")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := tx.QueryRow(`SELECT reply_to FROM entry WHERE pid = ?`, cur).Scan(&cur); err != nil {
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
if try >= 4 || !strings.Contains(err.Error(), "UNIQUE") {
|
||||||
|
tx.Rollback()
|
||||||
|
return Internal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Antwort: reply_count und last_activity aller Vorfahren bis zum Root
|
||||||
|
// anheben -- per rekursiver CTE in einem Statement statt bis zu 50
|
||||||
|
// Einzelqueries (hält die Transaktion kurz). depth deckelt die Kette und
|
||||||
|
// bricht auch einen (theoretischen) Zyklus ab.
|
||||||
|
if replyTo != 0 {
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
`WITH RECURSIVE chain(pid, depth) AS (
|
||||||
|
SELECT ?, 0
|
||||||
|
UNION ALL
|
||||||
|
SELECT e.reply_to, c.depth + 1
|
||||||
|
FROM entry e JOIN chain c ON e.pid = c.pid
|
||||||
|
WHERE e.reply_to != 0 AND c.depth < 50
|
||||||
|
)
|
||||||
|
UPDATE entry SET reply_count = reply_count + 1,
|
||||||
|
last_activity = MAX(last_activity, ?)
|
||||||
|
WHERE pid IN (SELECT pid FROM chain)`,
|
||||||
|
replyTo, now,
|
||||||
|
); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return Internal(err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "Beitrag konnte nicht gespeichert werden")
|
return Internal(err)
|
||||||
return
|
}
|
||||||
|
|
||||||
|
// 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{
|
||||||
@@ -273,77 +378,80 @@ func handleCreateEntry(w http.ResponseWriter, r *http.Request) {
|
|||||||
"filepath": filepath,
|
"filepath": filepath,
|
||||||
"reply_to": replyTo,
|
"reply_to": replyTo,
|
||||||
})
|
})
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleEditEntry ändert den Inhalt eines eigenen Beitrags. Das angehängte Bild
|
// handleEditEntry ändert den Inhalt eines eigenen Beitrags. Das angehängte Bild
|
||||||
// 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) {
|
func handleEditEntry(w http.ResponseWriter, r *http.Request) error {
|
||||||
pid, err := strconv.ParseInt(chi.URLParam(r, "pid"), 10, 64)
|
json := wantsJSON(r)
|
||||||
|
|
||||||
|
pid, err := pidParam(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "Ungültige pid")
|
return err
|
||||||
return
|
|
||||||
}
|
}
|
||||||
uid := uidFromContext(r.Context())
|
// Dieselbe Prüfung wie beim Anzeigen des Formulars -- Besitz und Zustand
|
||||||
|
// können sich zwischen Aufruf und Absenden geändert haben.
|
||||||
var owner int64
|
if _, err := ownEntry(r, pid); err != nil {
|
||||||
if err := db.QueryRow(`SELECT uid FROM entry WHERE pid = ?`, pid).Scan(&owner); err != nil {
|
return err
|
||||||
writeError(w, http.StatusNotFound, "Beitrag nicht gefunden")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if owner != uid {
|
|
||||||
writeError(w, http.StatusForbidden, "Das ist nicht dein Beitrag.")
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
content := strings.TrimSpace(r.FormValue("content"))
|
content := truncateRunes(strings.TrimSpace(r.FormValue("content")), 1000)
|
||||||
if content == "" {
|
if content == "" {
|
||||||
writeError(w, http.StatusBadRequest, "Inhalt darf nicht leer sein.")
|
e := Invalid("entry.empty", "Inhalt darf nicht leer sein.").At("content")
|
||||||
return
|
if json {
|
||||||
|
return e
|
||||||
}
|
}
|
||||||
if len(content) > 1000 {
|
return failEdit(w, r, pid, content, e)
|
||||||
content = content[:1000]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := db.Exec(`UPDATE entry SET content = ? WHERE pid = ?`, content, pid); err != nil {
|
if _, err := db.Exec(`UPDATE entry SET content = ? WHERE pid = ?`, content, pid); err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "Bearbeiten fehlgeschlagen")
|
return Internal(err)
|
||||||
return
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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.
|
||||||
func handleDeleteEntry(w http.ResponseWriter, r *http.Request) {
|
func handleDeleteEntry(w http.ResponseWriter, r *http.Request) error {
|
||||||
pid, err := strconv.ParseInt(chi.URLParam(r, "pid"), 10, 64)
|
pid, err := pidParam(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "Ungültige pid")
|
return err
|
||||||
return
|
|
||||||
}
|
}
|
||||||
uid := uidFromContext(r.Context())
|
// Dieselbe Prüfung wie auf der Bestätigungsseite; entryByPID liefert dabei
|
||||||
|
// auch den Bildpfad, der nach dem UPDATE nicht mehr in der Zeile steht.
|
||||||
var owner int64
|
e, err := ownEntry(r, pid)
|
||||||
var filepath string
|
if err != nil {
|
||||||
if err := db.QueryRow(`SELECT uid, filepath FROM entry WHERE pid = ?`, pid).Scan(&owner, &filepath); err != nil {
|
return err
|
||||||
writeError(w, http.StatusNotFound, "Beitrag nicht gefunden")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if owner != uid {
|
|
||||||
writeError(w, http.StatusForbidden, "Das ist nicht dein Beitrag.")
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 = ?`, pid,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "Löschen fehlgeschlagen")
|
return Internal(err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if filepath != "" {
|
if e.Filepath != "" {
|
||||||
os.Remove(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
|
||||||
}
|
}
|
||||||
|
|
||||||
// bumpStep ist der Cooldown-Zuwachs je bereits erfolgtem Bump: nach dem 1. Bump
|
// bumpStep ist der Cooldown-Zuwachs je bereits erfolgtem Bump: nach dem 1. Bump
|
||||||
@@ -357,46 +465,53 @@ const bumpStep = 24 * 60 * 60
|
|||||||
// Spam wächst der Cooldown linear mit der Zahl bisheriger Bumps (siehe bumpStep);
|
// Spam wächst der Cooldown linear mit der Zahl bisheriger Bumps (siehe bumpStep);
|
||||||
// er sitzt am Beitrag selbst (global), nicht am bumpenden Nutzer. Jeder
|
// er sitzt am Beitrag selbst (global), nicht am bumpenden Nutzer. Jeder
|
||||||
// Eingeloggte darf bumpen (Route in der requireAuth-Gruppe).
|
// Eingeloggte darf bumpen (Route in der requireAuth-Gruppe).
|
||||||
func handleBump(w http.ResponseWriter, r *http.Request) {
|
func handleBump(w http.ResponseWriter, r *http.Request) error {
|
||||||
pid, err := strconv.ParseInt(chi.URLParam(r, "pid"), 10, 64)
|
pid, err := pidParam(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "Ungültige pid")
|
return err
|
||||||
return
|
}
|
||||||
|
|
||||||
|
// Cooldown = bisherige Bumps in Tagen. Beim ersten Bump (bump_count 0) ist
|
||||||
|
// die Sperre 0 -> sofort erlaubt; danach wächst sie mit jedem Bump. Die
|
||||||
|
// Bedingung sitzt direkt im UPDATE (statt Read-then-Write), damit zwei
|
||||||
|
// gleichzeitige Bumps den Cooldown nicht umgehen können.
|
||||||
|
now := time.Now().Unix()
|
||||||
|
res, err := db.Exec(
|
||||||
|
`UPDATE entry SET last_activity = ?, bump_count = bump_count + 1, last_bump = ?
|
||||||
|
WHERE pid = ? AND deleted = 0 AND last_bump + bump_count * ? <= ?`,
|
||||||
|
now, now, pid, bumpStep, now,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
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 = ?`, pid).
|
||||||
Scan(&bumpCount, &lastBump, &deleted); err != nil {
|
Scan(&bumpCount, &lastBump, &deleted); err != nil || deleted != 0 {
|
||||||
writeError(w, http.StatusNotFound, "Beitrag nicht gefunden")
|
return errEntryNotFound
|
||||||
return
|
|
||||||
}
|
|
||||||
if deleted != 0 {
|
|
||||||
writeError(w, http.StatusNotFound, "Beitrag nicht gefunden")
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now().Unix()
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
// Cooldown = bisherige Bumps in Tagen. Beim ersten Bump (bumpCount 0) ist die
|
remaining := lastBump + bumpCount*bumpStep - now
|
||||||
// Sperre 0 -> sofort erlaubt; danach wächst sie mit jedem Bump.
|
if remaining < 0 {
|
||||||
if remaining := lastBump + bumpCount*bumpStep - now; remaining > 0 {
|
remaining = 0
|
||||||
|
}
|
||||||
|
// Bewusst kein Taxonomie-Fehler: die Antwort trägt Nutzdaten
|
||||||
|
// (retry_after, bump_count), die das Frontend zum Sperren des Buttons
|
||||||
|
// braucht -- ein reiner Fehlerkörper kann die nicht transportieren.
|
||||||
|
// Der Cooldown ist auch kein Fehlerfall, sondern ein regulärer
|
||||||
|
// Ausgang des Bumpens.
|
||||||
w.Header().Set("Retry-After", strconv.FormatInt(remaining, 10))
|
w.Header().Set("Retry-After", strconv.FormatInt(remaining, 10))
|
||||||
writeJSON(w, http.StatusTooManyRequests, map[string]any{
|
writeJSON(w, http.StatusTooManyRequests, map[string]any{
|
||||||
"error": "Dieser Beitrag wurde erst kürzlich repostet.",
|
"error": apiErrorBody{
|
||||||
|
Code: "entry.bump_cooldown",
|
||||||
|
Message: "Dieser Beitrag wurde erst kürzlich repostet.",
|
||||||
|
},
|
||||||
"retry_after": remaining,
|
"retry_after": remaining,
|
||||||
"bump_count": bumpCount,
|
"bump_count": bumpCount,
|
||||||
})
|
})
|
||||||
return
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := db.Exec(
|
|
||||||
`UPDATE entry SET last_activity = ?, bump_count = bump_count + 1, last_bump = ? WHERE pid = ?`,
|
|
||||||
now, now, pid,
|
|
||||||
); err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "Bumpen fehlgeschlagen")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
bumpCount++
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"pid": pid,
|
"pid": pid,
|
||||||
"bump_count": bumpCount,
|
"bump_count": bumpCount,
|
||||||
@@ -404,6 +519,7 @@ func handleBump(w http.ResponseWriter, r *http.Request) {
|
|||||||
"last_activity": now,
|
"last_activity": now,
|
||||||
"retry_after": bumpCount * bumpStep, // bis zum nächsten erlaubten Bump
|
"retry_after": bumpCount * bumpStep, // bis zum nächsten erlaubten Bump
|
||||||
})
|
})
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// voteTally liefert die Zähler und die eigene Auswahl (selected) für einen Beitrag.
|
// voteTally liefert die Zähler und die eigene Auswahl (selected) für einen Beitrag.
|
||||||
@@ -416,10 +532,105 @@ func voteTally(pid, uid int64) (left, right int, selected string) {
|
|||||||
selected = cur
|
selected = cur
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
db.QueryRow(`SELECT COUNT(*) FROM vote WHERE pid = ? AND mode = 'left'`, pid).Scan(&left)
|
// Beide Zähler in einem Query statt zwei COUNTs.
|
||||||
db.QueryRow(`SELECT COUNT(*) FROM vote WHERE pid = ? AND mode = 'right'`, pid).Scan(&right)
|
rows, err := db.Query(`SELECT mode, COUNT(*) FROM vote WHERE pid = ? GROUP BY mode`, pid)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("voteTally pid=%d: %v", pid, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var mode string
|
||||||
|
var n int
|
||||||
|
if err := rows.Scan(&mode, &n); err != nil {
|
||||||
|
log.Printf("voteTally pid=%d: %v", pid, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch mode {
|
||||||
|
case "left":
|
||||||
|
left = n
|
||||||
|
case "right":
|
||||||
|
right = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// tally ist der Abstimmungsstand eines Beitrags aus Sicht eines Betrachters.
|
||||||
|
type tallyState struct {
|
||||||
|
Left int
|
||||||
|
Right int
|
||||||
|
Selected string // "left", "right" oder "none"
|
||||||
|
}
|
||||||
|
|
||||||
|
// voteTallies holt die Stände für eine ganze Feed-Seite in zwei Abfragen statt
|
||||||
|
// in zwei pro Beitrag. Bei 20 Beiträgen wären das sonst 40 Roundtrips für eine
|
||||||
|
// einzige Seitenansicht.
|
||||||
|
func voteTallies(pids []int64, uid int64) (map[int64]tallyState, error) {
|
||||||
|
out := make(map[int64]tallyState, len(pids))
|
||||||
|
for _, pid := range pids {
|
||||||
|
out[pid] = tallyState{Selected: "none"}
|
||||||
|
}
|
||||||
|
if len(pids) == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(pids)), ",")
|
||||||
|
args := make([]any, 0, len(pids)+1)
|
||||||
|
for _, pid := range pids {
|
||||||
|
args = append(args, pid)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := db.Query(
|
||||||
|
`SELECT pid, mode, COUNT(*) FROM vote WHERE pid IN (`+placeholders+`) GROUP BY pid, mode`, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var pid int64
|
||||||
|
var mode string
|
||||||
|
var n int
|
||||||
|
if err := rows.Scan(&pid, &mode, &n); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
t := out[pid]
|
||||||
|
switch mode {
|
||||||
|
case "left":
|
||||||
|
t.Left = n
|
||||||
|
case "right":
|
||||||
|
t.Right = n
|
||||||
|
}
|
||||||
|
out[pid] = t
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eigene Stimmen nur, wenn überhaupt jemand angemeldet ist.
|
||||||
|
if uid == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
rows, err = db.Query(
|
||||||
|
`SELECT pid, mode FROM vote WHERE uid = ? AND pid IN (`+placeholders+`)`,
|
||||||
|
append([]any{uid}, args...)...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var pid int64
|
||||||
|
var mode string
|
||||||
|
if err := rows.Scan(&pid, &mode); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
t := out[pid]
|
||||||
|
t.Selected = mode
|
||||||
|
out[pid] = t
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
func writeTally(w http.ResponseWriter, pid, uid int64) {
|
func writeTally(w http.ResponseWriter, pid, uid int64) {
|
||||||
left, right, selected := voteTally(pid, uid)
|
left, right, selected := voteTally(pid, uid)
|
||||||
@@ -432,11 +643,10 @@ func writeTally(w http.ResponseWriter, pid, uid int64) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// handleVotes liest den Abstimmungsstand eines Beitrags (read-only, öffentlich).
|
// handleVotes liest den Abstimmungsstand eines Beitrags (read-only, öffentlich).
|
||||||
func handleVotes(w http.ResponseWriter, r *http.Request) {
|
func handleVotes(w http.ResponseWriter, r *http.Request) error {
|
||||||
pid, err := strconv.ParseInt(chi.URLParam(r, "pid"), 10, 64)
|
pid, err := pidParam(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "Ungültige pid")
|
return err
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var uid int64
|
var uid int64
|
||||||
@@ -444,54 +654,120 @@ func handleVotes(w http.ResponseWriter, r *http.Request) {
|
|||||||
uid = s.UID
|
uid = s.UID
|
||||||
}
|
}
|
||||||
writeTally(w, pid, uid)
|
writeTally(w, pid, uid)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleReport nimmt eine Meldung zu einem Beitrag entgegen (DSA-Melde-
|
||||||
|
// verfahren). Bewusst ohne Auth -- jeder darf melden, weil es eine
|
||||||
|
// Moderationshilfe ist. Die Melder-uid wird vorerst nicht gespeichert (immer
|
||||||
|
// 0); nur der optionale Grund landet in der DB. Die Route ist per IP
|
||||||
|
// rate-limited (siehe main.go), damit der offene Endpunkt kein Spam-Kanal wird.
|
||||||
|
func handleReport(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
pid, err := pidParam(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Nur existierende Beiträge melden, sonst entstehen Waisen-Meldungen.
|
||||||
|
if _, err := entryByPID(pid); err != nil {
|
||||||
|
return errEntryNotFound
|
||||||
|
}
|
||||||
|
reason := truncateRunes(strings.TrimSpace(r.FormValue("reason")), 500)
|
||||||
|
if _, err := db.Exec(
|
||||||
|
`INSERT INTO report (pid, uid, reason, created_at, handled) VALUES (?, 0, ?, ?, 0)`,
|
||||||
|
pid, reason, time.Now().Unix(),
|
||||||
|
); err != nil {
|
||||||
|
return Internal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ohne JavaScript auf eine eigene Bestätigungsseite statt zurück in den
|
||||||
|
// Feed: bei einer Meldung soll erkennbar sein, dass sie angekommen ist.
|
||||||
|
// Ein stiller Rücksprung wäre von "nichts passiert" nicht zu unterscheiden.
|
||||||
|
// Der Weg zurück ist ein Link, kein automatischer Redirect -- der Nutzer
|
||||||
|
// entscheidet, wann er weiterliest.
|
||||||
|
if !wantsJSON(r) {
|
||||||
|
q := url.Values{"return_to": {safeReturn(r.FormValue("return_to"), "/feed")}}
|
||||||
|
if reason != "" {
|
||||||
|
q.Set("reason", reason)
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/entry/%d/gemeldet?%s", pid, q.Encode()), http.StatusSeeOther)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// reportedView füttert die Bestätigungsseite.
|
||||||
|
type reportedView struct {
|
||||||
|
PID int64
|
||||||
|
Reason string
|
||||||
|
BackURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// showReported bestätigt eine eingegangene Meldung. Eigene Seite mit eigener
|
||||||
|
// URL, damit ein Reload nichts erneut meldet (Post/Redirect/Get).
|
||||||
|
func showReported(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
pid, err := pidParam(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
render(w, r, "reported", pageData{
|
||||||
|
Title: "Meldung eingegangen",
|
||||||
|
Form: reportedView{
|
||||||
|
PID: pid,
|
||||||
|
Reason: truncateRunes(r.URL.Query().Get("reason"), 500),
|
||||||
|
BackURL: safeReturn(r.URL.Query().Get("return_to"), "/feed"),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleVote gibt die Stimme des angemeldeten Nutzers ab oder schaltet sie um:
|
// handleVote gibt die Stimme des angemeldeten Nutzers ab oder schaltet sie um:
|
||||||
// - keine bisherige Stimme -> neue Stimme
|
// - keine bisherige Stimme -> neue Stimme
|
||||||
// - gleiche Stimme erneut -> Stimme zurückziehen (Toggle)
|
// - gleiche Stimme erneut -> Stimme zurückziehen (Toggle)
|
||||||
// - andere Stimme -> auf den neuen Modus wechseln
|
// - andere Stimme -> auf den neuen Modus wechseln
|
||||||
func handleVote(w http.ResponseWriter, r *http.Request) {
|
func handleVote(w http.ResponseWriter, r *http.Request) error {
|
||||||
pid, err := strconv.ParseInt(chi.URLParam(r, "pid"), 10, 64)
|
pid, err := pidParam(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "Ungültige pid")
|
return err
|
||||||
return
|
|
||||||
}
|
}
|
||||||
uid := uidFromContext(r.Context())
|
uid := uidFromContext(r.Context())
|
||||||
|
|
||||||
mode := r.FormValue("mode")
|
mode := r.FormValue("mode")
|
||||||
if mode != "left" && mode != "right" {
|
if mode != "left" && mode != "right" {
|
||||||
writeError(w, http.StatusBadRequest, "Ungültiger Modus (left oder right)")
|
return Invalid("vote.bad_mode", "Ungültiger Modus (left oder right)").At("mode")
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nur auf existierende Beiträge abstimmen, sonst entstehen Waisen-Votes.
|
// Nur auf existierende Beiträge abstimmen, sonst entstehen Waisen-Votes.
|
||||||
if _, err := entryByPID(pid); err != nil {
|
if _, err := entryByPID(pid); err != nil {
|
||||||
writeError(w, http.StatusNotFound, "Beitrag nicht gefunden")
|
return errEntryNotFound
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var prev string
|
// Toggle-Logik in zwei jeweils atomaren Statements statt Read-then-Write
|
||||||
readErr := db.QueryRow(`SELECT mode FROM vote WHERE uid = ? AND pid = ?`, uid, pid).Scan(&prev)
|
// (parallele Requests konnten den Toggle sonst doppelt anwenden):
|
||||||
if readErr != nil && readErr != sql.ErrNoRows {
|
// 1. Gleiche Stimme löschen -> hat das getroffen, war es ein Toggle-off.
|
||||||
writeError(w, http.StatusInternalServerError, "Datenbankfehler")
|
// 2. Sonst Upsert: neue Stimme anlegen bzw. bestehende ummodeln.
|
||||||
return
|
res, err := db.Exec(`DELETE FROM vote WHERE uid = ? AND pid = ? AND mode = ?`, uid, pid, mode)
|
||||||
|
if err != nil {
|
||||||
|
return Internal(err)
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
if _, err := db.Exec(
|
||||||
|
`INSERT INTO vote (uid, pid, mode) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(uid, pid) DO UPDATE SET mode = excluded.mode`,
|
||||||
|
uid, pid, mode,
|
||||||
|
); err != nil {
|
||||||
|
return Internal(err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var execErr error
|
// Ohne JavaScript zurück an den Beitrag, auf dem abgestimmt wurde -- der
|
||||||
switch {
|
// Anker in return_to hält die Position im Feed.
|
||||||
case readErr == sql.ErrNoRows:
|
if !wantsJSON(r) {
|
||||||
_, execErr = db.Exec(`INSERT INTO vote (uid, pid, mode) VALUES (?, ?, ?)`, uid, pid, mode)
|
redirectBack(w, r, "/feed")
|
||||||
case prev == mode:
|
return nil
|
||||||
_, execErr = db.Exec(`DELETE FROM vote WHERE uid = ? AND pid = ?`, uid, pid)
|
|
||||||
default:
|
|
||||||
_, execErr = db.Exec(`UPDATE vote SET mode = ? WHERE uid = ? AND pid = ?`, mode, uid, pid)
|
|
||||||
}
|
}
|
||||||
if execErr != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "Datenbankfehler")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
writeTally(w, pid, uid)
|
writeTally(w, pid, uid)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -506,6 +782,18 @@ const (
|
|||||||
maxUploadBytes = 17 << 20
|
maxUploadBytes = 17 << 20
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// errUploadTooLargeOrBroken unterscheidet die beiden Gründe, an denen
|
||||||
|
// ParseMultipartForm scheitern kann: das Limit von MaxBytesReader (der Upload
|
||||||
|
// war zu groß -> 413) oder kaputtes Multipart (-> 400). Vorher endete beides
|
||||||
|
// als 400, was dem Nutzer nicht sagte, dass die Datei schlicht zu groß war.
|
||||||
|
func errUploadTooLargeOrBroken(err error) error {
|
||||||
|
var tooLarge *http.MaxBytesError
|
||||||
|
if errors.As(err, &tooLarge) {
|
||||||
|
return TooLarge("upload.too_large", "Die Datei ist zu groß.").WithCause(err)
|
||||||
|
}
|
||||||
|
return Invalid("request.malformed", "Ungültige Anfrage").WithCause(err)
|
||||||
|
}
|
||||||
|
|
||||||
// storeImage speichert ein hochgeladenes Bild unter static/media.
|
// storeImage speichert ein hochgeladenes Bild unter static/media.
|
||||||
// Standbilder (JPEG/PNG/Einzelbild-GIF) werden auf max. 1024px skaliert und
|
// Standbilder (JPEG/PNG/Einzelbild-GIF) werden auf max. 1024px skaliert und
|
||||||
// dabei durch Decode+Re-Encode sanitisiert. Animierte GIFs würden durch das
|
// dabei durch Decode+Re-Encode sanitisiert. Animierte GIFs würden durch das
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Fehler-Taxonomie: trennt die drei Informationen, die früher an jeder
|
||||||
|
// Fehler-Aufrufstelle verklebt waren -- wie schwer der Fehler ist (Kind),
|
||||||
|
// was genau passiert ist (Code) und was der Nutzer lesen soll (Msg).
|
||||||
|
//
|
||||||
|
// Der Sinn der Trennung: derselbe Fehler muss künftig von zwei Adaptern
|
||||||
|
// unterschiedlich dargestellt werden. Der HTML-Pfad braucht Msg und Field, um
|
||||||
|
// ein Formular mit Fehlermarkierung neu zu rendern; ein API-Client braucht
|
||||||
|
// Code, um ohne Parsen deutscher Strings reagieren zu können.
|
||||||
|
|
||||||
|
type Kind uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
KindInternal Kind = iota // Bug oder Infrastruktur -- Ursache nie nach außen
|
||||||
|
KindInvalid // Eingabe passt nicht
|
||||||
|
KindUnauth // nicht angemeldet
|
||||||
|
KindForbidden // angemeldet, aber nicht erlaubt
|
||||||
|
KindNotFound // Objekt existiert nicht (oder soll nicht sichtbar sein)
|
||||||
|
KindConflict // Name vergeben, Doppel-Post
|
||||||
|
KindTooLarge // Upload über dem Limit
|
||||||
|
KindRateLimited // zu viele Anfragen
|
||||||
|
KindUnavailable // Feature nicht konfiguriert
|
||||||
|
)
|
||||||
|
|
||||||
|
// Error ist der Fehlertyp, den die Service-Schicht nach oben gibt. Kind ist
|
||||||
|
// bewusst kein HTTP-Status: die Zuordnung passiert erst im Adapter, damit ein
|
||||||
|
// künftiger CLI- oder App-Client dieselben Fehler auf eigene Weise abbilden
|
||||||
|
// kann (Exit-Code, Dialog, Retry).
|
||||||
|
type Error struct {
|
||||||
|
Kind Kind
|
||||||
|
Code string // stabil und maschinenlesbar, Schema "<bereich>.<sache>"
|
||||||
|
Msg string // deutsche Nutzermeldung
|
||||||
|
Field string // optional: betroffenes Formularfeld
|
||||||
|
Fields map[string]string // optional: mehrere Feldfehler auf einmal
|
||||||
|
cause error // nur fürs Log, nie für den Nutzer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Error) Error() string { return e.Code + ": " + e.Msg }
|
||||||
|
|
||||||
|
// Unwrap gibt die eingepackte Ursache frei, damit errors.Is/As über den
|
||||||
|
// Taxonomie-Fehler hinweg bis zum ursprünglichen Fehler (etwa sql.ErrNoRows)
|
||||||
|
// durchsuchen kann.
|
||||||
|
func (e *Error) Unwrap() error { return e.cause }
|
||||||
|
|
||||||
|
func Invalid(code, msg string) *Error { return &Error{Kind: KindInvalid, Code: code, Msg: msg} }
|
||||||
|
func Unauth(code, msg string) *Error { return &Error{Kind: KindUnauth, Code: code, Msg: msg} }
|
||||||
|
func Forbidden(code, msg string) *Error { return &Error{Kind: KindForbidden, Code: code, Msg: msg} }
|
||||||
|
func NotFound(code, msg string) *Error { return &Error{Kind: KindNotFound, Code: code, Msg: msg} }
|
||||||
|
func Conflict(code, msg string) *Error { return &Error{Kind: KindConflict, Code: code, Msg: msg} }
|
||||||
|
func TooLarge(code, msg string) *Error { return &Error{Kind: KindTooLarge, Code: code, Msg: msg} }
|
||||||
|
func RateLimited(code, msg string) *Error { return &Error{Kind: KindRateLimited, Code: code, Msg: msg} }
|
||||||
|
func Unavailable(code, msg string) *Error { return &Error{Kind: KindUnavailable, Code: code, Msg: msg} }
|
||||||
|
|
||||||
|
// Internal verschluckt die Ursache absichtlich: der Nutzer sieht nie einen
|
||||||
|
// DB- oder Dateisystemfehler (der Interna und Pfade verraten würde), das Log
|
||||||
|
// sieht ihn immer. Deshalb hat Internal auch keine freie Msg -- eine
|
||||||
|
// nutzerseitige Unterscheidung zwischen "Datenbankfehler" und "Speichern
|
||||||
|
// fehlgeschlagen" ist bedeutungslos.
|
||||||
|
func Internal(cause error) *Error {
|
||||||
|
return &Error{
|
||||||
|
Kind: KindInternal,
|
||||||
|
Code: "internal",
|
||||||
|
Msg: "Es ist ein Fehler aufgetreten.",
|
||||||
|
cause: cause,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// At markiert das Formularfeld, an dem der Fehler hängt. Verkettbar, damit die
|
||||||
|
// Aufrufstelle einzeilig bleibt: return Invalid(...).At("content")
|
||||||
|
//
|
||||||
|
// Achtung: At und WithCause verändern den Empfänger. Auf einen frisch
|
||||||
|
// konstruierten Fehler anwenden, NICHT auf ein geteiltes Sentinel wie
|
||||||
|
// errEntryNotFound -- das würde den globalen Wert für alle Requests umschreiben.
|
||||||
|
func (e *Error) At(field string) *Error {
|
||||||
|
e.Field = field
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithCause hängt eine technische Ursache an einen fachlichen Fehler. Nützlich,
|
||||||
|
// wenn ein DB-Fehler fachlich eindeutig ist (UNIQUE-Verletzung -> Conflict),
|
||||||
|
// die Ursache aber trotzdem ins Log soll.
|
||||||
|
func (e *Error) WithCause(cause error) *Error {
|
||||||
|
e.cause = cause
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// asError normalisiert alles, was kein *Error ist, zu einem Internal. Damit
|
||||||
|
// müssen die Adapter keinen Sonderfall für "nackte" Fehler kennen, die aus
|
||||||
|
// Bibliotheken oder noch nicht umgestellten Stellen kommen.
|
||||||
|
func asError(err error) *Error {
|
||||||
|
var e *Error
|
||||||
|
if errors.As(err, &e) {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
return Internal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// statusOf bildet Kind auf HTTP ab. Beide HTTP-Adapter (HTML und JSON) teilen
|
||||||
|
// sich diese Zuordnung -- unterschiedliche Statuscodes für denselben Fehler
|
||||||
|
// wären genau die Divergenz, die die Taxonomie verhindern soll.
|
||||||
|
func statusOf(k Kind) int {
|
||||||
|
switch k {
|
||||||
|
case KindInvalid:
|
||||||
|
return http.StatusBadRequest
|
||||||
|
case KindUnauth:
|
||||||
|
return http.StatusUnauthorized
|
||||||
|
case KindForbidden:
|
||||||
|
return http.StatusForbidden
|
||||||
|
case KindNotFound:
|
||||||
|
return http.StatusNotFound
|
||||||
|
case KindConflict:
|
||||||
|
return http.StatusConflict
|
||||||
|
case KindTooLarge:
|
||||||
|
return http.StatusRequestEntityTooLarge
|
||||||
|
case KindRateLimited:
|
||||||
|
return http.StatusTooManyRequests
|
||||||
|
case KindUnavailable:
|
||||||
|
return http.StatusServiceUnavailable
|
||||||
|
default:
|
||||||
|
return http.StatusInternalServerError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// logInternal protokolliert nur echte Serverfehler -- Validierungs- und
|
||||||
|
// Rechtefehler sind normaler Betrieb und würden das Log zumüllen. Beide
|
||||||
|
// Adapter rufen das auf, damit ein Fehler unabhängig vom Ausgabeformat
|
||||||
|
// genau einmal im Log landet.
|
||||||
|
func logInternal(e *Error) {
|
||||||
|
if e.Kind != KindInternal {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Die Ursache ist das Einzige, was hier wirklich interessiert; fehlt sie,
|
||||||
|
// bleibt wenigstens die Meldung.
|
||||||
|
if e.cause != nil {
|
||||||
|
log.Printf("internal: %v", e.cause)
|
||||||
|
} else {
|
||||||
|
log.Printf("internal: %s", e.Msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// apiErrorBody ist das Drahtformat des JSON-Adapters. Eigener Typ statt einer
|
||||||
|
// Map, damit die Feldnamen an genau einer Stelle stehen -- sie sind Teil des
|
||||||
|
// API-Vertrags, sobald ein externer Client existiert.
|
||||||
|
type apiErrorBody struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Field string `json:"field,omitempty"`
|
||||||
|
Fields map[string]string `json:"fields,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeAPIError ist der einzige Ausgang für Fehler im JSON-Adapter. Hier -- an
|
||||||
|
// der Adaptergrenze -- wird auch geloggt, und zwar nur einmal: die
|
||||||
|
// Service-Schicht loggt nicht, sonst steht derselbe Fehler mehrfach im Log.
|
||||||
|
func writeAPIError(w http.ResponseWriter, err error) {
|
||||||
|
e := asError(err)
|
||||||
|
logInternal(e)
|
||||||
|
|
||||||
|
writeJSON(w, statusOf(e.Kind), map[string]any{
|
||||||
|
"error": apiErrorBody{
|
||||||
|
Code: e.Code,
|
||||||
|
Message: e.Msg,
|
||||||
|
Field: e.Field,
|
||||||
|
Fields: e.Fields,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
+499
@@ -0,0 +1,499 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
// handlerFunc ist die Handler-Signatur dieser App: wie http.HandlerFunc, aber
|
||||||
|
// mit Fehler-Rückgabe. Der Gewinn ist nicht nur Kürze -- die alte Form
|
||||||
|
// (writeError(...); return) machte ein vergessenes return zu einem stillen Bug:
|
||||||
|
// der Handler lief weiter und schrieb eine zweite Antwort in denselben
|
||||||
|
// ResponseWriter. Mit "return err" kann das strukturell nicht mehr passieren.
|
||||||
|
type handlerFunc func(http.ResponseWriter, *http.Request) error
|
||||||
|
|
||||||
|
// api hängt einen fehlerliefernden Handler in chi ein. Der Fehler geht durch
|
||||||
|
// failRequest und wird damit in dem Format ausgegeben, das der Client erwartet
|
||||||
|
// -- 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 {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := h(w, r); err != nil {
|
||||||
|
failRequest(w, r, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// guard ist dasselbe für Middlewares: der Prüfschritt liefert einen Fehler,
|
||||||
|
// statt selbst zu schreiben. Der ResponseWriter wird durchgereicht, weil
|
||||||
|
// manche Prüfungen vor der Antwort noch Header setzen müssen.
|
||||||
|
func guard(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 {
|
||||||
|
failRequest(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+273
@@ -0,0 +1,273 @@
|
|||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# kver – Laufzeit-Konfiguration.
|
||||||
|
#
|
||||||
|
# Vorlage: als echte Datei kopieren (z. B. `cp kver.env.example kver.env`),
|
||||||
|
# Werte eintragen und via `podman run --env-file kver.env ...` laden.
|
||||||
|
# Die echte Datei NICHT committen und NICHT ins Image kopieren (enthält Secrets)
|
||||||
|
# -- `*.env` ist bereits in .gitignore und .containerignore.
|
||||||
|
#
|
||||||
|
# Format (podman/docker --env-file): eine VAR=Wert-Zeile pro Eintrag,
|
||||||
|
# KEINE Anführungszeichen um den Wert, keine Shell-Expansion ($VAR ist literal),
|
||||||
|
# `#` für Kommentare.
|
||||||
|
|
||||||
|
# Basic-Auth für die Moderationsseite /moderation. PFLICHT: ohne diese beiden
|
||||||
|
# Werte bleibt /moderation gesperrt (HTTP 503, fail-closed). Langes Zufalls-
|
||||||
|
# passwort verwenden.
|
||||||
|
KVER_MOD_USER=moderator
|
||||||
|
KVER_MOD_PASS=hier-ein-langes-zufallspasswort
|
||||||
|
|
||||||
|
# Optional: Pfad zur GeoLite2-ASN-DB im Container (leer = ASN-Auflösung aus).
|
||||||
|
#KVER_GEOIP_ASN=/app/data/GeoLite2-ASN.mmdb
|
||||||
|
|
||||||
|
# Selten nötig -- die Container-Defaults passen normalerweise:
|
||||||
|
#KVER_ADDR=:8080
|
||||||
|
#KVER_DB=/app/data/kver.db
|
||||||
|
|
||||||
|
# Optional: Verzeichnis mit web/ und static/ (die App macht dort ein chdir).
|
||||||
|
# Erlaubt den Start aus beliebigem Arbeitsverzeichnis.
|
||||||
|
#KVER_DIR=/app
|
||||||
|
|
||||||
|
# TOR-Sperrnetz (CIDR) für /entry/create. Default 10.89.0.0/16.
|
||||||
|
# Leer setzen (KVER_TOR_NET=) schaltet die Sperre ab.
|
||||||
|
#KVER_TOR_NET=10.89.0.0/16
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
@@ -16,6 +17,15 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
// KVER_DIR: Verzeichnis mit web/ und static/. Per Chdir werden alle
|
||||||
|
// relativen Pfade (auch Uploads und ein relativer KVER_DB-Pfad) auf einmal
|
||||||
|
// verankert -- die App ist damit nicht mehr an das Start-CWD gebunden.
|
||||||
|
if dir := os.Getenv("KVER_DIR"); dir != "" {
|
||||||
|
if err := os.Chdir(dir); err != nil {
|
||||||
|
log.Fatalf("KVER_DIR: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := initDB(envOr("KVER_DB", "kver.db")); err != nil {
|
if err := initDB(envOr("KVER_DB", "kver.db")); err != nil {
|
||||||
log.Fatalf("db init: %v", err)
|
log.Fatalf("db init: %v", err)
|
||||||
}
|
}
|
||||||
@@ -26,6 +36,12 @@ func main() {
|
|||||||
initGeoASN()
|
initGeoASN()
|
||||||
defer closeGeoASN()
|
defer closeGeoASN()
|
||||||
|
|
||||||
|
// Impression-Writer erst nach DB und GeoASN starten und VOR deren Close
|
||||||
|
// stoppen (LIFO der defers) -- sonst könnte er nach dem Shutdown noch auf
|
||||||
|
// die geschlossene DB oder den unmap-ten GeoIP-Reader zugreifen.
|
||||||
|
startImpressionWorker()
|
||||||
|
defer stopImpressionWorker()
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: envOr("KVER_ADDR", ":8080"),
|
Addr: envOr("KVER_ADDR", ":8080"),
|
||||||
Handler: routes(),
|
Handler: routes(),
|
||||||
@@ -80,48 +96,87 @@ func routes() http.Handler {
|
|||||||
r.Use(middleware.Logger)
|
r.Use(middleware.Logger)
|
||||||
r.Use(middleware.Recoverer)
|
r.Use(middleware.Recoverer)
|
||||||
r.Use(secHeaders)
|
r.Use(secHeaders)
|
||||||
|
r.Use(checkOrigin)
|
||||||
|
|
||||||
// --- Auth (öffentlich) ---
|
// --- Auth (öffentlich) ---
|
||||||
// Login und Registrierung pro IP drosseln (Brute-Force-Bremse). bcrypt bremst
|
// Login und Registrierung pro IP drosseln (Brute-Force-Bremse). bcrypt bremst
|
||||||
// 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))
|
||||||
r.Post("/auth/login", handleLogin)
|
// handleLogin bedient JSON- und Formular-Clients (siehe wantsJSON) und
|
||||||
r.Post("/auth/newuser", handleNewUser)
|
// schreibt HTML-Antworten selbst -- api() greift nur noch für den
|
||||||
|
// JSON-Zweig.
|
||||||
|
r.Post("/auth/login", api(handleLogin))
|
||||||
|
r.Post("/auth/newuser", api(handleNewUser))
|
||||||
})
|
})
|
||||||
r.Get("/auth/logout", handleLogout)
|
// Logout als POST: SameSite=Lax schickt Cookies bei Top-Level-GET-
|
||||||
r.Get("/auth/sessioninfo", handleSessionInfo)
|
// Navigationen mit -- als GET könnte eine fremde Seite Nutzer per Link
|
||||||
|
// ausloggen (CSRF). POST ist durch Lax + checkOrigin gedeckt.
|
||||||
|
r.Post("/auth/logout", handleLogout)
|
||||||
|
// 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/headerbar", handleHeaderbar)
|
r.Get("/auth/headerbar", handleHeaderbar)
|
||||||
r.Get("/stats", handleStats)
|
r.Get("/stats", handleStats)
|
||||||
r.Get("/stats/detail", handleStatsDetail)
|
r.Get("/stats/detail", handleStatsDetail)
|
||||||
|
|
||||||
|
// Moderations-Queue (Meldungen). Basic-Auth direkt in der App (modBasicAuth),
|
||||||
|
// weil TOR-Zugriffe die Go-App direkt erreichen und NICHT über Caddy laufen --
|
||||||
|
// ein rein proxy-seitiger Schutz hätte diese Lücke offen gelassen. Caddy
|
||||||
|
// erzwingt zusätzlich TLS für den Clearnet-Weg.
|
||||||
|
r.With(modBasicAuth).Get("/moderation", page(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.
|
||||||
r.With(trackImpression).Get("/", serveIndex)
|
r.With(trackImpression).Get("/", serveIndex)
|
||||||
|
|
||||||
// --- Entries ---
|
// --- Entries ---
|
||||||
r.Get("/entry/feed/{page}", handleFeed)
|
r.Get("/entry/feed/{page}", api(handleFeed))
|
||||||
r.Get("/entry/{pid}/votes", handleVotes)
|
r.Get("/entry/{pid}/votes", api(handleVotes))
|
||||||
r.Get("/entry/{pid}/thread", handleThread)
|
r.Get("/entry/{pid}/thread", api(handleThread))
|
||||||
r.With(trackImpression).Get("/e/{pid}", serveEntryPage)
|
r.With(trackImpression).Get("/e/{pid}", serveEntryPage)
|
||||||
|
|
||||||
|
// Melden: öffentlich (jeder darf melden, Moderationshilfe), aber pro IP
|
||||||
|
// gedrosselt, damit der auth-lose Endpunkt nicht zum Spam-Kanal wird.
|
||||||
|
r.With(httprate.LimitByIP(10, time.Minute)).Post("/entry/{pid}/report", api(handleReport))
|
||||||
|
// 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)
|
||||||
r.Get("/u/{username}/info", handleUserPage)
|
r.Get("/u/{username}/info", api(handleUserPage))
|
||||||
r.Get("/u/{username}/feed/{page}", handleUserFeed)
|
r.Get("/u/{username}/feed/{page}", api(handleUserFeed))
|
||||||
|
|
||||||
// --- Geschützt (Session erforderlich) ---
|
// --- Geschützt (Session erforderlich) ---
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(requireAuth)
|
r.Use(requireAuth)
|
||||||
r.With(blockTOR).Post("/entry/create", handleCreateEntry)
|
r.With(blockTOR).Post("/entry/create", api(handleCreateEntry))
|
||||||
r.Post("/entry/{pid}/vote", handleVote)
|
r.Post("/entry/{pid}/vote", api(handleVote))
|
||||||
r.Post("/entry/{pid}/bump", handleBump)
|
r.Post("/entry/{pid}/bump", api(handleBump))
|
||||||
r.Post("/entry/{pid}/edit", handleEditEntry)
|
r.Post("/entry/{pid}/edit", api(handleEditEntry))
|
||||||
r.Post("/entry/{pid}/delete", handleDeleteEntry)
|
// Bearbeitungsformular (noscript-Weg). In der requireAuth-Gruppe, weil
|
||||||
r.Get("/user/info", handleUserInfo)
|
// nur der Autor den bisherigen Text zu sehen bekommen soll.
|
||||||
r.Post("/user/rename", handleUserRename)
|
r.Get("/beitrag/{pid}/bearbeiten", page(showEditEntry))
|
||||||
r.Post("/user/avatar", handleSetAvatar)
|
// Löschbestätigung: das eigentliche Löschen bleibt ein POST, damit ein
|
||||||
r.Post("/user/delete", handleUserDelete)
|
// gefolgter Link (Linkvorschau, Crawler) nichts vernichtet.
|
||||||
|
r.Get("/beitrag/{pid}/loeschen", page(showDeleteEntry))
|
||||||
|
r.Post("/entry/{pid}/delete", api(handleDeleteEntry))
|
||||||
|
r.Get("/user/info", api(handleUserInfo))
|
||||||
|
r.Post("/user/rename", api(handleUserRename))
|
||||||
|
r.Post("/user/avatar", api(handleSetAvatar))
|
||||||
|
r.Post("/user/delete", api(handleUserDelete))
|
||||||
})
|
})
|
||||||
|
|
||||||
// Hochgeladene Medien + alte statische Assets. nosniff (global via
|
// Hochgeladene Medien + alte statische Assets. nosniff (global via
|
||||||
@@ -154,12 +209,35 @@ func secHeaders(next http.Handler) http.Handler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkOrigin weist zustandsändernde Cross-Site-Requests ab (CSRF-Schutz,
|
||||||
|
// zusätzlich zu SameSite=Lax für ältere Browser bzw. GET-Navigationen).
|
||||||
|
// Browser setzen bei Cross-Site-Requests immer einen Origin-Header; fehlt er
|
||||||
|
// (Nicht-Browser-Clients wie curl, gleiche Site bei manchen Browsern), lassen
|
||||||
|
// wir durch -- der Cookie-Schutz greift dort ohnehin nicht bzw. schon.
|
||||||
|
var checkOrigin = guard(func(_ http.ResponseWriter, r *http.Request) error {
|
||||||
|
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||||
|
if o := r.Header.Get("Origin"); o != "" {
|
||||||
|
u, err := url.Parse(o)
|
||||||
|
if err != nil || u.Host != r.Host {
|
||||||
|
return Forbidden("csrf.bad_origin", "Anfrage von fremder Seite abgelehnt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
_ = json.NewEncoder(w).Encode(v)
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
// truncateRunes kürzt s auf höchstens n Runen. Ein Byte-Slice s[:n] könnte
|
||||||
writeJSON(w, status, map[string]string{"error": msg})
|
// mitten in eine Multibyte-Rune schneiden und kaputtes UTF-8 erzeugen (gleiches
|
||||||
|
// Problem wie beim Username-Kürzen, siehe sanitizeUsername).
|
||||||
|
func truncateRunes(s string, n int) string {
|
||||||
|
if r := []rune(s); len(r) > n {
|
||||||
|
return string(r[:n])
|
||||||
|
}
|
||||||
|
return s
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-11
@@ -5,6 +5,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ctxKey string
|
type ctxKey string
|
||||||
@@ -20,7 +21,14 @@ const uidKey ctxKey = "uid"
|
|||||||
var torNet *net.IPNet
|
var torNet *net.IPNet
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
cidr := envOr("KVER_TOR_NET", "10.89.0.0/16")
|
// LookupEnv statt envOr: envOr würde bei leer GESETZTER Variable den
|
||||||
|
// Default liefern -- dann ließe sich die Sperre nie abschalten. Nur eine
|
||||||
|
// UNgesetzte Variable fällt auf den Default zurück; KVER_TOR_NET= (leer)
|
||||||
|
// deaktiviert die Sperre.
|
||||||
|
cidr, ok := os.LookupEnv("KVER_TOR_NET")
|
||||||
|
if !ok {
|
||||||
|
cidr = "10.89.0.0/16"
|
||||||
|
}
|
||||||
if cidr == "" {
|
if cidr == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -34,24 +42,38 @@ func init() {
|
|||||||
// blockTOR weist Anfragen aus dem TOR-Netz ab (siehe torNet). Die volle IP wird
|
// blockTOR weist Anfragen aus dem TOR-Netz ab (siehe torNet). Die volle IP wird
|
||||||
// hier nur für diese Zugriffsentscheidung geprüft, nicht gespeichert oder
|
// hier nur für diese Zugriffsentscheidung geprüft, nicht gespeichert oder
|
||||||
// geloggt. Ist kein Netz konfiguriert, ist die Middleware ein No-op.
|
// geloggt. Ist kein Netz konfiguriert, ist die Middleware ein No-op.
|
||||||
func blockTOR(next http.Handler) http.Handler {
|
var blockTOR = guard(func(_ http.ResponseWriter, r *http.Request) error {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
if isTOR(r) {
|
||||||
if torNet != nil {
|
return errTORBlocked
|
||||||
if ip := clientIP(r.RemoteAddr); ip != nil && torNet.Contains(ip) {
|
|
||||||
writeError(w, http.StatusForbidden, "Beiträge über TOR sind derzeit nicht möglich.")
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
return nil
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
var errTORBlocked = Forbidden("tor.blocked", "Beiträge über TOR sind derzeit nicht möglich.")
|
||||||
|
|
||||||
|
// isTOR erkennt Anfragen aus dem TOR-Netz. Ausgelagert, damit die
|
||||||
|
// serverseitig gerenderten Seiten das Beitragsformular erst gar nicht anbieten,
|
||||||
|
// statt es anzuzeigen und beim Absenden abzulehnen.
|
||||||
|
func isTOR(r *http.Request) bool {
|
||||||
|
if torNet == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
ip := clientIP(r.RemoteAddr)
|
||||||
|
return ip != nil && torNet.Contains(ip)
|
||||||
}
|
}
|
||||||
|
|
||||||
// requireAuth lehnt Anfragen ohne gültige Session ab und legt die uid in den Context.
|
var errAuthRequired = Unauth("auth.required", "Nicht angemeldet")
|
||||||
|
|
||||||
|
// requireAuth lehnt Anfragen ohne gültige Session ab und legt die uid in den
|
||||||
|
// Context. Nicht über guard, weil hier nicht nur geprüft, sondern auch der
|
||||||
|
// Request angereichert wird.
|
||||||
func requireAuth(next http.Handler) http.Handler {
|
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 {
|
||||||
writeError(w, http.StatusUnauthorized, "Nicht angemeldet")
|
// Über failRequest: ohne JavaScript ist die Session-Abfrage sonst
|
||||||
|
// 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)
|
||||||
|
|||||||
+146
@@ -0,0 +1,146 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"html/template"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// modBasicAuth schützt die Moderationsseite per HTTP Basic Auth direkt in der
|
||||||
|
// App. Nötig, weil TOR-Zugriffe direkt an die Go-App gehen und NICHT über Caddy
|
||||||
|
// laufen -- der proxy-seitige Schutz greift dort also nicht. Credentials kommen
|
||||||
|
// aus KVER_MOD_USER/KVER_MOD_PASS und werden einmalig beim Routing-Aufbau
|
||||||
|
// gelesen. Fail-closed: sind sie nicht gesetzt, bleibt die Seite gesperrt (503),
|
||||||
|
// damit sie nie versehentlich offen ist. Vergleich über SHA-256 + ConstantTime,
|
||||||
|
// damit weder Inhalt noch Länge der Credentials über Timing durchsickern.
|
||||||
|
func modBasicAuth(next http.Handler) http.Handler {
|
||||||
|
wantUser := envOr("KVER_MOD_USER", "")
|
||||||
|
wantPass := envOr("KVER_MOD_PASS", "")
|
||||||
|
// Einmalig beim Routing-Aufbau warnen statt bei jedem Request: der Hinweis
|
||||||
|
// richtet sich an den Betreiber, nicht an den Besucher. Die Namen der
|
||||||
|
// Umgebungsvariablen bleiben deshalb auch aus der Fehlerseite heraus --
|
||||||
|
// die Seite ist öffentlich erreichbar.
|
||||||
|
if wantUser == "" || wantPass == "" {
|
||||||
|
log.Printf("moderation gesperrt: KVER_MOD_USER/KVER_MOD_PASS nicht gesetzt")
|
||||||
|
}
|
||||||
|
return pageMiddleware(func(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
if wantUser == "" || wantPass == "" {
|
||||||
|
return Unavailable("mod.not_configured", "Die Moderationsseite ist nicht konfiguriert.")
|
||||||
|
}
|
||||||
|
u, p, ok := r.BasicAuth()
|
||||||
|
uSum, wuSum := sha256.Sum256([]byte(u)), sha256.Sum256([]byte(wantUser))
|
||||||
|
pSum, wpSum := sha256.Sum256([]byte(p)), sha256.Sum256([]byte(wantPass))
|
||||||
|
userOK := subtle.ConstantTimeCompare(uSum[:], wuSum[:]) == 1
|
||||||
|
passOK := subtle.ConstantTimeCompare(pSum[:], wpSum[:]) == 1
|
||||||
|
if !ok || !userOK || !passOK {
|
||||||
|
// Der Header muss vor dem Rendern stehen, sonst fragt der Browser
|
||||||
|
// nicht nach Zugangsdaten -- deshalb bekommt der Prüfschritt hier
|
||||||
|
// den ResponseWriter.
|
||||||
|
w.Header().Set("WWW-Authenticate", `Basic realm="Moderation", charset="UTF-8"`)
|
||||||
|
return Unauth("mod.auth_required", "Zugang erforderlich.")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Moderationsseite: spiegelt die report-Tabelle als serverseitig gerenderte,
|
||||||
|
// JS-freie HTML-Tabelle (offene Meldungen zuerst). Bewusst OHNE App-seitige
|
||||||
|
// Auth -- der Zugriff wird ausschließlich vom vorgelagerten Reverse-Proxy
|
||||||
|
// (Caddy: erzwingt TLS + Basic-Auth) geschützt. Die Route darf daher niemals
|
||||||
|
// direkt, am Proxy vorbei, erreichbar sein.
|
||||||
|
//
|
||||||
|
// html/template escaped reason/content automatisch -> der attacker-kontrollierte
|
||||||
|
// Meldegrund kann kein Markup einschleusen. Kein Inline-CSS/JS wegen der CSP;
|
||||||
|
// das gemeinsame Stylesheet wird als externe (same-origin) Datei verlinkt.
|
||||||
|
|
||||||
|
type reportRow struct {
|
||||||
|
PID int64
|
||||||
|
Reason string
|
||||||
|
When string
|
||||||
|
Handled bool
|
||||||
|
Username string
|
||||||
|
Content string
|
||||||
|
}
|
||||||
|
|
||||||
|
var moderationTmpl = template.Must(template.New("moderation").Parse(`<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
<title>Moderation · Meldungen</title>
|
||||||
|
<link rel="stylesheet" href="/css/app.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>Meldungen</h1>
|
||||||
|
<p class="muted">{{len .}} Meldung(en), offene zuerst.</p>
|
||||||
|
<table border="1" cellpadding="6" cellspacing="0">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Status</th><th>Zeit</th><th>Beitrag</th><th>Autor</th><th>Inhalt (Auszug)</th><th>Grund</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .}}
|
||||||
|
<tr>
|
||||||
|
<td>{{if .Handled}}erledigt{{else}}<strong>offen</strong>{{end}}</td>
|
||||||
|
<td>{{.When}}</td>
|
||||||
|
<td><a href="/e/{{.PID}}">{{.PID}}</a></td>
|
||||||
|
<td>{{if .Username}}{{.Username}}{{else}}[gelöscht/unbekannt]{{end}}</td>
|
||||||
|
<td>{{.Content}}</td>
|
||||||
|
<td>{{if .Reason}}{{.Reason}}{{else}}<span class="muted">–</span>{{end}}</td>
|
||||||
|
</tr>
|
||||||
|
{{else}}
|
||||||
|
<tr><td colspan="6" class="muted">Keine Meldungen.</td></tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>`))
|
||||||
|
|
||||||
|
func handleModeration(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
// Offene Meldungen zuerst (handled ASC), innerhalb dessen die jüngsten oben.
|
||||||
|
rows, err := db.Query(
|
||||||
|
`SELECT r.pid, r.reason, r.created_at, r.handled,
|
||||||
|
COALESCE(u.username, ''), COALESCE(e.content, '')
|
||||||
|
FROM report r
|
||||||
|
LEFT JOIN entry e ON e.pid = r.pid
|
||||||
|
LEFT JOIN user u ON u.uid = e.uid
|
||||||
|
ORDER BY r.handled ASC, r.created_at DESC`)
|
||||||
|
if err != nil {
|
||||||
|
return Internal(err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
reports := []reportRow{}
|
||||||
|
for rows.Next() {
|
||||||
|
var rr reportRow
|
||||||
|
var created int64
|
||||||
|
var handled int
|
||||||
|
if err := rows.Scan(&rr.PID, &rr.Reason, &created, &handled, &rr.Username, &rr.Content); err != nil {
|
||||||
|
return Internal(err)
|
||||||
|
}
|
||||||
|
rr.Handled = handled != 0
|
||||||
|
rr.When = time.Unix(created, 0).UTC().Format("2006-01-02 15:04 UTC")
|
||||||
|
// Inhalt nur als kurzer Auszug -- die Volansicht hängt am Beitrags-Link.
|
||||||
|
// Runenweise kürzen, damit der Schnitt kein Multibyte-Zeichen zerreißt.
|
||||||
|
if excerpt := truncateRunes(rr.Content, 120); excerpt != rr.Content {
|
||||||
|
rr.Content = excerpt + "…"
|
||||||
|
}
|
||||||
|
reports = append(reports, rr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// rows.Err() nach der Schleife: ein Fehler mitten im Iterieren würde sonst
|
||||||
|
// als "einfach zu Ende" durchgehen und eine unvollständige Liste als
|
||||||
|
// vollständige Moderationsansicht ausliefern.
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return Internal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
moderationTmpl.Execute(w, reports)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
+4
-3
@@ -52,11 +52,12 @@ Neuen Account registrieren.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### `GET /auth/logout`
|
### `POST /auth/logout`
|
||||||
|
|
||||||
Session beenden und Cookie löschen.
|
Session beenden und Cookie löschen. POST statt GET: SameSite=Lax schützt
|
||||||
|
GET-Navigationen nicht, ein fremder Link könnte Nutzer sonst ausloggen (CSRF).
|
||||||
|
|
||||||
**Response:** Redirect auf `/`
|
**Response:** `200` `{"status": "ok"}`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ Healthcheck kommt stattdessen aus der Quadlet-Unit (s.u.); alternativ
|
|||||||
```sh
|
```sh
|
||||||
podman run -d --name kver \
|
podman run -d --name kver \
|
||||||
-p 127.0.0.1:7777:8080 \
|
-p 127.0.0.1:7777:8080 \
|
||||||
|
--env-file /etc/kver/kver.env \
|
||||||
-v kver-data:/app/data \
|
-v kver-data:/app/data \
|
||||||
-v kver-media:/app/static/media \
|
-v kver-media:/app/static/media \
|
||||||
kver
|
kver
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# TODO
|
||||||
|
|
||||||
|
- Moderation: handled setzen
|
||||||
|
- Moderation: Löschen aus Queue
|
||||||
|
- Passwort-Reset
|
||||||
|
- Suche
|
||||||
|
- Hashtags / Erwähnungen
|
||||||
|
- Block / Mute
|
||||||
|
- Notification System
|
||||||
|
- Direct Messages
|
||||||
|
- Multi-Account-Management
|
||||||
|
- RSS / Atom Feed
|
||||||
|
- Feed ohne JS
|
||||||
|
- robots.txt / Sitemap
|
||||||
|
- Links/Rechts Auswertung
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 9.0 KiB |
@@ -147,26 +147,60 @@ func lookupASN(ip net.IP) string {
|
|||||||
return label
|
return label
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Impressions laufen über einen einzelnen Worker statt einer Goroutine pro
|
||||||
|
// Request. Das deckelt die Nebenläufigkeit (ein Flood erzeugt keine unbegrenzten
|
||||||
|
// Goroutinen mit je einem ASN-Lookup) und macht den Shutdown sicher: der Worker
|
||||||
|
// wird gestoppt, BEVOR db und geoASN geschlossen werden -- eine freilaufende
|
||||||
|
// Goroutine könnte sonst nach closeGeoASN() auf den unmap-ten Reader zugreifen
|
||||||
|
// (SIGSEGV; middleware.Recoverer fängt Panics in eigenen Goroutinen nicht).
|
||||||
|
// Bei vollem Puffer wird der Zählwert verworfen -- ein Seitenaufruf darf nie
|
||||||
|
// auf die Statistik warten.
|
||||||
|
type impressionEvent struct {
|
||||||
|
remote, path string
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
impressionCh = make(chan impressionEvent, 256)
|
||||||
|
impressionDone = make(chan struct{})
|
||||||
|
)
|
||||||
|
|
||||||
|
// startImpressionWorker startet den Schreib-Worker (nach initDB/initGeoASN).
|
||||||
|
func startImpressionWorker() {
|
||||||
|
go func() {
|
||||||
|
defer close(impressionDone)
|
||||||
|
for ev := range impressionCh {
|
||||||
|
netw := maskIP(clientIP(ev.remote))
|
||||||
|
anon := ""
|
||||||
|
if netw != nil {
|
||||||
|
anon = netw.String()
|
||||||
|
}
|
||||||
|
recordImpression(ev.path, anon, lookupASN(netw))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// stopImpressionWorker lässt den Worker die gepufferten Events noch schreiben
|
||||||
|
// und kehrt erst zurück, wenn er fertig ist (vor db.Close/closeGeoASN rufen).
|
||||||
|
func stopImpressionWorker() {
|
||||||
|
close(impressionCh)
|
||||||
|
<-impressionDone
|
||||||
|
}
|
||||||
|
|
||||||
// trackImpression zählt einen Seitenaufruf pro (Tag, anonymisiertes Netz, Pfad)
|
// trackImpression zählt einen Seitenaufruf pro (Tag, anonymisiertes Netz, Pfad)
|
||||||
// und vermerkt das autonome System. Die IP wird zuerst maskiert; sowohl die
|
// und vermerkt das autonome System. Die IP wird zuerst maskiert; sowohl die
|
||||||
// Speicherung als auch die ASN-Auflösung arbeiten nur auf dem anonymisierten
|
// Speicherung als auch die ASN-Auflösung arbeiten nur auf dem anonymisierten
|
||||||
// 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: der Seitenaufruf darf nicht auf den DB-Schreib
|
// Die Erfassung läuft asynchron über den Impression-Worker: der Seitenaufruf
|
||||||
// warten (SQLite serialisiert Schreiber, synchronous=FULL macht pro Commit ein
|
// darf nicht auf den DB-Schreib warten (SQLite serialisiert Schreiber). r ist
|
||||||
// fsync). r ist nach Rückkehr des Handlers nicht mehr sicher nutzbar -> wir
|
// nach Rückkehr des Handlers nicht mehr sicher nutzbar -> wir kopieren die zwei
|
||||||
// kopieren die zwei Werte vorher heraus.
|
// Werte vorher heraus.
|
||||||
func trackImpression(next http.Handler) http.Handler {
|
func trackImpression(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) {
|
||||||
remote, path := r.RemoteAddr, r.URL.Path
|
select {
|
||||||
go func() {
|
case impressionCh <- impressionEvent{remote: r.RemoteAddr, path: r.URL.Path}:
|
||||||
netw := maskIP(clientIP(remote))
|
default: // Puffer voll oder Worker nicht gestartet (Tests): verwerfen
|
||||||
anon := ""
|
|
||||||
if netw != nil {
|
|
||||||
anon = netw.String()
|
|
||||||
}
|
}
|
||||||
recordImpression(path, anon, lookupASN(netw))
|
|
||||||
}()
|
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{{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}}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{{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}}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{{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}}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
{{/*
|
||||||
|
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}}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{{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}}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{{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}}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{{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}}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{{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}}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{{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}}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{{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,18 +5,32 @@ 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"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
func createUser(username, password string) error {
|
// errUsernameTaken: der UNIQUE-Constraint auf username hat beim INSERT
|
||||||
|
// gegriffen (Rennen zwischen Vorab-Prüfung und Anlegen). Als Sentinel nutzbar
|
||||||
|
// (errors.Is) und zugleich direkt an den Adapter zurückgebbar.
|
||||||
|
var errUsernameTaken = Conflict("user.name_taken", "Der Nutzername wird bereits verwendet.")
|
||||||
|
|
||||||
|
// errUserNotFound deckt sowohl "gibt es nicht" als auch den DB-Fehler beim
|
||||||
|
// Lesen ab: für den Aufrufer ist beides dasselbe, und die Unterscheidung würde
|
||||||
|
// nur die Existenz von Accounts verraten.
|
||||||
|
var errUserNotFound = NotFound("user.not_found", "Nutzer nicht gefunden")
|
||||||
|
|
||||||
|
// createUser legt den Account an und liefert dessen uid zurück -- der Aufrufer
|
||||||
|
// braucht sie, um direkt eine Session zu eröffnen (Registrierung ohne
|
||||||
|
// JavaScript loggt in einem Schritt ein).
|
||||||
|
func createUser(username, password string) (int64, error) {
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
|
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// uid ist nie 0: 0 ist Sentinel für "gelöscht"/"anonym" (siehe handleDeleteEntry,
|
// uid ist nie 0: 0 ist Sentinel für "gelöscht"/"anonym" (siehe handleDeleteEntry,
|
||||||
@@ -29,12 +43,15 @@ func createUser(username, password string) error {
|
|||||||
uid, now, username, hash,
|
uid, now, username, hash,
|
||||||
)
|
)
|
||||||
if lastErr == nil {
|
if lastErr == nil {
|
||||||
return nil
|
return uid, nil
|
||||||
}
|
}
|
||||||
// username ist ebenfalls UNIQUE und wird vom Aufrufer vorab geprüft;
|
// username ist ebenfalls UNIQUE: hat DER kollidiert (Rennen mit der
|
||||||
// eine Kollision hier kann also nur die uid sein -> erneut versuchen.
|
// Vorab-Prüfung des Aufrufers), hilft kein Retry -> gezielt melden.
|
||||||
|
if strings.Contains(lastErr.Error(), "user.username") {
|
||||||
|
return 0, errUsernameTaken
|
||||||
}
|
}
|
||||||
return lastErr
|
}
|
||||||
|
return 0, lastErr
|
||||||
}
|
}
|
||||||
|
|
||||||
// idFromUsername liefert die interne id oder 0, wenn der Nutzer nicht existiert.
|
// idFromUsername liefert die interne id oder 0, wenn der Nutzer nicht existiert.
|
||||||
@@ -52,15 +69,14 @@ func serveUserPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.ServeFile(w, r, "web/user.html")
|
http.ServeFile(w, r, "web/user.html")
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleUserPage(w http.ResponseWriter, r *http.Request) {
|
func handleUserPage(w http.ResponseWriter, r *http.Request) error {
|
||||||
username := chi.URLParam(r, "username")
|
username := chi.URLParam(r, "username")
|
||||||
|
|
||||||
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 user WHERE username = ?`, username).Scan(&uid, &createdAt, &avatar)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusNotFound, "Nutzer nicht gefunden")
|
return errUserNotFound
|
||||||
return
|
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"uid": uid,
|
"uid": uid,
|
||||||
@@ -68,6 +84,7 @@ func handleUserPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
"created_at": createdAt,
|
"created_at": createdAt,
|
||||||
"avatar": avatar,
|
"avatar": avatar,
|
||||||
})
|
})
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleStats liefert die Kennzahlen für die Statuszeile unter dem Titel:
|
// handleStats liefert die Kennzahlen für die Statuszeile unter dem Titel:
|
||||||
@@ -82,7 +99,7 @@ func handleStats(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleUserInfo(w http.ResponseWriter, r *http.Request) {
|
func handleUserInfo(w http.ResponseWriter, r *http.Request) error {
|
||||||
uid := uidFromContext(r.Context())
|
uid := uidFromContext(r.Context())
|
||||||
|
|
||||||
var createdAt, lastLogin sql.NullInt64
|
var createdAt, lastLogin sql.NullInt64
|
||||||
@@ -91,8 +108,7 @@ func handleUserInfo(w http.ResponseWriter, r *http.Request) {
|
|||||||
`SELECT created_at, last_login, username, avatar FROM user WHERE uid = ?`, uid,
|
`SELECT created_at, last_login, username, avatar FROM user WHERE uid = ?`, uid,
|
||||||
).Scan(&createdAt, &lastLogin, &username, &avatar)
|
).Scan(&createdAt, &lastLogin, &username, &avatar)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusNotFound, "Nutzer nicht gefunden")
|
return errUserNotFound
|
||||||
return
|
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"uid": uid,
|
"uid": uid,
|
||||||
@@ -101,61 +117,59 @@ func handleUserInfo(w http.ResponseWriter, r *http.Request) {
|
|||||||
"last_login": lastLogin.Int64,
|
"last_login": lastLogin.Int64,
|
||||||
"avatar": avatar,
|
"avatar": avatar,
|
||||||
})
|
})
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleUserRename benennt den eingeloggten Account um. Nichts außer der user-Zeile
|
// handleUserRename benennt den eingeloggten Account um. Nichts außer der user-Zeile
|
||||||
// referenziert den username (alles hängt an der uid), daher genügt ein UPDATE.
|
// referenziert den username (alles hängt an der uid), daher genügt ein UPDATE.
|
||||||
func handleUserRename(w http.ResponseWriter, r *http.Request) {
|
func handleUserRename(w http.ResponseWriter, r *http.Request) error {
|
||||||
uid := uidFromContext(r.Context())
|
uid := uidFromContext(r.Context())
|
||||||
|
|
||||||
name := sanitizeUsername(r.FormValue("user"))
|
name := sanitizeUsername(r.FormValue("user"))
|
||||||
if len(name) < 3 {
|
if len(name) < 3 {
|
||||||
writeError(w, http.StatusBadRequest, "Der Nutzername ist zu kurz.")
|
return Invalid("user.name_short", "Der Nutzername ist zu kurz.").At("user")
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 user WHERE uid = ?`, uid).Scan(¤t); err != nil {
|
||||||
writeError(w, http.StatusNotFound, "Nutzer nicht gefunden")
|
return errUserNotFound
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if name == current {
|
if name == current {
|
||||||
writeError(w, http.StatusBadRequest, "Das ist bereits dein Name.")
|
return Invalid("user.name_unchanged", "Das ist bereits dein Name.").At("user")
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if idFromUsername(name) != 0 {
|
if idFromUsername(name) != 0 {
|
||||||
writeError(w, http.StatusConflict, "Der Nutzername wird bereits verwendet.")
|
return errUsernameTaken
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := db.Exec(`UPDATE user SET username = ? WHERE uid = ?`, name, uid); err != nil {
|
if _, err := db.Exec(`UPDATE user SET username = ? WHERE uid = ?`, name, uid); err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "Umbenennen fehlgeschlagen")
|
// Rennen zwischen Vorab-Prüfung und UPDATE: UNIQUE-Constraint -> 409.
|
||||||
return
|
if strings.Contains(err.Error(), "user.username") {
|
||||||
|
return errUsernameTaken
|
||||||
|
}
|
||||||
|
return Internal(err)
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"username": name})
|
writeJSON(w, http.StatusOK, map[string]string{"username": name})
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleSetAvatar speichert ein hochgeladenes Profilbild des eingeloggten
|
// handleSetAvatar speichert ein hochgeladenes Profilbild des eingeloggten
|
||||||
// Nutzers (über storeImage skaliert) und ersetzt ein eventuell vorhandenes.
|
// Nutzers (über storeImage skaliert) und ersetzt ein eventuell vorhandenes.
|
||||||
func handleSetAvatar(w http.ResponseWriter, r *http.Request) {
|
func handleSetAvatar(w http.ResponseWriter, r *http.Request) error {
|
||||||
uid := uidFromContext(r.Context())
|
uid := uidFromContext(r.Context())
|
||||||
|
|
||||||
// Wie bei handleCreateEntry: Request-Größe hart deckeln, bevor Multipart
|
// Wie bei handleCreateEntry: Request-Größe hart deckeln, bevor Multipart
|
||||||
// in Temp-Dateien streamen darf.
|
// in Temp-Dateien streamen darf.
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes)
|
r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes)
|
||||||
if err := r.ParseMultipartForm(16 << 20); err != nil {
|
if err := r.ParseMultipartForm(16 << 20); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
|
return errUploadTooLargeOrBroken(err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if r.MultipartForm == nil || len(r.MultipartForm.File["avatar"]) == 0 {
|
if r.MultipartForm == nil || len(r.MultipartForm.File["avatar"]) == 0 {
|
||||||
writeError(w, http.StatusBadRequest, "Kein Bild hochgeladen.")
|
return Invalid("avatar.missing", "Kein Bild hochgeladen.").At("avatar")
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
stored, err := storeImage(r.MultipartForm.File["avatar"][0])
|
stored, err := storeImage(r.MultipartForm.File["avatar"][0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "Bild konnte nicht verarbeitet werden.")
|
return Invalid("image.rejected", "Bild konnte nicht verarbeitet werden.").At("avatar").WithCause(err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var old string
|
var old string
|
||||||
@@ -163,13 +177,13 @@ func handleSetAvatar(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
if _, err := db.Exec(`UPDATE user SET avatar = ? WHERE uid = ?`, stored, uid); err != nil {
|
if _, err := db.Exec(`UPDATE user SET avatar = ? WHERE uid = ?`, stored, uid); err != nil {
|
||||||
os.Remove(stored)
|
os.Remove(stored)
|
||||||
writeError(w, http.StatusInternalServerError, "Speichern fehlgeschlagen")
|
return Internal(err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if old != "" && old != stored {
|
if old != "" && old != stored {
|
||||||
os.Remove(old)
|
os.Remove(old)
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"avatar": stored})
|
writeJSON(w, http.StatusOK, map[string]string{"avatar": stored})
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleUserDelete löscht den eingeloggten Account dauerhaft, nach Bestätigung
|
// handleUserDelete löscht den eingeloggten Account dauerhaft, nach Bestätigung
|
||||||
@@ -177,18 +191,16 @@ func handleSetAvatar(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Transaktion mitgelöscht; die eigenen Beiträge werden soft-gelöscht (als
|
// Transaktion mitgelöscht; die eigenen Beiträge werden soft-gelöscht (als
|
||||||
// [deleted]-Platzhalter erhalten), damit fremde Antworten nicht verwaisen.
|
// [deleted]-Platzhalter erhalten), damit fremde Antworten nicht verwaisen.
|
||||||
// Zugehörige Mediendateien werden best effort entfernt.
|
// Zugehörige Mediendateien werden best effort entfernt.
|
||||||
func handleUserDelete(w http.ResponseWriter, r *http.Request) {
|
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 user WHERE uid = ?`, uid).Scan(&hash); err != nil {
|
||||||
writeError(w, http.StatusNotFound, "Nutzer nicht gefunden")
|
return errUserNotFound
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if bcrypt.CompareHashAndPassword(hash, []byte(r.FormValue("pass1"))) != nil {
|
if bcrypt.CompareHashAndPassword(hash, []byte(r.FormValue("pass1"))) != nil {
|
||||||
writeError(w, http.StatusForbidden, "Passwort ist falsch.")
|
return Forbidden("auth.bad_password", "Passwort ist falsch.").At("pass1")
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mediendateien der Beiträge (und das Profilbild) einsammeln, bevor die
|
// Mediendateien der Beiträge (und das Profilbild) einsammeln, bevor die
|
||||||
@@ -210,8 +222,7 @@ func handleUserDelete(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
tx, err := db.Begin()
|
tx, err := db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "Löschen fehlgeschlagen")
|
return Internal(err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
for _, q := range []string{
|
for _, q := range []string{
|
||||||
`DELETE FROM session WHERE uid = ?`,
|
`DELETE FROM session WHERE uid = ?`,
|
||||||
@@ -221,13 +232,11 @@ func handleUserDelete(w http.ResponseWriter, r *http.Request) {
|
|||||||
} {
|
} {
|
||||||
if _, err := tx.Exec(q, uid); err != nil {
|
if _, err := tx.Exec(q, uid); err != nil {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
writeError(w, http.StatusInternalServerError, "Löschen fehlgeschlagen")
|
return Internal(err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "Löschen fehlgeschlagen")
|
return Internal(err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Erst nach erfolgreichem Commit die Dateien entfernen.
|
// Erst nach erfolgreichem Commit die Dateien entfernen.
|
||||||
@@ -246,4 +255,5 @@ func handleUserDelete(w http.ResponseWriter, r *http.Request) {
|
|||||||
SameSite: http.SameSiteLaxMode,
|
SameSite: http.SameSiteLaxMode,
|
||||||
})
|
})
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -302,6 +302,53 @@ img.avatar-lg { width: 72px; height: 72px; }
|
|||||||
.ghost.selected { font-weight: bold; }
|
.ghost.selected { font-weight: bold; }
|
||||||
.msg { color: red; }
|
.msg { color: red; }
|
||||||
|
|
||||||
|
/* Melden sitzt rechtsbündig in der Meta-Zeile (weit weg von den Interaktionen).
|
||||||
|
margin-left:auto schiebt es im Byline-Flex ganz nach rechts; position:relative
|
||||||
|
verankert das aufklappende Grund-Feld. Der <details>-Zweischritt (erst öffnen,
|
||||||
|
dann absenden) verhindert versehentliche Meldungen; nativ (details/summary +
|
||||||
|
echtes Formular), damit es später auch ohne JS funktioniert. */
|
||||||
|
details.report {
|
||||||
|
margin-left: auto;
|
||||||
|
position: relative;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
/* summary wie ein Button, aber zurückhaltend (grau), da Moderationshilfe. */
|
||||||
|
details.report > summary {
|
||||||
|
list-style: none;
|
||||||
|
user-select: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
color: gray;
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
details.report > summary::-webkit-details-marker { display: none; }
|
||||||
|
details.report > summary:hover { text-decoration: underline; }
|
||||||
|
details.report[open] > summary { color: #a00; border-color: #a00; }
|
||||||
|
details.report.reported > summary {
|
||||||
|
color: green;
|
||||||
|
border-color: green;
|
||||||
|
cursor: default;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
/* Aufgeklapptes Grund-Feld schwebt unter dem Button, damit die Meta-Zeile nicht
|
||||||
|
springt. Über dem Inhalt (z-index) und rechtsbündig ausgerichtet. */
|
||||||
|
.report-form {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
z-index: 2;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.25rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background-color: white;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.report-form input { font-family: inherit; font-size: 0.9rem; }
|
||||||
|
|
||||||
p.warning {
|
p.warning {
|
||||||
background-color: #faa;
|
background-color: #faa;
|
||||||
padding: 10px 15px;
|
padding: 10px 15px;
|
||||||
@@ -393,3 +440,19 @@ 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,13 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
|
<noscript>
|
||||||
|
<p class="msg">Diese Startseite braucht derzeit noch JavaScript. Ohne JS
|
||||||
|
nutzbar ist bereits alles Wesentliche – Lesen, Schreiben, Antworten,
|
||||||
|
Abstimmen und Melden: <a href="/feed">zum Feed</a> ·
|
||||||
|
<a href="/auth/login">Login</a> · <a href="/auth/register">Registrieren</a>.</p>
|
||||||
|
</noscript>
|
||||||
|
|
||||||
<!-- Login / Registrierung -->
|
<!-- Login / Registrierung -->
|
||||||
<section id="auth-box">
|
<section id="auth-box">
|
||||||
<form id="login-form">
|
<form id="login-form">
|
||||||
|
|||||||
+8
-7
@@ -16,7 +16,8 @@ async function refreshHeader() {
|
|||||||
const user = encodeURIComponent(me.username);
|
const user = encodeURIComponent(me.username);
|
||||||
bar.innerHTML = `<a class="button" href="/u/${user}">mein Profil</a> <button id="logout-btn">Logout</button>`;
|
bar.innerHTML = `<a class="button" href="/u/${user}">mein Profil</a> <button id="logout-btn">Logout</button>`;
|
||||||
document.getElementById("logout-btn").addEventListener("click", async () => {
|
document.getElementById("logout-btn").addEventListener("click", async () => {
|
||||||
await api.get("/auth/logout");
|
// POST statt GET: als GET wäre Logout per fremdem Link auslösbar (CSRF).
|
||||||
|
await api.post("/auth/logout", new FormData());
|
||||||
await refreshHeader();
|
await refreshHeader();
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -51,7 +52,7 @@ loginForm.addEventListener("submit", async (e) => {
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
await refreshHeader();
|
await refreshHeader();
|
||||||
} else {
|
} else {
|
||||||
authMsg.textContent = res.data.error || "Login fehlgeschlagen.";
|
authMsg.textContent = errMsg(res.data, "Login fehlgeschlagen.");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -60,9 +61,8 @@ registerForm.addEventListener("submit", async (e) => {
|
|||||||
const data = new FormData(e.target);
|
const data = new FormData(e.target);
|
||||||
const res = await api.post("/auth/newuser", data);
|
const res = await api.post("/auth/newuser", data);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
// newuser liefert eine Fehlerliste ({"errors": [...]}).
|
// newuser meldet alle Feldfehler auf einmal (error.fields).
|
||||||
const errors = res.data.errors || [res.data.error || "Registrierung fehlgeschlagen."];
|
authMsg.textContent = errMsg(res.data, "Registrierung fehlgeschlagen.");
|
||||||
authMsg.textContent = errors.join(" ");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Direkt mit denselben Daten einloggen, damit der Nutzer gleich drin ist.
|
// Direkt mit denselben Daten einloggen, damit der Nutzer gleich drin ist.
|
||||||
@@ -78,7 +78,8 @@ 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 ---
|
||||||
@@ -92,7 +93,7 @@ document.getElementById("entry-form").addEventListener("submit", async (e) => {
|
|||||||
e.target.reset();
|
e.target.reset();
|
||||||
feed.reset();
|
feed.reset();
|
||||||
} else {
|
} else {
|
||||||
msg.textContent = res.data.error || "Beitrag fehlgeschlagen.";
|
msg.textContent = errMsg(res.data, "Beitrag fehlgeschlagen.");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -18,7 +18,7 @@ replyForm.addEventListener("submit", async (e) => {
|
|||||||
load();
|
load();
|
||||||
} else {
|
} else {
|
||||||
document.getElementById("reply-msg").textContent =
|
document.getElementById("reply-msg").textContent =
|
||||||
res.data.error || "Antwort fehlgeschlagen.";
|
errMsg(res.data, "Antwort fehlgeschlagen.");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ async function load() {
|
|||||||
const res = await api.post(`/entry/${pid}/edit`, new FormData(form));
|
const res = await api.post(`/entry/${pid}/edit`, new FormData(form));
|
||||||
if (res.ok) load();
|
if (res.ok) load();
|
||||||
else form.querySelector(".msg").textContent =
|
else form.querySelector(".msg").textContent =
|
||||||
res.data.error || "Bearbeiten fehlgeschlagen.";
|
errMsg(res.data, "Bearbeiten fehlgeschlagen.");
|
||||||
});
|
});
|
||||||
form.querySelector(".cancel").addEventListener("click", load);
|
form.querySelector(".cancel").addEventListener("click", load);
|
||||||
focus.appendChild(form);
|
focus.appendChild(form);
|
||||||
@@ -71,7 +71,7 @@ async function load() {
|
|||||||
if (!confirm("Beitrag wirklich löschen?")) return;
|
if (!confirm("Beitrag wirklich löschen?")) return;
|
||||||
const res = await api.post(`/entry/${pid}/delete`, new FormData());
|
const res = await api.post(`/entry/${pid}/delete`, new FormData());
|
||||||
if (res.ok) load();
|
if (res.ok) load();
|
||||||
else alert(res.data.error || "Löschen fehlgeschlagen.");
|
else alert(errMsg(res.data, "Löschen fehlgeschlagen."));
|
||||||
});
|
});
|
||||||
|
|
||||||
focus.appendChild(editBtn);
|
focus.appendChild(editBtn);
|
||||||
|
|||||||
+99
-6
@@ -1,8 +1,16 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
|
// Accept: application/json markiert diese Aufrufe als API-Zugriffe. Endpunkte,
|
||||||
|
// die auch ein Browser-Formular bedienen (siehe wantsJSON im Backend),
|
||||||
|
// antworten daraufhin mit JSON statt mit einem Redirect auf eine HTML-Seite.
|
||||||
|
const JSON_HEADERS = { Accept: "application/json" };
|
||||||
|
|
||||||
const api = {
|
const api = {
|
||||||
async get(url) {
|
async get(url) {
|
||||||
const r = await fetch(url, { credentials: "same-origin" });
|
const r = await fetch(url, {
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: JSON_HEADERS,
|
||||||
|
});
|
||||||
return r.json();
|
return r.json();
|
||||||
},
|
},
|
||||||
async post(url, formData) {
|
async post(url, formData) {
|
||||||
@@ -10,11 +18,25 @@ const api = {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: formData,
|
body: formData,
|
||||||
credentials: "same-origin",
|
credentials: "same-origin",
|
||||||
|
headers: JSON_HEADERS,
|
||||||
});
|
});
|
||||||
return { ok: r.ok, status: r.status, data: await r.json().catch(() => ({})) };
|
return { ok: r.ok, status: r.status, data: await r.json().catch(() => ({})) };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// errMsg zieht die Nutzermeldung aus dem Fehlerformat der API:
|
||||||
|
// {"error": {"code", "message", "field", "fields"}}. Liegen mehrere
|
||||||
|
// Feldfehler vor (Registrierung), werden sie zu einem Satz zusammengezogen.
|
||||||
|
// Der code ist bewusst nicht Teil der Anzeige -- er ist für Logik da, nicht
|
||||||
|
// für Menschen.
|
||||||
|
function errMsg(data, fallback) {
|
||||||
|
const e = data && data.error;
|
||||||
|
if (!e) return fallback;
|
||||||
|
const fields = e.fields ? Object.values(e.fields) : [];
|
||||||
|
if (fields.length) return fields.join(" ");
|
||||||
|
return e.message || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
// Escaped für Text- UND Attributkontext. Der frühere textContent->innerHTML-
|
// Escaped für Text- UND Attributkontext. Der frühere textContent->innerHTML-
|
||||||
// Trick ließ Anführungszeichen stehen (in Textknoten harmlos); linkify steckt
|
// Trick ließ Anführungszeichen stehen (in Textknoten harmlos); linkify steckt
|
||||||
// den Wert aber in ein href="..."-Attribut, wo ein " ausbrechen würde.
|
// den Wert aber in ein href="..."-Attribut, wo ein " ausbrechen würde.
|
||||||
@@ -105,6 +127,15 @@ function renderEntry(e) {
|
|||||||
<div class="muted byline">
|
<div class="muted byline">
|
||||||
<a href="/u/${user}"><img class="avatar avatar-sm" src="${avatarUrl(e.avatar)}" alt="" loading="lazy"></a>
|
<a href="/u/${user}"><img class="avatar avatar-sm" src="${avatarUrl(e.avatar)}" alt="" loading="lazy"></a>
|
||||||
<span><a href="/u/${user}">${escapeHtml(e.username)}</a> · ${when}<span class="cooldown"></span></span>
|
<span><a href="/u/${user}">${escapeHtml(e.username)}</a> · ${when}<span class="cooldown"></span></span>
|
||||||
|
<details class="report">
|
||||||
|
<summary>Melden</summary>
|
||||||
|
<form class="report-form" method="post" action="/entry/${e.pid}/report">
|
||||||
|
<label>Grund <span class="muted">(optional)</span>
|
||||||
|
<input type="text" name="reason" maxlength="500" autocomplete="off">
|
||||||
|
</label>
|
||||||
|
<button type="submit">Absenden</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
<div class="threadline-wrapper">
|
<div class="threadline-wrapper">
|
||||||
<div class="threadline"></div>
|
<div class="threadline"></div>
|
||||||
@@ -125,6 +156,7 @@ function renderEntry(e) {
|
|||||||
`;
|
`;
|
||||||
setupVoting(el, e.pid);
|
setupVoting(el, e.pid);
|
||||||
setupBump(el, e);
|
setupBump(el, e);
|
||||||
|
setupReport(el, e.pid);
|
||||||
el.addEventListener("click", (ev) => {
|
el.addEventListener("click", (ev) => {
|
||||||
if (!ev.target.closest("a, button")) location.href = `/e/${e.pid}`;
|
if (!ev.target.closest("a, button")) location.href = `/e/${e.pid}`;
|
||||||
});
|
});
|
||||||
@@ -138,7 +170,7 @@ function setupVoting(el, pid) {
|
|||||||
|
|
||||||
function render(state) {
|
function render(state) {
|
||||||
if (state.error) {
|
if (state.error) {
|
||||||
counts.textContent = state.error;
|
counts.textContent = errMsg(state, "Fehler");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
counts.textContent = `${state.left} / ${state.right}`;
|
counts.textContent = `${state.left} / ${state.right}`;
|
||||||
@@ -191,17 +223,73 @@ function setupBump(el, e) {
|
|||||||
lock(res.data.retry_after || 86400);
|
lock(res.data.retry_after || 86400);
|
||||||
} else {
|
} else {
|
||||||
// 401 (nicht angemeldet) o. Ä. -> Fehlertext zeigen, Button frei lassen.
|
// 401 (nicht angemeldet) o. Ä. -> Fehlertext zeigen, Button frei lassen.
|
||||||
btn.textContent = res.data.error || "Fehler";
|
btn.textContent = errMsg(res.data, "Fehler");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setupReport verdrahtet den Melden-Aufklapper. Der <details>-Zweischritt
|
||||||
|
// (öffnen -> absenden) verhindert versehentliche Meldungen und hält das Grund-
|
||||||
|
// Feld eingeklappt. Das Formular ist ein echtes method=post-Form, damit es
|
||||||
|
// später auch ohne JS funktioniert; hier fangen wir den Submit nur ab, um
|
||||||
|
// Inline-Feedback zu geben statt die Seite neu zu laden. stopPropagation, weil
|
||||||
|
// die Karte sonst bei jedem Klick nach /e/{pid} navigieren würde.
|
||||||
|
function setupReport(el, pid) {
|
||||||
|
const details = el.querySelector("details.report");
|
||||||
|
if (!details) return;
|
||||||
|
details.addEventListener("click", (ev) => ev.stopPropagation());
|
||||||
|
|
||||||
|
const form = details.querySelector(".report-form");
|
||||||
|
const summary = details.querySelector("summary");
|
||||||
|
form.addEventListener("submit", async (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
const res = await api.post(`/entry/${pid}/report`, new FormData(form));
|
||||||
|
if (res.ok) {
|
||||||
|
form.remove();
|
||||||
|
details.open = false;
|
||||||
|
details.classList.add("reported");
|
||||||
|
summary.textContent = "Gemeldet – danke";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let msg = form.querySelector(".msg");
|
||||||
|
if (!msg) {
|
||||||
|
msg = document.createElement("span");
|
||||||
|
msg.className = "msg";
|
||||||
|
form.append(msg);
|
||||||
|
}
|
||||||
|
msg.textContent = errMsg(res.data, "Fehler beim Melden");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderAd baut einen In-Feed-Werbeslot: das Banner (selbst gehostet, sonst
|
||||||
|
// würde die CSP default-src 'self' es blocken), darunter der Pflicht-Hinweis
|
||||||
|
// "Werbung" und der Link zur Eigenvermarktung. rel="sponsored nofollow" macht
|
||||||
|
// den bezahlten Charakter für Suchmaschinen kenntlich.
|
||||||
|
function renderAd() {
|
||||||
|
const el = document.createElement("aside");
|
||||||
|
el.className = "ad";
|
||||||
|
el.innerHTML = `
|
||||||
|
<a class="ad-banner" href="/werben" rel="sponsored nofollow noopener">
|
||||||
|
<img src="/static/ads/placeholder.png" alt="Werbung" loading="lazy">
|
||||||
|
</a>
|
||||||
|
<div class="ad-meta muted">
|
||||||
|
<span>Werbung</span>
|
||||||
|
<a href="/werben">Auf dem Kontrollverlust werben</a>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
// createFeed hängt Infinite-Scroll an feedEl/sentinelEl und lädt Seiten über
|
// createFeed hängt Infinite-Scroll an feedEl/sentinelEl und lädt Seiten über
|
||||||
// urlFor(page). Gibt { reset } zurück, um den Feed neu zu laden.
|
// urlFor(page). opts.adEvery (>0) blendet nach je N Beiträgen einen
|
||||||
function createFeed(feedEl, sentinelEl, urlFor) {
|
// Werbebanner ein — nur im Top-Level-Feed, nicht in User-/Thread-Feeds.
|
||||||
|
// Gibt { reset } zurück, um den Feed neu zu laden.
|
||||||
|
function createFeed(feedEl, sentinelEl, urlFor, opts = {}) {
|
||||||
|
const adEvery = opts.adEvery || 0;
|
||||||
let page = 0;
|
let 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;
|
||||||
@@ -215,7 +303,11 @@ function createFeed(feedEl, sentinelEl, urlFor) {
|
|||||||
end.textContent = "YOU REACHED THE END!";
|
end.textContent = "YOU REACHED THE END!";
|
||||||
feedEl.appendChild(end);
|
feedEl.appendChild(end);
|
||||||
} else {
|
} else {
|
||||||
entries.forEach((e) => feedEl.appendChild(renderEntry(e)));
|
entries.forEach((e) => {
|
||||||
|
feedEl.appendChild(renderEntry(e));
|
||||||
|
shown++;
|
||||||
|
if (adEvery && shown % adEvery === 0) feedEl.appendChild(renderAd());
|
||||||
|
});
|
||||||
page++;
|
page++;
|
||||||
}
|
}
|
||||||
loading = false;
|
loading = false;
|
||||||
@@ -234,6 +326,7 @@ function createFeed(feedEl, sentinelEl, urlFor) {
|
|||||||
function reset() {
|
function reset() {
|
||||||
page = 0;
|
page = 0;
|
||||||
done = false;
|
done = false;
|
||||||
|
shown = 0;
|
||||||
feedEl.innerHTML = "";
|
feedEl.innerHTML = "";
|
||||||
loadMore();
|
loadMore();
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -10,7 +10,7 @@ document.getElementById("rename-form").addEventListener("submit", async (e) => {
|
|||||||
location.href = `/u/${encodeURIComponent(res.data.username)}`;
|
location.href = `/u/${encodeURIComponent(res.data.username)}`;
|
||||||
} else {
|
} else {
|
||||||
document.getElementById("rename-msg").textContent =
|
document.getElementById("rename-msg").textContent =
|
||||||
res.data.error || "Umbenennen fehlgeschlagen.";
|
errMsg(res.data, "Umbenennen fehlgeschlagen.");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ document.getElementById("avatar-form").addEventListener("submit", async (e) => {
|
|||||||
document.getElementById("avatar-msg").textContent = "";
|
document.getElementById("avatar-msg").textContent = "";
|
||||||
} else {
|
} else {
|
||||||
document.getElementById("avatar-msg").textContent =
|
document.getElementById("avatar-msg").textContent =
|
||||||
res.data.error || "Hochladen fehlgeschlagen.";
|
errMsg(res.data, "Hochladen fehlgeschlagen.");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ document.getElementById("delete-form").addEventListener("submit", async (e) => {
|
|||||||
location.href = "/";
|
location.href = "/";
|
||||||
} else {
|
} else {
|
||||||
document.getElementById("delete-msg").textContent =
|
document.getElementById("delete-msg").textContent =
|
||||||
res.data.error || "Löschen fehlgeschlagen.";
|
errMsg(res.data, "Löschen fehlgeschlagen.");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user