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:
co-authored by
Claude Opus 5.5
parent
d1bd0f5f1d
commit
df0cfec132
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user