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