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) }