package main import ( "database/sql" "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"` Username string `json:"username"` } // 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, COALESCE(u.username, '') 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.Username); 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.Username) return e, err } // feedPage liefert eine Feed-Seite (20 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 > 100 { page = 100 } const count = 20 query := entrySelect 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 { query += " WHERE " + strings.Join(where, " AND ") } if rootsOnly { query += ` ORDER BY e.last_activity DESC` } else { query += ` ORDER BY e.created_at DESC` } query += ` LIMIT ? OFFSET ?` args = append(args, count, page*count) rows, err := db.Query(query, args...) if err != nil { return nil, err } return scanEntries(rows) } func handleFeed(w http.ResponseWriter, r *http.Request) { page, _ := strconv.Atoi(chi.URLParam(r, "page")) entries, err := feedPage(page, 0, true) if err != nil { writeError(w, http.StatusInternalServerError, "Datenbankfehler") return } writeJSON(w, http.StatusOK, entries) } // handleUserFeed liefert den Feed eines einzelnen Nutzers (öffentlich, inkl. Antworten). func handleUserFeed(w http.ResponseWriter, r *http.Request) { var uid int64 if err := db.QueryRow(`SELECT uid FROM user WHERE username = ?`, chi.URLParam(r, "username")).Scan(&uid); err != nil { writeError(w, http.StatusNotFound, "Nutzer nicht gefunden") return } page, _ := strconv.Atoi(chi.URLParam(r, "page")) entries, err := feedPage(page, uid, false) if err != nil { writeError(w, http.StatusInternalServerError, "Datenbankfehler") return } writeJSON(w, http.StatusOK, entries) } // 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") } // handleThread liefert einen Beitrag mit seiner Ahnenkette (Root zuerst) und den // direkten Antworten (öffentlich). func handleThread(w http.ResponseWriter, r *http.Request) { pid, err := strconv.ParseInt(chi.URLParam(r, "pid"), 10, 64) if err != nil { writeError(w, http.StatusBadRequest, "Ungültige pid") return } entry, err := entryByPID(pid) if err != nil { writeError(w, http.StatusNotFound, "Beitrag nicht gefunden") return } // Ahnenkette von Eltern bis Root hochlaufen, dann umdrehen (Root zuerst). ancestors := []Entry{} for cur := entry.ReplyTo; cur != 0 && len(ancestors) < 50; { a, err := entryByPID(cur) if err != nil { break } ancestors = append(ancestors, a) cur = a.ReplyTo } for i, j := 0, len(ancestors)-1; i < j; i, j = i+1, j-1 { ancestors[i], ancestors[j] = ancestors[j], ancestors[i] } rows, err := db.Query(entrySelect+` WHERE e.reply_to = ? ORDER BY e.last_activity DESC LIMIT 100`, pid) if err != nil { writeError(w, http.StatusInternalServerError, "Datenbankfehler") return } replies, err := scanEntries(rows) if err != nil { writeError(w, http.StatusInternalServerError, "Datenbankfehler") return } writeJSON(w, http.StatusOK, map[string]any{ "entry": entry, "ancestors": ancestors, "replies": replies, }) } func handleCreateEntry(w http.ResponseWriter, r *http.Request) { uid := uidFromContext(r.Context()) if err := r.ParseMultipartForm(16 << 20); err != nil && err != http.ErrNotMultipart { writeError(w, http.StatusBadRequest, "Ungültige Anfrage") return } content := strings.TrimSpace(r.FormValue("content")) if len(content) > 1000 { content = content[:1000] } replyTo, _ := strconv.ParseInt(r.FormValue("reply_to"), 10, 64) if replyTo != 0 { if _, err := entryByPID(replyTo); err != nil { writeError(w, http.StatusBadRequest, "Elternbeitrag nicht gefunden") return } } filepath := "" if r.MultipartForm != nil { if files := r.MultipartForm.File["file"]; len(files) > 0 { if stored, err := storeImage(files[0]); err == nil { filepath = stored } } } if filepath == "" && content == "" { writeError(w, http.StatusBadRequest, "Leerer Beitrag!") return } pid := int64(rand.IntN(999999999999)) now := time.Now().Unix() tx, err := db.Begin() if err != nil { writeError(w, http.StatusInternalServerError, "Beitrag konnte nicht gespeichert werden") return } if _, 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, ); err != nil { tx.Rollback() writeError(w, http.StatusInternalServerError, "Beitrag konnte nicht gespeichert werden") return } // Antwort: reply_count und last_activity aller Vorfahren bis zum Root anheben. for cur, i := replyTo, 0; cur != 0 && i < 50; i++ { if _, err := tx.Exec( `UPDATE entry SET reply_count = reply_count + 1, last_activity = MAX(COALESCE(last_activity, 0), ?) WHERE pid = ?`, now, cur, ); err != nil { tx.Rollback() writeError(w, http.StatusInternalServerError, "Beitrag konnte nicht gespeichert werden") return } if err := tx.QueryRow(`SELECT reply_to FROM entry WHERE pid = ?`, cur).Scan(&cur); err != nil { break } } if err := tx.Commit(); err != nil { writeError(w, http.StatusInternalServerError, "Beitrag konnte nicht gespeichert werden") return } writeJSON(w, http.StatusCreated, map[string]any{ "pid": pid, "content": content, "filepath": filepath, "reply_to": replyTo, }) } // 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) { pid, err := strconv.ParseInt(chi.URLParam(r, "pid"), 10, 64) if err != nil { writeError(w, http.StatusBadRequest, "Ungültige pid") return } uid := uidFromContext(r.Context()) var owner int64 if err := db.QueryRow(`SELECT uid FROM entry WHERE pid = ?`, pid).Scan(&owner); err != nil { writeError(w, http.StatusNotFound, "Beitrag nicht gefunden") return } if owner != uid { writeError(w, http.StatusForbidden, "Das ist nicht dein Beitrag.") return } content := strings.TrimSpace(r.FormValue("content")) if content == "" { writeError(w, http.StatusBadRequest, "Inhalt darf nicht leer sein.") return } if len(content) > 1000 { content = content[:1000] } if _, err := db.Exec(`UPDATE entry SET content = ? WHERE pid = ?`, content, pid); err != nil { writeError(w, http.StatusInternalServerError, "Bearbeiten fehlgeschlagen") return } writeJSON(w, http.StatusOK, map[string]any{"pid": pid, "content": content}) } // 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) { pid, err := strconv.ParseInt(chi.URLParam(r, "pid"), 10, 64) if err != nil { writeError(w, http.StatusBadRequest, "Ungültige pid") return } uid := uidFromContext(r.Context()) var owner int64 var filepath string if err := db.QueryRow(`SELECT uid, filepath FROM entry WHERE pid = ?`, pid).Scan(&owner, &filepath); err != nil { writeError(w, http.StatusNotFound, "Beitrag nicht gefunden") return } if owner != uid { writeError(w, http.StatusForbidden, "Das ist nicht dein Beitrag.") return } if _, err := db.Exec( `UPDATE entry SET deleted = 1, content = '', filepath = '', uid = 0 WHERE pid = ?`, pid, ); err != nil { writeError(w, http.StatusInternalServerError, "Löschen fehlgeschlagen") return } if filepath != "" { os.Remove(filepath) } writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) } // 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 } } db.QueryRow(`SELECT COUNT(*) FROM vote WHERE pid = ? AND mode = 'left'`, pid).Scan(&left) db.QueryRow(`SELECT COUNT(*) FROM vote WHERE pid = ? AND mode = 'right'`, pid).Scan(&right) return } 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) { pid, err := strconv.ParseInt(chi.URLParam(r, "pid"), 10, 64) if err != nil { writeError(w, http.StatusBadRequest, "Ungültige pid") return } var uid int64 if s, ok := getSession(r); ok { uid = s.UID } 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) { pid, err := strconv.ParseInt(chi.URLParam(r, "pid"), 10, 64) if err != nil { writeError(w, http.StatusBadRequest, "Ungültige pid") return } uid := uidFromContext(r.Context()) mode := r.FormValue("mode") if mode != "left" && mode != "right" { writeError(w, http.StatusBadRequest, "Ungültiger Modus (left oder right)") return } // Nur auf existierende Beiträge abstimmen, sonst entstehen Waisen-Votes. if _, err := entryByPID(pid); err != nil { writeError(w, http.StatusNotFound, "Beitrag nicht gefunden") return } var prev string readErr := db.QueryRow(`SELECT mode FROM vote WHERE uid = ? AND pid = ?`, uid, pid).Scan(&prev) if readErr != nil && readErr != sql.ErrNoRows { writeError(w, http.StatusInternalServerError, "Datenbankfehler") return } var execErr error switch { case readErr == sql.ErrNoRows: _, execErr = db.Exec(`INSERT INTO vote (uid, pid, mode) VALUES (?, ?, ?)`, uid, pid, mode) case prev == mode: _, execErr = db.Exec(`DELETE FROM vote WHERE uid = ? AND pid = ?`, uid, pid) default: _, execErr = db.Exec(`UPDATE vote SET mode = ? WHERE uid = ? AND pid = ?`, mode, uid, pid) } if execErr != nil { writeError(w, http.StatusInternalServerError, "Datenbankfehler") return } writeTally(w, pid, uid) } // storeImage skaliert ein hochgeladenes Bild auf max. 1024px und speichert es unter static/media. // Hinweis: animierte GIFs werden derzeit auf das erste Frame reduziert. func storeImage(fh *multipart.FileHeader) (string, error) { const maxSize = 1024 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. const maxPixels = 50_000_000 // ~50 MP cfg, _, err := image.DecodeConfig(src) if err != nil { return "", err } if cfg.Width <= 0 || cfg.Height <= 0 || cfg.Width*cfg.Height > maxPixels { return "", fmt.Errorf("bild zu groß: %dx%d", cfg.Width, cfg.Height) } if _, err := src.Seek(0, io.SeekStart); err != nil { return "", err } img, format, err := image.Decode(src) if err != nil { return "", err } b := img.Bounds() width, height := b.Dx(), b.Dy() nw, nh := width, height if width > maxSize || height > maxSize { if width > height { nw, nh = maxSize, height*maxSize/width } else { nw, nh = width*maxSize/height, maxSize } } ext := "jpg" if format == "gif" { ext = "gif" } if err := os.MkdirAll("static/media", 0o755); err != nil { return "", err } 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 }