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
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", "
")) } last := 0 for _, m := range reContentURL.FindAllStringIndex(s, -1) { esc(s[last:m[0]]) link := template.HTMLEscapeString(s[m[0]:m[1]]) b.WriteString(`` + link + ``) 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 } // bumpRemaining ist die Restsperre des Reposts in Sekunden (0 = wieder // möglich). Spiegelt die Bedingung im UPDATE von handleBump; die Vorlage // braucht den Wert, um den Repost-Knopf zu sperren und die Wartezeit // anzuzeigen -- das Gegenstück zu setupBump in web/js/feed.js. func (v entryView) bumpRemaining() int64 { rem := v.LastBump + v.BumpCount*bumpStep - time.Now().Unix() if rem < 0 { return 0 } return rem } // BumpLocked meldet, ob der Repost dieses Beitrags gerade im Cooldown steckt. func (v entryView) BumpLocked() bool { return v.bumpRemaining() > 0 } // BumpWaitHours ist die Restsperre auf volle Stunden aufgerundet (mindestens // 1, solange überhaupt Zeit übrig ist) -- wie die Anzeige im JS-Frontend. func (v entryView) BumpWaitHours() int64 { h := (v.bumpRemaining() + 3599) / 3600 if h < 1 { return 1 } return h } // composeForm hält die Eingaben des Beitragsformulars über einen // fehlgeschlagenen Versuch hinweg. Die angehängte Datei fehlt dabei // zwangsläufig: ein 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 // ReturnTo ist der Rücksprungort dieser Seite (beim GET die Seite selbst). // Das Beitragsformular trägt ihn als verstecktes Feld, damit er einen // abgelehnten Post übersteht -- sonst fiele das neu gerenderte Formular // samt seiner Interaktions-Knöpfe auf den Default "/" zurück (die noch per // JS bediente Startseite) statt auf "/feed". ReturnTo 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) } back := returnTo(r) v := feedView{ Entries: toEntryViews(entries, tallies, back, viewer), Page: page, Pages: pages, IsTOR: isTOR(r), ReturnTo: back, } 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 // ReturnTo wie bei feedView: das Antwortformular trägt ihn versteckt mit, // damit eine abgelehnte Antwort die Interaktions-Knöpfe nicht auf "/" // zurückwirft statt auf den Thread. ReturnTo string } // 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), ReturnTo: back, } // 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