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
This commit is contained in:
irrlicht
2026-09-27 15:03:22 +02:00
co-authored by Claude Opus 5.5
parent d1bd0f5f1d
commit df0cfec132
33 changed files with 235 additions and 933 deletions
+68 -122
View File
@@ -1,7 +1,6 @@
package main
import (
"database/sql"
"errors"
"fmt"
"image"
@@ -56,15 +55,26 @@ const entrySelect = `
COALESCE(u.username, ''), COALESCE(u.avatar, '')
FROM entry e LEFT JOIN account u ON u.uid = e.uid`
// scanEntries liest Entry-Zeilen aus einem Query mit entrySelect-Spalten.
func scanEntries(rows *sql.Rows) ([]Entry, error) {
// 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() {
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 {
e, err := scanEntry(rows)
if err != nil {
return nil, err
}
entries = append(entries, e)
@@ -74,12 +84,7 @@ func scanEntries(rows *sql.Rows) ([]Entry, error) {
// entryByPID liefert einen einzelnen Beitrag oder sql.ErrNoRows.
func entryByPID(pid int64) (Entry, error) {
var e Entry
err := db.QueryRow(entrySelect+` WHERE e.pid = $1`, 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
return scanEntry(db.QueryRow(entrySelect+` WHERE e.pid = $1`, pid))
}
const (
@@ -89,49 +94,14 @@ const (
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 {
args = append(args, uid)
where = append(where, fmt.Sprintf("e.uid = $%d", len(args)))
}
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 += fmt.Sprintf(` LIMIT $%d OFFSET $%d`, len(args)+1, len(args)+2)
args = append(args, feedPageSize, page*feedPageSize)
rows, err := db.Query(query, args...)
if err != nil {
return nil, err
}
return scanEntries(rows)
// 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
@@ -141,7 +111,7 @@ func feedPage(page int, uid int64, rootsOnly bool) ([]Entry, error) {
//
// 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 (threadData).
// deckelt die Tiefe wie bei der Ahnenkette (handleThread).
func attachLatest(entries []Entry) error {
if len(entries) == 0 {
return nil
@@ -187,11 +157,7 @@ func attachLatest(entries []Entry) error {
return nil
}
rows, err = db.Query(entrySelect+` WHERE e.pid = ANY($1)`, latestPIDs)
if err != nil {
return err
}
latest, err := scanEntries(rows)
latest, err := queryEntries(entrySelect+` WHERE e.pid = ANY($1)`, latestPIDs)
if err != nil {
return err
}
@@ -209,9 +175,10 @@ func attachLatest(entries []Entry) error {
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 {
page, _ := strconv.Atoi(chi.URLParam(r, "page"))
entries, err := feedPage(page, 0, true)
entries, err := feedPage(r, entrySelect+` WHERE e.reply_to = 0 ORDER BY e.last_activity DESC`)
if err != nil {
return Internal(err)
}
@@ -222,15 +189,15 @@ func handleFeed(w http.ResponseWriter, r *http.Request) error {
return nil
}
// handleUserFeed liefert den Feed eines einzelnen Nutzers (öffentlich, inkl. Antworten).
// 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
}
page, _ := strconv.Atoi(chi.URLParam(r, "page"))
entries, err := feedPage(page, uid, false)
entries, err := feedPage(r, entrySelect+` WHERE e.uid = $1 ORDER BY e.created_at DESC`, uid)
if err != nil {
return Internal(err)
}
@@ -253,49 +220,30 @@ func pidParam(r *http.Request) (int64, error) {
return pid, nil
}
// handleThread liefert einen Beitrag mit seiner Ahnenkette (Root zuerst) und den
// direkten Antworten samt deren jüngstem Nachfahren (öffentlich).
// 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
}
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
return errEntryNotFound
}
t := thread{Entry: entry, Ancestors: []Entry{}, Replies: []Entry{}}
t := thread{Entry: entry, Ancestors: []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.
// 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(
t.Ancestors, err = queryEntries(
`WITH RECURSIVE chain(pid, depth) AS (
SELECT $1::bigint, 1
UNION ALL
@@ -306,24 +254,20 @@ func threadData(pid int64) (thread, error) {
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)
return Internal(err)
}
}
rows, err := db.Query(entrySelect+` WHERE e.reply_to = $1 ORDER BY e.last_activity DESC LIMIT 100`, pid)
t.Replies, err = queryEntries(entrySelect+` WHERE e.reply_to = $1 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 Internal(err)
}
if err := attachLatest(t.Replies); err != nil {
return thread{}, Internal(err)
return Internal(err)
}
return t, nil
writeJSON(w, http.StatusOK, t)
return nil
}
func handleCreateEntry(w http.ResponseWriter, r *http.Request) error {
@@ -504,10 +448,9 @@ 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).
// 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 {
@@ -539,9 +482,8 @@ func handleBump(w http.ResponseWriter, r *http.Request) error {
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.
// 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{
@@ -656,8 +598,7 @@ const (
// 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.
// war zu groß -> 413) oder kaputtes Multipart (-> 400).
func errUploadTooLargeOrBroken(err error) error {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
@@ -749,7 +690,7 @@ func storeImage(fh *multipart.FileHeader) (string, error) {
if format == "gif" {
ext = "gif"
}
name := fmt.Sprintf("static/media/%d-%d.%s", time.Now().Unix(), rand.IntN(999999), ext)
name := newMediaName(ext)
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
xdraw.CatmullRom.Scale(dst, dst.Bounds(), img, b, xdraw.Over, nil)
@@ -776,7 +717,7 @@ func storeImage(fh *multipart.FileHeader) (string, error) {
// 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)
name := newMediaName(ext)
out, err := os.Create(name)
if err != nil {
return "", err
@@ -795,3 +736,8 @@ func copyUpload(src io.Reader, ext string) (string, error) {
}
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)
}