Umbau-Projekt (SSR und Rest)
This commit is contained in:
+499
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user