package main import ( "database/sql" "errors" "fmt" "image" "image/gif" "image/jpeg" _ "image/png" "io" "log" "math/rand/v2" "mime/multipart" "net/http" "net/url" "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. 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{} where := []string{} if uid != 0 { where = append(where, "e.uid = ?") args = append(args, uid) } if rootsOnly { where = append(where, "e.reply_to = 0") } if len(where) == 0 { return "", args } return " WHERE " + strings.Join(where, " AND "), args } // feedTotal zählt die Beiträge, die feedPage mit denselben Filtern liefern // würde -- Grundlage für "Seite n von m" im serverseitig gerenderten Feed. func feedTotal(uid int64, rootsOnly bool) (int, error) { where, args := feedWhere(uid, rootsOnly) var n int err := db.QueryRow( `SELECT COUNT(*) FROM entry e LEFT JOIN user u ON u.uid = e.uid`+where, args..., ).Scan(&n) return n, err } // feedPage liefert eine Feed-Seite (feedPageSize Beiträge). Ist uid != 0, nur die Beiträge // dieses Nutzers. rootsOnly blendet Antworten aus und sortiert nach Aktivität; // sonst werden alle Beiträge chronologisch geliefert. func feedPage(page int, uid int64, rootsOnly bool) ([]Entry, error) { if page < 0 { page = 0 } if page > maxFeedPage { page = maxFeedPage } where, args := feedWhere(uid, rootsOnly) query := entrySelect + where if rootsOnly { query += ` ORDER BY e.last_activity DESC` } else { query += ` ORDER BY e.created_at DESC` } query += ` LIMIT ? OFFSET ?` args = append(args, feedPageSize, page*feedPageSize) rows, err := db.Query(query, args...) if err != nil { return nil, err } return scanEntries(rows) } func handleFeed(w http.ResponseWriter, r *http.Request) error { page, _ := strconv.Atoi(chi.URLParam(r, "page")) 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. 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) 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()) json := wantsJSON(r) // 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 { // 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 := 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) // 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 _, err := entryByPID(replyTo); err != nil { return fail(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 fail(Invalid("image.rejected", "Bild konnte nicht verarbeitet werden.").At("file").WithCause(err)) } filepath = stored } if filepath == "" && content == "" { return fail(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) } // 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{ "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 { json := wantsJSON(r) pid, err := pidParam(r) if err != nil { return err } // Dieselbe Prüfung wie beim Anzeigen des Formulars -- Besitz und Zustand // können sich zwischen Aufruf und Absenden geändert haben. if _, err := ownEntry(r, pid); err != nil { return err } content := truncateRunes(strings.TrimSpace(r.FormValue("content")), 1000) if content == "" { e := Invalid("entry.empty", "Inhalt darf nicht leer sein.").At("content") if json { return e } return failEdit(w, r, pid, content, e) } if _, err := db.Exec(`UPDATE entry SET content = ? WHERE pid = ?`, content, pid); err != nil { return Internal(err) } // Zurück dorthin, wo der Bearbeiten-Link angeklickt wurde -- der Beitrag // kann im Feed, im Nutzerfeed oder in einem Thread gestanden haben. if !json { redirectBack(w, r, threadAnchor(pid)) return nil } writeJSON(w, http.StatusOK, map[string]any{"pid": pid, "content": content}) return 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 } // Dieselbe Prüfung wie auf der Bestätigungsseite; entryByPID liefert dabei // 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) } // 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"}) 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 -- 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)) 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) } // 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: // - 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) } } // Ohne JavaScript zurück an den Beitrag, auf dem abgestimmt wurde -- der // Anker in return_to hält die Position im Feed. if !wantsJSON(r) { redirectBack(w, r, "/feed") return nil } writeTally(w, pid, uid) 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 }