SSR-Renderer entfernt, Fokus auf SPA + JSON-API

Die Kernlogik bleibt von der Schnittstelle getrennt; der letzte SSR-Stand
liegt im Tag ssr-archiv. Die Moderationsseite bleibt, liefert Fehler aber
als JSON.

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-26 21:28:34 +02:00
co-authored by Claude Opus 5.5
parent eccddcebe6
commit c6ab082f8c
26 changed files with 50 additions and 2603 deletions
+25 -133
View File
@@ -13,7 +13,6 @@ import (
"math/rand/v2"
"mime/multipart"
"net/http"
"net/url"
"os"
"strconv"
"strings"
@@ -81,9 +80,7 @@ const (
maxFeedPage = 100
)
// feedWhere baut die Filterbedingung für Feed-Abfragen. Von feedPage und
// feedTotal gemeinsam benutzt -- zwei getrennte Kopien würden auseinander
// laufen und die Seitenzahl gegenüber dem Inhalt verfälschen.
// feedWhere baut die Filterbedingung für Feed-Abfragen.
func feedWhere(uid int64, rootsOnly bool) (string, []any) {
args := []any{}
where := []string{}
@@ -100,17 +97,6 @@ func feedWhere(uid int64, rootsOnly bool) (string, []any) {
return " WHERE " + strings.Join(where, " AND "), args
}
// feedTotal zählt die Beiträge, die feedPage mit denselben Filtern liefern
// würde -- Grundlage für "Seite n von m" im serverseitig gerenderten Feed.
func feedTotal(uid int64, rootsOnly bool) (int, error) {
where, args := feedWhere(uid, rootsOnly)
var n int
err := db.QueryRow(
`SELECT COUNT(*) FROM entry e LEFT JOIN user u ON u.uid = e.uid`+where, args...,
).Scan(&n)
return n, err
}
// 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.
@@ -215,9 +201,7 @@ type thread struct {
Replies []Entry
}
// threadData holt einen Thread aus der Datenbank. Von beiden Adaptern benutzt
// (JSON über handleThread, HTML über showThread) -- die rekursive CTE gibt es
// deshalb nur einmal.
// threadData holt einen Thread aus der Datenbank.
func threadData(pid int64) (thread, error) {
entry, err := entryByPID(pid)
if err != nil {
@@ -260,7 +244,6 @@ func threadData(pid int64) (thread, error) {
func handleCreateEntry(w http.ResponseWriter, r *http.Request) error {
uid := uidFromContext(r.Context())
json := wantsJSON(r)
// MaxBytesReader kappt den gesamten Request hart: ParseMultipartForm
// begrenzt nur den Speicher, alles darüber liefe sonst unbegrenzt in
@@ -268,11 +251,6 @@ func handleCreateEntry(w http.ResponseWriter, r *http.Request) error {
r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes)
parseErr := r.ParseMultipartForm(16 << 20)
if parseErr != nil && parseErr != http.ErrNotMultipart {
// Hier ist der Formularinhalt nicht mehr lesbar (der Body ist weg oder
// kaputt) -- der Text lässt sich also nicht retten.
if !json {
return failCompose(w, r, 0, composeForm{}, errUploadTooLargeOrBroken(parseErr))
}
return errUploadTooLargeOrBroken(parseErr)
}
@@ -280,18 +258,9 @@ func handleCreateEntry(w http.ResponseWriter, r *http.Request) error {
hadFile := r.MultipartForm != nil && len(r.MultipartForm.File["file"]) > 0
replyTo, _ := strconv.ParseInt(r.FormValue("reply_to"), 10, 64)
// fail zeichnet für Formular-Clients die Seite neu, auf der geschrieben
// wurde -- Thread bei einer Antwort, sonst den Feed.
fail := func(err error) error {
if json {
return err
}
return failCompose(w, r, replyTo, composeForm{Content: content, HadFile: hadFile}, err)
}
if replyTo != 0 {
if _, err := entryByPID(replyTo); err != nil {
return fail(Invalid("entry.parent_missing", "Elternbeitrag nicht gefunden").At("reply_to"))
return Invalid("entry.parent_missing", "Elternbeitrag nicht gefunden").At("reply_to")
}
}
@@ -301,13 +270,13 @@ func handleCreateEntry(w http.ResponseWriter, r *http.Request) error {
if err != nil {
// Nicht stillschweigend ohne Bild posten -- der Nutzer soll
// erfahren, dass sein Bild abgelehnt wurde.
return fail(Invalid("image.rejected", "Bild konnte nicht verarbeitet werden.").At("file").WithCause(err))
return Invalid("image.rejected", "Bild konnte nicht verarbeitet werden.").At("file").WithCause(err)
}
filepath = stored
}
if filepath == "" && content == "" {
return fail(Invalid("entry.empty", "Leerer Beitrag!").At("content"))
return Invalid("entry.empty", "Leerer Beitrag!").At("content")
}
now := time.Now().Unix()
@@ -360,18 +329,6 @@ func handleCreateEntry(w http.ResponseWriter, r *http.Request) error {
return Internal(err)
}
// Ohne JavaScript zum frisch erstellten Beitrag. Nicht über return_to:
// ein neuer Wurzelbeitrag steht oben auf Seite 1, egal von welcher Seite
// aus er geschrieben wurde. Bei einer Antwort führt der Weg in den Thread.
if !json {
target := fmt.Sprintf("/feed#e%d", pid)
if replyTo != 0 {
target = fmt.Sprintf("/beitrag/%d#e%d", replyTo, pid)
}
http.Redirect(w, r, target, http.StatusSeeOther)
return nil
}
writeJSON(w, http.StatusCreated, map[string]any{
"pid": pid,
"content": content,
@@ -385,41 +342,43 @@ func handleCreateEntry(w http.ResponseWriter, r *http.Request) error {
// 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 {
json := wantsJSON(r)
pid, err := pidParam(r)
if err != nil {
return err
}
// Dieselbe Prüfung wie beim Anzeigen des Formulars -- Besitz und Zustand
// können sich zwischen Aufruf und Absenden geändert haben.
if _, err := ownEntry(r, pid); err != nil {
return err
}
content := truncateRunes(strings.TrimSpace(r.FormValue("content")), 1000)
if content == "" {
e := Invalid("entry.empty", "Inhalt darf nicht leer sein.").At("content")
if json {
return e
}
return failEdit(w, r, pid, content, e)
return Invalid("entry.empty", "Inhalt darf nicht leer sein.").At("content")
}
if _, err := db.Exec(`UPDATE entry SET content = ? WHERE pid = ?`, content, pid); err != nil {
return Internal(err)
}
// Zurück dorthin, wo der Bearbeiten-Link angeklickt wurde -- der Beitrag
// kann im Feed, im Nutzerfeed oder in einem Thread gestanden haben.
if !json {
redirectBack(w, r, threadAnchor(pid))
return nil
}
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.
@@ -428,8 +387,8 @@ func handleDeleteEntry(w http.ResponseWriter, r *http.Request) error {
if err != nil {
return err
}
// Dieselbe Prüfung wie auf der Bestätigungsseite; entryByPID liefert dabei
// auch den Bildpfad, der nach dem UPDATE nicht mehr in der Zeile steht.
// 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
@@ -444,12 +403,6 @@ func handleDeleteEntry(w http.ResponseWriter, r *http.Request) error {
os.Remove(e.Filepath)
}
// Zurück dorthin, wo gelöscht wurde -- der Platzhalter steht dort weiterhin,
// der Anker bleibt also gültig.
if !wantsJSON(r) {
redirectBack(w, r, "/feed")
return nil
}
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
return nil
}
@@ -496,18 +449,7 @@ func handleBump(w http.ResponseWriter, r *http.Request) error {
if remaining < 0 {
remaining = 0
}
// Ohne JavaScript: der Cooldown ist eine eigene Fehlerseite mit der
// Restzeit -- ein stiller Rücksprung wäre von "hat geklappt" nicht zu
// unterscheiden.
if !wantsJSON(r) {
hours := (remaining + 3599) / 3600
if hours < 1 {
hours = 1
}
return RateLimited("entry.bump_cooldown", fmt.Sprintf(
"Dieser Beitrag wurde erst kürzlich repostet. Nächster Repost in etwa %d Stunde(n).", hours))
}
// JSON-Zweig: bewusst kein Taxonomie-Fehler -- die Antwort trägt
// 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.
w.Header().Set("Retry-After", strconv.FormatInt(remaining, 10))
@@ -522,12 +464,6 @@ func handleBump(w http.ResponseWriter, r *http.Request) error {
return nil
}
// Ohne JavaScript zurück an den Beitrag, auf dem repostet wurde -- der
// Anker in return_to hält die Position.
if !wantsJSON(r) {
redirectBack(w, r, threadAnchor(pid))
return nil
}
writeJSON(w, http.StatusOK, map[string]any{
"pid": pid,
"bump_count": bumpCount,
@@ -695,48 +631,10 @@ func handleReport(w http.ResponseWriter, r *http.Request) error {
return Internal(err)
}
// Ohne JavaScript auf eine eigene Bestätigungsseite statt zurück in den
// Feed: bei einer Meldung soll erkennbar sein, dass sie angekommen ist.
// Ein stiller Rücksprung wäre von "nichts passiert" nicht zu unterscheiden.
// Der Weg zurück ist ein Link, kein automatischer Redirect -- der Nutzer
// entscheidet, wann er weiterliest.
if !wantsJSON(r) {
q := url.Values{"return_to": {safeReturn(r.FormValue("return_to"), "/feed")}}
if reason != "" {
q.Set("reason", reason)
}
http.Redirect(w, r, fmt.Sprintf("/entry/%d/gemeldet?%s", pid, q.Encode()), http.StatusSeeOther)
return nil
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
return nil
}
// reportedView füttert die Bestätigungsseite.
type reportedView struct {
PID int64
Reason string
BackURL string
}
// showReported bestätigt eine eingegangene Meldung. Eigene Seite mit eigener
// URL, damit ein Reload nichts erneut meldet (Post/Redirect/Get).
func showReported(w http.ResponseWriter, r *http.Request) error {
pid, err := pidParam(r)
if err != nil {
return err
}
render(w, r, "reported", pageData{
Title: "Meldung eingegangen",
Form: reportedView{
PID: pid,
Reason: truncateRunes(r.URL.Query().Get("reason"), 500),
BackURL: safeReturn(r.URL.Query().Get("return_to"), "/feed"),
},
})
return nil
}
// handleVote gibt die Stimme des angemeldeten Nutzers ab oder schaltet sie um:
// - keine bisherige Stimme -> neue Stimme
// - gleiche Stimme erneut -> Stimme zurückziehen (Toggle)
@@ -776,12 +674,6 @@ func handleVote(w http.ResponseWriter, r *http.Request) error {
}
}
// Ohne JavaScript zurück an den Beitrag, auf dem abgestimmt wurde -- der
// Anker in return_to hält die Position im Feed.
if !wantsJSON(r) {
redirectBack(w, r, "/feed")
return nil
}
writeTally(w, pid, uid)
return nil
}