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