Umbau-Projekt (SSR und Rest)

This commit is contained in:
2026-09-03 22:20:55 +02:00
parent 13363cb568
commit c66df283d1
37 changed files with 3719 additions and 344 deletions
+499
View File
@@ -0,0 +1,499 @@
package main
import (
"fmt"
"html/template"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
)
// Serverseitig gerenderter Feed: das Gegenstück zu createFeed/renderEntry in
// web/js/feed.js. Beide holen dieselben Daten über feedPage; nur das Zeichnen
// passiert einmal in Go und einmal in JavaScript.
//
// Der Unterschied im Blättern ist Absicht: mit JavaScript hängt der Feed die
// nächste Seite unten an (Infinite Scroll), ohne JavaScript wechselt man auf
// eine eigene Seiten-URL. Nur so bleibt ein Anker (#e123) nach einer Aktion
// gültig -- bei akkumulierendem Nachladen wäre der Beitrag nach der Rückkehr
// gar nicht geladen.
// reContentURL entspricht dem Muster in feed.js (linkify): http/https bis zum
// nächsten Leerzeichen oder '<'. Das Schema ist Teil des Musters, deshalb kann
// hier kein javascript:-Link entstehen.
var reContentURL = regexp.MustCompile(`https?://[^\s<]+`)
// renderContent macht aus dem rohen Beitragstext sicheres HTML: alles wird
// escaped, danach werden nur die selbst erzeugten Links und <br> eingefügt.
// Reihenfolge ist wesentlich -- erst escapen, dann Markup ergänzen. Umgekehrt
// würde das Escapen die eigenen Tags wieder zerlegen.
func renderContent(s string) template.HTML {
var b strings.Builder
esc := func(part string) {
b.WriteString(strings.ReplaceAll(template.HTMLEscapeString(part), "\n", "<br>"))
}
last := 0
for _, m := range reContentURL.FindAllStringIndex(s, -1) {
esc(s[last:m[0]])
link := template.HTMLEscapeString(s[m[0]:m[1]])
b.WriteString(`<a href="` + link + `" target="_blank" rel="noopener">` + link + `</a>`)
last = m[1]
}
esc(s[last:])
return template.HTML(b.String())
}
// avatarURL spiegelt avatarUrl aus feed.js.
func avatarURL(path string) string {
if path == "" {
return "/static/assets/no_profile_pic.jpg"
}
return "/" + path
}
// entryView ist ein Beitrag mit allem, was die Vorlage zum Zeichnen braucht.
// Der Abstimmungsstand hängt am Betrachter, ReturnTo am Seitenaufruf --
// beides kann die Entry-Struktur aus der Datenbank nicht liefern.
type entryView struct {
Entry
tallyState
// ReturnTo ist auf einer Seite überall gleich; am Beitrag statt an der
// Seite, damit der entry-Block ohne Zugriff auf den Seitenkontext
// auskommt und auch einzeln (Thread-Ansicht) verwendbar bleibt.
ReturnTo string
LoggedIn bool
// EditURL ist gesetzt, wenn der Betrachter den Beitrag bearbeiten darf.
// Die Adresse trägt den Rückweg schon mit, damit das Speichern dorthin
// zurückführt, wo der Bearbeiten-Link angeklickt wurde -- und nicht auf
// die Bearbeitungsseite selbst.
EditURL string
// DeleteURL führt auf die Bestätigungsseite, nicht direkt aufs Löschen:
// ein Link darf nichts Unwiderrufliches auslösen (Vorschauen und Crawler
// folgen GET-Links ungefragt).
DeleteURL string
}
// composeForm hält die Eingaben des Beitragsformulars über einen
// fehlgeschlagenen Versuch hinweg. Die angehängte Datei fehlt dabei
// zwangsläufig: ein <input type="file"> lässt sich aus Sicherheitsgründen
// nicht vorbelegen, deshalb HadFile als Hinweis für den Nutzer.
type composeForm struct {
Content string
HadFile bool
}
// feedView ist das Datenmodell der Feed-Seite.
type feedView struct {
Entries []entryView
Page int // 0-basiert wie feedPage
Pages int // Gesamtzahl, mindestens 1
PrevURL string
NextURL string
// CanPost steuert, ob das Beitragsformular erscheint. Ohne Anmeldung gibt
// es nichts zu schreiben; über TOR wird das Schreiben abgelehnt (blockTOR),
// und ein Formular, das beim Absenden immer scheitert, wäre schlechter als
// ein ehrlicher Hinweis.
CanPost bool
IsTOR bool
Compose composeForm
}
// HumanPage ist die für Menschen gezählte Seitenzahl (ab 1).
func (v feedView) HumanPage() int { return v.Page + 1 }
// pageURL baut die URL einer Feed-Seite aus der aktuellen Anfrage. Über
// r.URL.Query() statt aus Einzelteilen, damit spätere Filter (Suche,
// Nutzer-Feed) automatisch erhalten bleiben.
func pageURL(r *http.Request, page int) string {
q := r.URL.Query()
if page <= 0 {
q.Del("page")
} else {
q.Set("page", strconv.Itoa(page))
}
if len(q) == 0 {
return r.URL.Path
}
return r.URL.Path + "?" + q.Encode()
}
// buildFeedView holt Beiträge und Gesamtzahl und baut die Blätter-Links.
func buildFeedView(r *http.Request, uid int64, rootsOnly bool) (feedView, error) {
// Bei GET steht die Seite in der Query. Wird der Feed nach einem
// fehlgeschlagenen POST neu gerendert, kommt sie aus dem Formular --
// sonst landet der Nutzer mit seinem Text unvermittelt auf Seite 1.
pageParam := r.URL.Query().Get("page")
if pageParam == "" {
pageParam = r.FormValue("page")
}
page, _ := strconv.Atoi(pageParam)
if page < 0 {
page = 0
}
if page > maxFeedPage {
page = maxFeedPage
}
total, err := feedTotal(uid, rootsOnly)
if err != nil {
return feedView{}, Internal(err)
}
pages := (total + feedPageSize - 1) / feedPageSize
if pages < 1 {
pages = 1
}
if pages > maxFeedPage+1 {
pages = maxFeedPage + 1
}
// Hinter der letzten Seite gäbe es nur eine leere Liste ohne Weg zurück.
if page > pages-1 {
page = pages - 1
}
entries, err := feedPage(page, uid, rootsOnly)
if err != nil {
return feedView{}, Internal(err)
}
// Der Betrachter ist nicht der gefilterte Nutzer: uid oben schränkt ein,
// wessen Beiträge gezeigt werden -- viewer ist, wer zusieht.
var viewer int64
if s, ok := getSession(r); ok {
viewer = s.UID
}
pids := make([]int64, 0, len(entries))
for _, e := range entries {
pids = append(pids, e.PID)
}
tallies, err := voteTallies(pids, viewer)
if err != nil {
return feedView{}, Internal(err)
}
v := feedView{
Entries: toEntryViews(entries, tallies, returnTo(r), viewer),
Page: page,
Pages: pages,
IsTOR: isTOR(r),
}
v.CanPost = viewer != 0 && !v.IsTOR
if page > 0 {
v.PrevURL = pageURL(r, page-1)
}
if page < pages-1 {
v.NextURL = pageURL(r, page+1)
}
return v, nil
}
// threadView ist das Datenmodell der Thread-Seite.
type threadView struct {
Ancestors []entryView
Entry entryView
Replies []entryView
CanPost bool
IsTOR bool
Compose composeForm
}
// toEntryViews reichert Beiträge mit Abstimmungsstand, Rücksprungziel und
// Anmeldestatus an -- alles, was die Vorlage braucht und die Datenbank nicht
// liefert. Feed und Thread teilen sich den Schritt.
func toEntryViews(entries []Entry, tallies map[int64]tallyState, back string, viewer int64) []entryView {
views := make([]entryView, 0, len(entries))
for _, e := range entries {
v := entryView{
Entry: e,
tallyState: tallies[e.PID],
ReturnTo: back,
LoggedIn: viewer != 0,
}
// Gelöschte Beiträge haben uid 0 -- ohne die Deleted-Prüfung wäre der
// Platzhalter für einen nicht angemeldeten Betrachter "eigener"
// Beitrag. viewer != 0 fängt das zwar schon ab, die Prüfung bleibt
// trotzdem stehen: an einem [deleted] gibt es nichts zu bearbeiten.
if viewer != 0 && e.UID == viewer && e.Deleted == 0 {
v.EditURL = ownActionURL("bearbeiten", e.PID, back)
v.DeleteURL = ownActionURL("loeschen", e.PID, back)
}
views = append(views, v)
}
return views
}
// ownActionURL verlinkt eine Seite für den eigenen Beitrag (bearbeiten,
// loeschen) und hängt den Rückweg als Query an. url.Values kodiert dabei auch
// das '#' des Ankers korrekt -- von Hand zusammengesetzt würde der Anker die
// Query zerschneiden.
func ownActionURL(action string, pid int64, back string) string {
q := url.Values{"return_to": {fmt.Sprintf("%s#e%d", back, pid)}}
return fmt.Sprintf("/beitrag/%d/%s?%s", pid, action, q.Encode())
}
// buildThreadView holt den Thread und bereitet ihn zum Zeichnen auf.
func buildThreadView(r *http.Request, pid int64) (threadView, error) {
t, err := threadData(pid)
if err != nil {
return threadView{}, err
}
var viewer int64
if s, ok := getSession(r); ok {
viewer = s.UID
}
// Alle Beiträge der Seite in einem Rutsch -- Ahnen, der Beitrag selbst und
// die Antworten.
pids := make([]int64, 0, len(t.Ancestors)+1+len(t.Replies))
pids = append(pids, t.Entry.PID)
for _, e := range t.Ancestors {
pids = append(pids, e.PID)
}
for _, e := range t.Replies {
pids = append(pids, e.PID)
}
tallies, err := voteTallies(pids, viewer)
if err != nil {
return threadView{}, Internal(err)
}
back := returnTo(r)
v := threadView{
Ancestors: toEntryViews(t.Ancestors, tallies, back, viewer),
Entry: toEntryViews([]Entry{t.Entry}, tallies, back, viewer)[0],
Replies: toEntryViews(t.Replies, tallies, back, viewer),
IsTOR: isTOR(r),
}
// Auf einen gelöschten Beitrag lässt sich nicht antworten -- er hat keinen
// Autor mehr und der Platzhalter soll den Thread nur zusammenhalten.
v.CanPost = viewer != 0 && !v.IsTOR && t.Entry.Deleted == 0
return v, nil
}
// showThread rendert einen Beitrag mit Ahnenkette, Antworten und Antwortformular.
func showThread(w http.ResponseWriter, r *http.Request) error {
pid, err := pidParam(r)
if err != nil {
return err
}
v, err := buildThreadView(r, pid)
if err != nil {
return err
}
render(w, r, "thread", pageData{Title: "Beitrag", Form: v})
return nil
}
// editView füttert die Bearbeitungsseite. Anders als beim Erstellen ist der
// Ausgangszustand nicht leer, sondern der bisherige Text.
type editView struct {
PID int64
Content string
BackURL string
}
// ownEntry lädt einen Beitrag und prüft, ob er dem Anfragenden gehört --
// Voraussetzung fürs Bearbeiten wie fürs Löschen. Bewusst dieselbe Prüfkette
// wie in den schreibenden Handlern: eine Seite anzuzeigen, die beim Absenden
// abgelehnt wird, wäre irreführend.
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
}
// showEditEntry zeigt das Bearbeitungsformular mit dem bisherigen Text.
func showEditEntry(w http.ResponseWriter, r *http.Request) error {
pid, err := pidParam(r)
if err != nil {
return err
}
e, err := ownEntry(r, pid)
if err != nil {
return err
}
render(w, r, "edit", pageData{
Title: "Beitrag bearbeiten",
Form: editView{
PID: pid,
Content: e.Content,
BackURL: safeReturn(r.URL.Query().Get("return_to"), threadAnchor(pid)),
},
})
return nil
}
// failEdit zeichnet das Bearbeitungsformular nach einem Fehler neu -- mit der
// abgelehnten Fassung, nicht mit dem gespeicherten Stand. Sonst verlöre der
// Nutzer genau die Änderung, die er gerade vorgenommen hat.
func failEdit(w http.ResponseWriter, r *http.Request, pid int64, content string, err error) error {
e := asError(err)
if e.Kind == KindInternal {
renderError(w, e)
return nil
}
failForm(w, r, "edit", pageData{
Title: "Beitrag bearbeiten",
Form: editView{
PID: pid,
Content: content,
BackURL: safeReturn(r.FormValue("return_to"), threadAnchor(pid)),
},
}, e)
return nil
}
// threadAnchor ist der Standard-Rückweg zu einem Beitrag.
func threadAnchor(pid int64) string {
return fmt.Sprintf("/beitrag/%d#e%d", pid, pid)
}
// deleteView füttert die Löschbestätigung. Sie zeigt den Beitrag noch einmal,
// damit erkennbar ist, was verschwindet.
type deleteView struct {
PID int64
Content string
HasImage bool
IsRoot bool
Replies int64
BackURL string
}
// showDeleteEntry fragt vor dem Löschen nach. Eigene Seite statt eines Links,
// der direkt löscht: ein GET darf nichts Unwiderrufliches tun, und ohne
// JavaScript gibt es kein confirm().
func showDeleteEntry(w http.ResponseWriter, r *http.Request) error {
pid, err := pidParam(r)
if err != nil {
return err
}
e, err := ownEntry(r, pid)
if err != nil {
return err
}
render(w, r, "delete", pageData{
Title: "Beitrag löschen",
Form: deleteView{
PID: pid,
Content: e.Content,
HasImage: e.Filepath != "",
IsRoot: e.ReplyTo == 0,
Replies: e.ReplyCount,
BackURL: safeReturn(r.URL.Query().Get("return_to"), threadAnchor(pid)),
},
})
return nil
}
// failCompose zeichnet den Feed nach einem abgelehnten Beitrag neu -- mit
// Fehlermeldung und dem bereits getippten Text. Ohne JavaScript gibt es keinen
// Weg, nur einen Teil der Seite auszutauschen; der Text darf dabei trotzdem
// nicht verloren gehen.
// Antworten werden im Thread neu gezeichnet, neue Beiträge im Feed -- der
// Nutzer soll dort landen, wo er geschrieben hat.
func failCompose(w http.ResponseWriter, r *http.Request, replyTo int64, form composeForm, err error) error {
e := asError(err)
if e.Kind == KindInternal {
renderError(w, e)
return nil
}
if replyTo != 0 {
v, verr := buildThreadView(r, replyTo)
if verr != nil {
// Gibt es den Elternbeitrag nicht (mehr), lässt sich der Thread
// nicht zeichnen -- dann bleibt nur die Fehlerseite.
return verr
}
v.Compose = form
failForm(w, r, "thread", pageData{Title: "Beitrag", Form: v}, e)
return nil
}
v, verr := buildFeedView(r, 0, true)
if verr != nil {
// Wenn schon der Feed nicht mehr geht, ist der Beitragsfehler das
// kleinere Problem -- dann die Fehlerseite zeigen.
return verr
}
v.Compose = form
failForm(w, r, "feed", pageData{Title: "Feed", Form: v}, e)
return nil
}
// showFeed rendert den Hauptfeed (nur Wurzelbeiträge, nach Aktivität sortiert).
func showFeed(w http.ResponseWriter, r *http.Request) error {
v, err := buildFeedView(r, 0, true)
if err != nil {
return err
}
render(w, r, "feed", pageData{Title: "Feed", Form: v})
return nil
}
// showUserFeed rendert den Feed eines einzelnen Nutzers (inkl. Antworten).
func showUserFeed(w http.ResponseWriter, r *http.Request) error {
username := chi.URLParam(r, "username")
var uid int64
if err := db.QueryRow(`SELECT uid FROM user WHERE username = ?`, username).Scan(&uid); err != nil {
return errUserNotFound
}
v, err := buildFeedView(r, uid, false)
if err != nil {
return err
}
render(w, r, "userfeed", pageData{
Title: username,
Form: userFeedView{feedView: v, Username: username},
})
return nil
}
// userFeedView ergänzt den Feed um den Nutzer, dessen Beiträge gezeigt werden.
type userFeedView struct {
feedView
Username string
}
// profileURL escaped den Namen für den Pfad. chi liefert ihn roh; ohne
// Kodierung würden Namen mit '.' oder '-' zwar funktionieren, ein '/' oder '?'
// im Namen aber die URL zerlegen.
func profileURL(username string) string {
return "/u/" + url.PathEscape(username)
}
// fmtTime formatiert einen Unix-Zeitstempel für die Anzeige. Ohne JavaScript
// gibt es keine Client-Zeitzone, deshalb UTC mit ausgeschriebenem Kürzel --
// lieber eindeutig als scheinbar lokal.
func fmtTime(unix int64) string {
return time.Unix(unix, 0).UTC().Format("02.01.2006, 15:04 UTC")
}
// fmtTimeAttr liefert dieselbe Zeit maschinenlesbar für <time datetime>.
// Damit kann eine spätere JS-Schicht sie in Ortszeit umschreiben, ohne dass
// die Seite ohne JavaScript etwas verliert.
func fmtTimeAttr(unix int64) string {
return time.Unix(unix, 0).UTC().Format(time.RFC3339)
}