Files
Kontrollverlust/entry.go
T
irrlichtandClaude Opus 5.5 df0cfec132 Code-Review: tote Stellen entfernt, Umwege vereinfacht
- SQLite-Import, migrate.sh, notes/migrations.md und altes jQuery-Skript entfernt
- Backend: scanEntry/queryEntries statt doppelter Scan-Liste, Feed-Queries
  direkt statt feedWhere, Thread ohne Map-Umweg, gemeinsame Helfer für
  Cookie-Löschen und Mediennamen, usernameTaken
- Frontend: PostForm für Beitrag und Antwort, EntryCard in Deleted/LiveCard
  aufgeteilt, tote CSS-Regeln und Typen entfernt, Strich-Regeln zusammengelegt
- Rechtstexte verlinken /stats; Kommentare ohne Vorgeschichte

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
2026-09-27 15:03:22 +02:00

744 lines
22 KiB
Go

package main
import (
"errors"
"fmt"
"image"
"image/gif"
"image/jpeg"
_ "image/png"
"io"
"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"`
// Latest ist nur in Feed und Antwortliste gesetzt, siehe attachLatest.
Latest *Latest `json:"latest,omitempty"`
}
// Latest ist der jüngste (nicht gelöschte) Beitrag im Teilbaum unter einem
// Beitrag -- die Antwort, die die Diskussion zuletzt fortgesetzt hat. Skipped
// zählt die Beiträge, die auf dem Pfad dazwischen liegen (0 = direkte Antwort).
type Latest struct {
Entry Entry `json:"entry"`
Skipped int64 `json:"skipped"`
}
// 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 account u ON u.uid = e.uid`
// scanEntry liest eine Zeile mit entrySelect-Spalten (aus *sql.Row oder *sql.Rows).
func scanEntry(row interface{ Scan(...any) error }) (Entry, error) {
var e Entry
err := row.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
}
// queryEntries führt einen Query mit entrySelect-Spalten aus.
func queryEntries(query string, args ...any) ([]Entry, error) {
rows, err := db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
entries := []Entry{}
for rows.Next() {
e, err := scanEntry(rows)
if 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) {
return scanEntry(db.QueryRow(entrySelect+` WHERE e.pid = $1`, pid))
}
const (
feedPageSize = 20
// maxFeedPage deckelt den Offset: sehr große Seitenzahlen zwingen die
// Datenbank sonst dazu, entsprechend viele Zeilen zu überspringen.
maxFeedPage = 100
)
// feedPage liefert Seite page (Routenparameter {page}) eines Feed-Queries mit
// entrySelect-Spalten; LIMIT und OFFSET werden hinter args angehängt.
func feedPage(r *http.Request, query string, args ...any) ([]Entry, error) {
page, _ := strconv.Atoi(chi.URLParam(r, "page"))
page = min(max(page, 0), maxFeedPage)
n := len(args)
query += fmt.Sprintf(` LIMIT $%d OFFSET $%d`, n+1, n+2)
return queryEntries(query, append(args, feedPageSize, page*feedPageSize)...)
}
// attachLatest hängt an jeden Beitrag den jüngsten nicht gelöschten Beitrag
// aus seinem Teilbaum (Latest). So bildet eine flache Liste auch tiefe
// Diskussionen ab: [Beitrag] -- "k weitere" -- [jüngste Antwort], ein Klick
// führt ans aktuelle Ende.
//
// Ein Query für alle Beiträge: die rekursive CTE steigt von allen zugleich
// ab, top merkt sich den Ausgangsbeitrag, depth die Tiefe darunter. Das Limit
// deckelt die Tiefe wie bei der Ahnenkette (handleThread).
func attachLatest(entries []Entry) error {
if len(entries) == 0 {
return nil
}
pids := make([]int64, len(entries))
for i, e := range entries {
pids[i] = e.PID
}
rows, err := db.Query(
`WITH RECURSIVE sub(top, pid, depth) AS (
SELECT e.reply_to, e.pid, 1 FROM entry e WHERE e.reply_to = ANY($1)
UNION ALL
SELECT s.top, e.pid, s.depth + 1
FROM entry e JOIN sub s ON e.reply_to = s.pid
WHERE s.depth < 50
)
SELECT DISTINCT ON (s.top) s.top, s.pid, s.depth
FROM sub s JOIN entry x ON x.pid = s.pid
WHERE x.deleted = 0
ORDER BY s.top, x.created_at DESC, x.id DESC`, pids)
if err != nil {
return err
}
type hit struct{ pid, depth int64 }
hits := map[int64]hit{}
latestPIDs := []int64{}
for rows.Next() {
var top int64
var h hit
if err := rows.Scan(&top, &h.pid, &h.depth); err != nil {
rows.Close()
return err
}
hits[top] = h
latestPIDs = append(latestPIDs, h.pid)
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
if len(latestPIDs) == 0 {
return nil
}
latest, err := queryEntries(entrySelect+` WHERE e.pid = ANY($1)`, latestPIDs)
if err != nil {
return err
}
byPID := make(map[int64]Entry, len(latest))
for _, e := range latest {
byPID[e.PID] = e
}
for i := range entries {
if h, ok := hits[entries[i].PID]; ok {
if e, ok := byPID[h.pid]; ok {
entries[i].Latest = &Latest{Entry: e, Skipped: h.depth - 1}
}
}
}
return nil
}
// handleFeed liefert die Wurzelbeiträge nach letzter Aktivität, jeweils mit
// ihrer jüngsten Antwort.
func handleFeed(w http.ResponseWriter, r *http.Request) error {
entries, err := feedPage(r, entrySelect+` WHERE e.reply_to = 0 ORDER BY e.last_activity DESC`)
if err != nil {
return Internal(err)
}
if err := attachLatest(entries); err != nil {
return Internal(err)
}
writeJSON(w, http.StatusOK, entries)
return nil
}
// handleUserFeed liefert alle Beiträge eines Nutzers chronologisch, Antworten
// eingeschlossen.
func handleUserFeed(w http.ResponseWriter, r *http.Request) error {
var uid int64
if err := db.QueryRow(`SELECT uid FROM account WHERE username = $1`, chi.URLParam(r, "username")).Scan(&uid); err != nil {
return errUserNotFound
}
entries, err := feedPage(r, entrySelect+` WHERE e.uid = $1 ORDER BY e.created_at DESC`, uid)
if err != nil {
return Internal(err)
}
writeJSON(w, http.StatusOK, entries)
return nil
}
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
}
// thread ist ein Beitrag mit seinem Kontext: Ahnenkette (Root zuerst) und
// direkte Antworten samt deren jüngstem Nachfahren.
type thread struct {
Entry Entry `json:"entry"`
Ancestors []Entry `json:"ancestors"`
Replies []Entry `json:"replies"`
}
func handleThread(w http.ResponseWriter, r *http.Request) error {
pid, err := pidParam(r)
if err != nil {
return err
}
entry, err := entryByPID(pid)
if err != nil {
return errEntryNotFound
}
t := thread{Entry: entry, Ancestors: []Entry{}}
// 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 {
t.Ancestors, err = queryEntries(
`WITH RECURSIVE chain(pid, depth) AS (
SELECT $1::bigint, 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 Internal(err)
}
}
t.Replies, err = queryEntries(entrySelect+` WHERE e.reply_to = $1 ORDER BY e.last_activity DESC LIMIT 100`, pid)
if err != nil {
return Internal(err)
}
if err := attachLatest(t.Replies); err != nil {
return Internal(err)
}
writeJSON(w, http.StatusOK, t)
return 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. Kollisionen über ON CONFLICT statt über den Fehler: ein
// fehlgeschlagenes Statement bricht in Postgres die ganze Transaktion ab.
var pid int64
for try := 0; ; try++ {
pid = int64(rand.IntN(999999999999)) + 1
res, err := tx.Exec(
`INSERT INTO entry (pid, uid, created_at, content, filepath, reply_to, reply_count, last_activity)
VALUES ($1, $2, $3, $4, $5, $6, 0, $7)
ON CONFLICT (pid) DO NOTHING`,
pid, uid, now, content, filepath, replyTo, now,
)
if err != nil {
tx.Rollback()
return Internal(err)
}
if n, _ := res.RowsAffected(); n == 1 {
break
}
if try >= 4 {
tx.Rollback()
return Internal(errors.New("pid-Kollision nach 5 Versuchen"))
}
}
// Antwort: reply_count und last_activity aller Vorfahren bis zum Root
// 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 $1::bigint, 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 = GREATEST(last_activity, $2)
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 = $1 WHERE pid = $2`, 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 = $1`, 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. Gegen Spam wächst der Cooldown linear mit der Zahl
// bisheriger Bumps (siehe bumpStep); er sitzt am Beitrag selbst (global), nicht
// am bumpenden Nutzer.
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 = $1, bump_count = bump_count + 1, last_bump = $1
WHERE pid = $2 AND deleted = 0 AND last_bump + bump_count * $3 <= $1`,
now, pid, bumpStep,
)
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 = $1`, 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
}
// Kein Taxonomie-Fehler: die Antwort trägt neben dem Fehler Nutzdaten
// (retry_after, bump_count), mit denen das Frontend den Button sperrt.
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
}
// writeTally schreibt die Zähler und die eigene Auswahl (selected) für einen
// Beitrag. Ist uid == 0 (nicht eingeloggt), ist selected immer "none".
func writeTally(w http.ResponseWriter, pid, uid int64) error {
var left, right int64
var selected string
if err := db.QueryRow(
`SELECT COUNT(*) FILTER (WHERE mode = 'left'),
COUNT(*) FILTER (WHERE mode = 'right'),
COALESCE(MAX(mode) FILTER (WHERE uid = $2), 'none')
FROM vote WHERE pid = $1`, pid, uid).
Scan(&left, &right, &selected); err != nil {
return Internal(err)
}
writeJSON(w, http.StatusOK, map[string]any{
"pid": pid,
"left": left,
"right": right,
"selected": selected,
})
return nil
}
// 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
}
return writeTally(w, pid, uid)
}
// 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 = $1 AND pid = $2 AND mode = $3`, 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 ($1, $2, $3)
ON CONFLICT (uid, pid) DO UPDATE SET mode = excluded.mode`,
uid, pid, mode,
); err != nil {
return Internal(err)
}
}
return writeTally(w, pid, uid)
}
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).
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 := newMediaName(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 := newMediaName(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
}
// newMediaName erzeugt einen serverseitigen Dateinamen unter static/media.
func newMediaName(ext string) string {
return fmt.Sprintf("static/media/%d-%d.%s", time.Now().Unix(), rand.IntN(999999), ext)
}