Die Kernlogik bleibt von der Schnittstelle getrennt; der letzte SSR-Stand liegt im Tag ssr-archiv. Die Moderationsseite bleibt, liefert Fehler aber als JSON. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
834 lines
24 KiB
Go
834 lines
24 KiB
Go
package main
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"image"
|
|
"image/gif"
|
|
"image/jpeg"
|
|
_ "image/png"
|
|
"io"
|
|
"log"
|
|
"math/rand/v2"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
xdraw "golang.org/x/image/draw"
|
|
)
|
|
|
|
type Entry struct {
|
|
PID int64 `json:"pid"`
|
|
UID int64 `json:"uid"`
|
|
CreatedAt int64 `json:"created_at"`
|
|
Content string `json:"content"`
|
|
Filepath string `json:"filepath"`
|
|
ReplyTo int64 `json:"reply_to"`
|
|
ReplyCount int64 `json:"reply_count"`
|
|
LastActivity int64 `json:"last_activity"`
|
|
Deleted int64 `json:"deleted"`
|
|
BumpCount int64 `json:"bump_count"`
|
|
LastBump int64 `json:"last_bump"`
|
|
Username string `json:"username"`
|
|
Avatar string `json:"avatar"`
|
|
}
|
|
|
|
// LEFT JOIN + COALESCE, damit soft-gelöschte Beiträge (uid auf 0 gesetzt) als
|
|
// [deleted]-Platzhalter erhalten bleiben und Threads nicht verwaisen.
|
|
const entrySelect = `
|
|
SELECT e.pid, e.uid, e.created_at, e.content, e.filepath,
|
|
e.reply_to, e.reply_count, e.last_activity, e.deleted,
|
|
e.bump_count, e.last_bump,
|
|
COALESCE(u.username, ''), COALESCE(u.avatar, '')
|
|
FROM entry e LEFT JOIN user u ON u.uid = e.uid`
|
|
|
|
// scanEntries liest Entry-Zeilen aus einem Query mit entrySelect-Spalten.
|
|
func scanEntries(rows *sql.Rows) ([]Entry, error) {
|
|
defer rows.Close()
|
|
entries := []Entry{}
|
|
for rows.Next() {
|
|
var e Entry
|
|
if err := rows.Scan(&e.PID, &e.UID, &e.CreatedAt, &e.Content, &e.Filepath,
|
|
&e.ReplyTo, &e.ReplyCount, &e.LastActivity, &e.Deleted,
|
|
&e.BumpCount, &e.LastBump, &e.Username, &e.Avatar); err != nil {
|
|
return nil, err
|
|
}
|
|
entries = append(entries, e)
|
|
}
|
|
return entries, rows.Err()
|
|
}
|
|
|
|
// entryByPID liefert einen einzelnen Beitrag oder sql.ErrNoRows.
|
|
func entryByPID(pid int64) (Entry, error) {
|
|
var e Entry
|
|
err := db.QueryRow(entrySelect+` WHERE e.pid = ?`, pid).Scan(
|
|
&e.PID, &e.UID, &e.CreatedAt, &e.Content, &e.Filepath,
|
|
&e.ReplyTo, &e.ReplyCount, &e.LastActivity, &e.Deleted,
|
|
&e.BumpCount, &e.LastBump, &e.Username, &e.Avatar)
|
|
return e, err
|
|
}
|
|
|
|
const (
|
|
feedPageSize = 20
|
|
// maxFeedPage deckelt den Offset: sehr große Seitenzahlen zwingen SQLite
|
|
// sonst dazu, entsprechend viele Zeilen zu überspringen.
|
|
maxFeedPage = 100
|
|
)
|
|
|
|
// feedWhere baut die Filterbedingung für Feed-Abfragen.
|
|
func feedWhere(uid int64, rootsOnly bool) (string, []any) {
|
|
args := []any{}
|
|
where := []string{}
|
|
if uid != 0 {
|
|
where = append(where, "e.uid = ?")
|
|
args = append(args, uid)
|
|
}
|
|
if rootsOnly {
|
|
where = append(where, "e.reply_to = 0")
|
|
}
|
|
if len(where) == 0 {
|
|
return "", args
|
|
}
|
|
return " WHERE " + strings.Join(where, " AND "), args
|
|
}
|
|
|
|
// feedPage liefert eine Feed-Seite (feedPageSize Beiträge). Ist uid != 0, nur die Beiträge
|
|
// dieses Nutzers. rootsOnly blendet Antworten aus und sortiert nach Aktivität;
|
|
// sonst werden alle Beiträge chronologisch geliefert.
|
|
func feedPage(page int, uid int64, rootsOnly bool) ([]Entry, error) {
|
|
if page < 0 {
|
|
page = 0
|
|
}
|
|
if page > maxFeedPage {
|
|
page = maxFeedPage
|
|
}
|
|
|
|
where, args := feedWhere(uid, rootsOnly)
|
|
query := entrySelect + where
|
|
if rootsOnly {
|
|
query += ` ORDER BY e.last_activity DESC`
|
|
} else {
|
|
query += ` ORDER BY e.created_at DESC`
|
|
}
|
|
query += ` LIMIT ? OFFSET ?`
|
|
args = append(args, feedPageSize, page*feedPageSize)
|
|
|
|
rows, err := db.Query(query, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return scanEntries(rows)
|
|
}
|
|
|
|
func handleFeed(w http.ResponseWriter, r *http.Request) error {
|
|
page, _ := strconv.Atoi(chi.URLParam(r, "page"))
|
|
entries, err := feedPage(page, 0, true)
|
|
if err != nil {
|
|
return Internal(err)
|
|
}
|
|
writeJSON(w, http.StatusOK, entries)
|
|
return nil
|
|
}
|
|
|
|
// handleUserFeed liefert den Feed eines einzelnen Nutzers (öffentlich, inkl. Antworten).
|
|
func handleUserFeed(w http.ResponseWriter, r *http.Request) error {
|
|
var uid int64
|
|
if err := db.QueryRow(`SELECT uid FROM user WHERE username = ?`, chi.URLParam(r, "username")).Scan(&uid); err != nil {
|
|
return errUserNotFound
|
|
}
|
|
|
|
page, _ := strconv.Atoi(chi.URLParam(r, "page"))
|
|
entries, err := feedPage(page, uid, false)
|
|
if err != nil {
|
|
return Internal(err)
|
|
}
|
|
writeJSON(w, http.StatusOK, entries)
|
|
return nil
|
|
}
|
|
|
|
// serveEntryPage liefert die statische Focus-Seite; das Frontend lädt die Daten
|
|
// dann über /entry/{pid}/thread.
|
|
func serveEntryPage(w http.ResponseWriter, r *http.Request) {
|
|
http.ServeFile(w, r, "web/entry.html")
|
|
}
|
|
|
|
var (
|
|
errEntryNotFound = NotFound("entry.not_found", "Beitrag nicht gefunden")
|
|
errEntryNotOwned = Forbidden("entry.not_owner", "Das ist nicht dein Beitrag.")
|
|
)
|
|
|
|
// 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)
|
|
if err != nil {
|
|
return 0, Invalid("entry.bad_pid", "Ungültige pid").WithCause(err)
|
|
}
|
|
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.
|
|
func threadData(pid int64) (thread, error) {
|
|
entry, err := entryByPID(pid)
|
|
if err != nil {
|
|
return thread{}, errEntryNotFound
|
|
}
|
|
t := thread{Entry: entry, Ancestors: []Entry{}, Replies: []Entry{}}
|
|
|
|
// Ahnenkette (Root zuerst) per rekursiver CTE in einem Query statt bis zu
|
|
// 50 Einzelqueries. depth zählt vom Beitrag aufwärts; ORDER BY depth DESC
|
|
// dreht die Kette auf Root-zuerst. Das Limit deckelt die Kettenlänge und
|
|
// 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 {
|
|
return thread{}, Internal(err)
|
|
}
|
|
if t.Ancestors, err = scanEntries(rows); err != nil {
|
|
return thread{}, Internal(err)
|
|
}
|
|
}
|
|
|
|
rows, err := db.Query(entrySelect+` WHERE e.reply_to = ? ORDER BY e.last_activity DESC LIMIT 100`, pid)
|
|
if err != nil {
|
|
return thread{}, Internal(err)
|
|
}
|
|
if t.Replies, err = scanEntries(rows); err != nil {
|
|
return thread{}, Internal(err)
|
|
}
|
|
return t, nil
|
|
}
|
|
|
|
func handleCreateEntry(w http.ResponseWriter, r *http.Request) error {
|
|
uid := uidFromContext(r.Context())
|
|
|
|
// MaxBytesReader kappt den gesamten Request hart: ParseMultipartForm
|
|
// begrenzt nur den Speicher, alles darüber liefe sonst unbegrenzt in
|
|
// Temp-Dateien auf die Platte.
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes)
|
|
parseErr := r.ParseMultipartForm(16 << 20)
|
|
if parseErr != nil && parseErr != http.ErrNotMultipart {
|
|
return errUploadTooLargeOrBroken(parseErr)
|
|
}
|
|
|
|
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)
|
|
|
|
if replyTo != 0 {
|
|
if _, err := entryByPID(replyTo); err != nil {
|
|
return Invalid("entry.parent_missing", "Elternbeitrag nicht gefunden").At("reply_to")
|
|
}
|
|
}
|
|
|
|
filepath := ""
|
|
if hadFile {
|
|
stored, err := storeImage(r.MultipartForm.File["file"][0])
|
|
if err != nil {
|
|
// Nicht stillschweigend ohne Bild posten -- der Nutzer soll
|
|
// erfahren, dass sein Bild abgelehnt wurde.
|
|
return Invalid("image.rejected", "Bild konnte nicht verarbeitet werden.").At("file").WithCause(err)
|
|
}
|
|
filepath = stored
|
|
}
|
|
|
|
if filepath == "" && content == "" {
|
|
return Invalid("entry.empty", "Leerer Beitrag!").At("content")
|
|
}
|
|
|
|
now := time.Now().Unix()
|
|
|
|
tx, err := db.Begin()
|
|
if err != nil {
|
|
return Internal(err)
|
|
}
|
|
// 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)
|
|
VALUES (?, ?, ?, ?, ?, ?, 0, ?)`,
|
|
pid, uid, now, content, filepath, replyTo, now,
|
|
)
|
|
if err == nil {
|
|
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 {
|
|
return Internal(err)
|
|
}
|
|
|
|
writeJSON(w, http.StatusCreated, map[string]any{
|
|
"pid": pid,
|
|
"content": content,
|
|
"filepath": filepath,
|
|
"reply_to": replyTo,
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// handleEditEntry ändert den Inhalt eines eigenen Beitrags. Das angehängte Bild
|
|
// bleibt unverändert; last_activity wird nicht angefasst (eine Korrektur soll den
|
|
// Thread nicht nach oben spülen).
|
|
func handleEditEntry(w http.ResponseWriter, r *http.Request) error {
|
|
pid, err := pidParam(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := ownEntry(r, pid); err != nil {
|
|
return err
|
|
}
|
|
|
|
content := truncateRunes(strings.TrimSpace(r.FormValue("content")), 1000)
|
|
if content == "" {
|
|
return Invalid("entry.empty", "Inhalt darf nicht leer sein.").At("content")
|
|
}
|
|
|
|
if _, err := db.Exec(`UPDATE entry SET content = ? WHERE pid = ?`, content, pid); err != nil {
|
|
return Internal(err)
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{"pid": pid, "content": content})
|
|
return nil
|
|
}
|
|
|
|
// ownEntry lädt einen Beitrag und prüft, ob er dem Anfragenden gehört --
|
|
// Voraussetzung fürs Bearbeiten wie fürs Löschen.
|
|
func ownEntry(r *http.Request, pid int64) (Entry, error) {
|
|
e, err := entryByPID(pid)
|
|
if err != nil {
|
|
return Entry{}, errEntryNotFound
|
|
}
|
|
// Soft-gelöschte Beiträge stehen auf uid 0 und scheitern damit an derselben
|
|
// Besitzprüfung -- sie gehören niemandem mehr. Kein eigener Zweig dafür:
|
|
// so bleibt es bei den 403, die der Endpunkt schon immer geliefert hat.
|
|
if e.UID != uidFromContext(r.Context()) {
|
|
return Entry{}, errEntryNotOwned
|
|
}
|
|
return e, nil
|
|
}
|
|
|
|
// handleDeleteEntry löscht einen eigenen Beitrag "weich": die Zeile bleibt als
|
|
// [deleted]-Platzhalter erhalten (damit Antworten nicht verwaisen), Inhalt, Bild
|
|
// und Autor werden entfernt.
|
|
func handleDeleteEntry(w http.ResponseWriter, r *http.Request) error {
|
|
pid, err := pidParam(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// ownEntry liefert auch den Bildpfad, der nach dem UPDATE nicht mehr in
|
|
// der Zeile steht.
|
|
e, err := ownEntry(r, pid)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if _, err := db.Exec(
|
|
`UPDATE entry SET deleted = 1, content = '', filepath = '', uid = 0 WHERE pid = ?`, pid,
|
|
); err != nil {
|
|
return Internal(err)
|
|
}
|
|
if e.Filepath != "" {
|
|
os.Remove(e.Filepath)
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
|
return nil
|
|
}
|
|
|
|
// bumpStep ist der Cooldown-Zuwachs je bereits erfolgtem Bump: nach dem 1. Bump
|
|
// ein Tag Sperre, nach dem 10. Bump zehn Tage. So wird Hochspülen mit jedem Mal
|
|
// teurer.
|
|
const bumpStep = 24 * 60 * 60
|
|
|
|
// handleBump hebt last_activity eines Beitrags auf jetzt -> er steigt im Feed
|
|
// (Roots, sortiert nach last_activity) bzw. in der Antwortliste eines Threads
|
|
// wieder nach oben. Tritt an die Stelle der früheren "Bump"-Kommentare. Gegen
|
|
// Spam wächst der Cooldown linear mit der Zahl bisheriger Bumps (siehe bumpStep);
|
|
// er sitzt am Beitrag selbst (global), nicht am bumpenden Nutzer. Jeder
|
|
// Eingeloggte darf bumpen (Route in der requireAuth-Gruppe).
|
|
func handleBump(w http.ResponseWriter, r *http.Request) error {
|
|
pid, err := pidParam(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// 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
|
|
if err := db.QueryRow(`SELECT bump_count, last_bump, deleted FROM entry WHERE pid = ?`, pid).
|
|
Scan(&bumpCount, &lastBump, &deleted); err != nil || deleted != 0 {
|
|
return errEntryNotFound
|
|
}
|
|
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
remaining := lastBump + bumpCount*bumpStep - now
|
|
if 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, und der Cooldown ist ein regulärer Ausgang.
|
|
w.Header().Set("Retry-After", strconv.FormatInt(remaining, 10))
|
|
writeJSON(w, http.StatusTooManyRequests, map[string]any{
|
|
"error": apiErrorBody{
|
|
Code: "entry.bump_cooldown",
|
|
Message: "Dieser Beitrag wurde erst kürzlich repostet.",
|
|
},
|
|
"retry_after": remaining,
|
|
"bump_count": bumpCount,
|
|
})
|
|
return nil
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"pid": pid,
|
|
"bump_count": bumpCount,
|
|
"last_bump": now,
|
|
"last_activity": now,
|
|
"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.
|
|
// Ist uid == 0 (nicht eingeloggt), ist selected immer "none".
|
|
func voteTally(pid, uid int64) (left, right int, selected string) {
|
|
selected = "none"
|
|
if uid != 0 {
|
|
var cur string
|
|
if err := db.QueryRow(`SELECT mode FROM vote WHERE uid = ? AND pid = ?`, uid, pid).Scan(&cur); err == nil {
|
|
selected = cur
|
|
}
|
|
}
|
|
// Beide Zähler in einem Query statt zwei COUNTs.
|
|
rows, err := db.Query(`SELECT mode, COUNT(*) FROM vote WHERE pid = ? GROUP BY mode`, pid)
|
|
if err != nil {
|
|
log.Printf("voteTally pid=%d: %v", pid, err)
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var mode string
|
|
var n int
|
|
if err := rows.Scan(&mode, &n); err != nil {
|
|
log.Printf("voteTally pid=%d: %v", pid, err)
|
|
return
|
|
}
|
|
switch mode {
|
|
case "left":
|
|
left = n
|
|
case "right":
|
|
right = n
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
// tally ist der Abstimmungsstand eines Beitrags aus Sicht eines Betrachters.
|
|
type tallyState struct {
|
|
Left int
|
|
Right int
|
|
Selected string // "left", "right" oder "none"
|
|
}
|
|
|
|
// voteTallies holt die Stände für eine ganze Feed-Seite in zwei Abfragen statt
|
|
// in zwei pro Beitrag. Bei 20 Beiträgen wären das sonst 40 Roundtrips für eine
|
|
// einzige Seitenansicht.
|
|
func voteTallies(pids []int64, uid int64) (map[int64]tallyState, error) {
|
|
out := make(map[int64]tallyState, len(pids))
|
|
for _, pid := range pids {
|
|
out[pid] = tallyState{Selected: "none"}
|
|
}
|
|
if len(pids) == 0 {
|
|
return out, nil
|
|
}
|
|
|
|
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(pids)), ",")
|
|
args := make([]any, 0, len(pids)+1)
|
|
for _, pid := range pids {
|
|
args = append(args, pid)
|
|
}
|
|
|
|
rows, err := db.Query(
|
|
`SELECT pid, mode, COUNT(*) FROM vote WHERE pid IN (`+placeholders+`) GROUP BY pid, mode`, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for rows.Next() {
|
|
var pid int64
|
|
var mode string
|
|
var n int
|
|
if err := rows.Scan(&pid, &mode, &n); err != nil {
|
|
rows.Close()
|
|
return nil, err
|
|
}
|
|
t := out[pid]
|
|
switch mode {
|
|
case "left":
|
|
t.Left = n
|
|
case "right":
|
|
t.Right = n
|
|
}
|
|
out[pid] = t
|
|
}
|
|
rows.Close()
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Eigene Stimmen nur, wenn überhaupt jemand angemeldet ist.
|
|
if uid == 0 {
|
|
return out, nil
|
|
}
|
|
rows, err = db.Query(
|
|
`SELECT pid, mode FROM vote WHERE uid = ? AND pid IN (`+placeholders+`)`,
|
|
append([]any{uid}, args...)...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var pid int64
|
|
var mode string
|
|
if err := rows.Scan(&pid, &mode); err != nil {
|
|
return nil, err
|
|
}
|
|
t := out[pid]
|
|
t.Selected = mode
|
|
out[pid] = t
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func writeTally(w http.ResponseWriter, pid, uid int64) {
|
|
left, right, selected := voteTally(pid, uid)
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"pid": pid,
|
|
"left": left,
|
|
"right": right,
|
|
"selected": selected,
|
|
})
|
|
}
|
|
|
|
// handleVotes liest den Abstimmungsstand eines Beitrags (read-only, öffentlich).
|
|
func handleVotes(w http.ResponseWriter, r *http.Request) error {
|
|
pid, err := pidParam(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var uid int64
|
|
if s, ok := getSession(r); ok {
|
|
uid = s.UID
|
|
}
|
|
writeTally(w, pid, uid)
|
|
return nil
|
|
}
|
|
|
|
// handleReport nimmt eine Meldung zu einem Beitrag entgegen (DSA-Melde-
|
|
// verfahren). Bewusst ohne Auth -- jeder darf melden, weil es eine
|
|
// Moderationshilfe ist. Die Melder-uid wird vorerst nicht gespeichert (immer
|
|
// 0); nur der optionale Grund landet in der DB. Die Route ist per IP
|
|
// rate-limited (siehe main.go), damit der offene Endpunkt kein Spam-Kanal wird.
|
|
func handleReport(w http.ResponseWriter, r *http.Request) error {
|
|
pid, err := pidParam(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Nur existierende Beiträge melden, sonst entstehen Waisen-Meldungen.
|
|
if _, err := entryByPID(pid); err != nil {
|
|
return errEntryNotFound
|
|
}
|
|
reason := truncateRunes(strings.TrimSpace(r.FormValue("reason")), 500)
|
|
if _, err := db.Exec(
|
|
`INSERT INTO report (pid, uid, reason, created_at, handled) VALUES (?, 0, ?, ?, 0)`,
|
|
pid, reason, time.Now().Unix(),
|
|
); err != nil {
|
|
return Internal(err)
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
|
return nil
|
|
}
|
|
|
|
// handleVote gibt die Stimme des angemeldeten Nutzers ab oder schaltet sie um:
|
|
// - keine bisherige Stimme -> neue Stimme
|
|
// - gleiche Stimme erneut -> Stimme zurückziehen (Toggle)
|
|
// - andere Stimme -> auf den neuen Modus wechseln
|
|
func handleVote(w http.ResponseWriter, r *http.Request) error {
|
|
pid, err := pidParam(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
uid := uidFromContext(r.Context())
|
|
|
|
mode := r.FormValue("mode")
|
|
if mode != "left" && mode != "right" {
|
|
return Invalid("vote.bad_mode", "Ungültiger Modus (left oder right)").At("mode")
|
|
}
|
|
|
|
// Nur auf existierende Beiträge abstimmen, sonst entstehen Waisen-Votes.
|
|
if _, err := entryByPID(pid); err != nil {
|
|
return errEntryNotFound
|
|
}
|
|
|
|
// Toggle-Logik in zwei jeweils atomaren Statements statt Read-then-Write
|
|
// (parallele Requests konnten den Toggle sonst doppelt anwenden):
|
|
// 1. Gleiche Stimme löschen -> hat das getroffen, war es ein Toggle-off.
|
|
// 2. Sonst Upsert: neue Stimme anlegen bzw. bestehende ummodeln.
|
|
res, err := db.Exec(`DELETE FROM vote WHERE uid = ? AND pid = ? AND mode = ?`, uid, pid, mode)
|
|
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)
|
|
}
|
|
}
|
|
|
|
writeTally(w, pid, uid)
|
|
return nil
|
|
}
|
|
|
|
const (
|
|
maxImageSize = 1024 // Zielkante beim Skalieren
|
|
maxImagePixels = 50_000_000 // pro Frame: Schutz vor Decompression-Bomb
|
|
maxGIFBytes = 16 << 20 // Obergrenze für durchgereichte GIFs
|
|
maxGIFFrames = 1000 // animiertes GIF: Frame-Budget
|
|
maxGIFPixels = 100_000_000 // animiertes GIF: Summe aller Frame-Pixel
|
|
|
|
// Hartes Limit für Upload-Requests insgesamt (Bild + Formularfelder +
|
|
// Multipart-Overhead), via http.MaxBytesReader.
|
|
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.
|
|
// Standbilder (JPEG/PNG/Einzelbild-GIF) werden auf max. 1024px skaliert und
|
|
// dabei durch Decode+Re-Encode sanitisiert. Animierte GIFs würden durch das
|
|
// Re-Encode auf das erste Frame reduziert -> sie werden stattdessen unverändert
|
|
// durchgereicht. Die Bytes sind dann NICHT sanitisiert; deshalb werden Medien
|
|
// mit X-Content-Type-Options: nosniff ausgeliefert (siehe routes()), und Frame-
|
|
// sowie Gesamtpixel-Budget begrenzen den DoS-Vektor von gif.DecodeAll.
|
|
func storeImage(fh *multipart.FileHeader) (string, error) {
|
|
src, err := fh.Open()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer src.Close()
|
|
|
|
// Dimensionen aus dem Header prüfen, bevor wir die vollen Pixel allokieren:
|
|
// eine kleine Datei kann riesige Maße deklarieren (Decompression-Bomb) und
|
|
// sonst beim Decode den Speicher sprengen.
|
|
cfg, format, err := image.DecodeConfig(src)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if cfg.Width <= 0 || cfg.Height <= 0 || cfg.Width*cfg.Height > maxImagePixels {
|
|
return "", fmt.Errorf("bild zu groß: %dx%d", cfg.Width, cfg.Height)
|
|
}
|
|
|
|
if err := os.MkdirAll("static/media", 0o755); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if format == "gif" {
|
|
// LimitReader deckelt den von DecodeAll gelesenen (und damit allokierten)
|
|
// Speicher unabhängig von einer evtl. gelogenen Header-Größe.
|
|
if _, err := src.Seek(0, io.SeekStart); err != nil {
|
|
return "", err
|
|
}
|
|
g, err := gif.DecodeAll(io.LimitReader(src, maxGIFBytes))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if len(g.Image) > 1 {
|
|
if len(g.Image) > maxGIFFrames {
|
|
return "", fmt.Errorf("animiertes GIF: zu viele Frames (%d)", len(g.Image))
|
|
}
|
|
total := 0
|
|
for _, fr := range g.Image {
|
|
b := fr.Bounds()
|
|
total += b.Dx() * b.Dy()
|
|
if total > maxGIFPixels {
|
|
return "", fmt.Errorf("animiertes GIF zu groß")
|
|
}
|
|
}
|
|
if _, err := src.Seek(0, io.SeekStart); err != nil {
|
|
return "", err
|
|
}
|
|
return copyUpload(src, "gif")
|
|
}
|
|
// Einzelbild-GIF: weiter in den Skalierungspfad unten.
|
|
}
|
|
|
|
if _, err := src.Seek(0, io.SeekStart); err != nil {
|
|
return "", err
|
|
}
|
|
img, _, err := image.Decode(src)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
b := img.Bounds()
|
|
width, height := b.Dx(), b.Dy()
|
|
|
|
nw, nh := width, height
|
|
if width > maxImageSize || height > maxImageSize {
|
|
if width > height {
|
|
nw, nh = maxImageSize, height*maxImageSize/width
|
|
} else {
|
|
nw, nh = width*maxImageSize/height, maxImageSize
|
|
}
|
|
}
|
|
|
|
ext := "jpg"
|
|
if format == "gif" {
|
|
ext = "gif"
|
|
}
|
|
name := fmt.Sprintf("static/media/%d-%d.%s", time.Now().Unix(), rand.IntN(999999), ext)
|
|
|
|
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
|
|
xdraw.CatmullRom.Scale(dst, dst.Bounds(), img, b, xdraw.Over, nil)
|
|
|
|
out, err := os.Create(name)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer out.Close()
|
|
|
|
if ext == "gif" {
|
|
err = gif.Encode(out, dst, nil)
|
|
} else {
|
|
err = jpeg.Encode(out, dst, &jpeg.Options{Quality: 85})
|
|
}
|
|
if err != nil {
|
|
os.Remove(name)
|
|
return "", err
|
|
}
|
|
return name, nil
|
|
}
|
|
|
|
// copyUpload schreibt die Originalbytes (max. maxGIFBytes) unter einem
|
|
// serverseitig erzeugten Namen nach static/media. Der hochgeladene Dateiname
|
|
// fließt nie ein (kein Path-Traversal).
|
|
func copyUpload(src io.Reader, ext string) (string, error) {
|
|
name := fmt.Sprintf("static/media/%d-%d.%s", time.Now().Unix(), rand.IntN(999999), ext)
|
|
out, err := os.Create(name)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer out.Close()
|
|
|
|
// maxGIFBytes+1 lesen, um ein Überschreiten sicher zu erkennen.
|
|
n, err := io.Copy(out, io.LimitReader(src, maxGIFBytes+1))
|
|
if err != nil {
|
|
os.Remove(name)
|
|
return "", err
|
|
}
|
|
if n > maxGIFBytes {
|
|
os.Remove(name)
|
|
return "", fmt.Errorf("datei zu groß")
|
|
}
|
|
return name, nil
|
|
}
|