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
@@ -4,10 +4,6 @@ WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY *.go ./
|
||||
# templates/ wird per go:embed ins Binary kompiliert (siehe htmlpage.go) und
|
||||
# muss deshalb schon in der Build-Stage vorliegen -- in der Laufzeit-Stage
|
||||
# braucht es das Verzeichnis dann nicht mehr.
|
||||
COPY templates/ templates/
|
||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /kver .
|
||||
|
||||
# Laufzeit-Stage: minimales Alpine, eigener unprivilegierter Nutzer.
|
||||
|
||||
@@ -58,52 +58,15 @@ func getSession(r *http.Request) (*Session, bool) {
|
||||
// absichtlich derselbe Fehler -- sonst wird der Endpunkt zum Nutzernamen-Orakel.
|
||||
var errBadCredentials = Unauth("auth.bad_credentials", "Nutzername oder Passwort ist falsch.")
|
||||
|
||||
// loginForm trägt die Eingaben über einen fehlgeschlagenen Versuch hinweg.
|
||||
// Bewusst ohne Passwort: das wird nie zurück ins Formular geschrieben.
|
||||
type loginForm struct {
|
||||
User string
|
||||
}
|
||||
|
||||
// showLogin ist die serverseitig gerenderte Login-Seite -- der Weg für Clients
|
||||
// ohne JavaScript (TOR Browser auf "Safest", I2P).
|
||||
func showLogin(w http.ResponseWriter, r *http.Request) error {
|
||||
if _, ok := getSession(r); ok {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return nil
|
||||
}
|
||||
render(w, r, "login", pageData{Title: "Login"})
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleLogin bedient beide Adapter: das JS-Frontend (Accept: application/json)
|
||||
// bekommt JSON, ein abgeschicktes Browser-Formular bekommt einen Redirect bzw.
|
||||
// das neu gerenderte Formular. Die Logik dazwischen existiert nur einmal.
|
||||
func handleLogin(w http.ResponseWriter, r *http.Request) error {
|
||||
json := wantsJSON(r)
|
||||
|
||||
// fail wählt die zum Client passende Fehlerdarstellung. Der Rückgabewert
|
||||
// geht an den Wrapper; bei HTML ist die Antwort schon geschrieben, deshalb
|
||||
// nil.
|
||||
fail := func(username string, err error) error {
|
||||
if json {
|
||||
return err
|
||||
}
|
||||
failForm(w, r, "login", pageData{Title: "Login", Form: loginForm{User: username}}, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, ok := getSession(r); ok {
|
||||
if !json {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return nil
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "already_logged_in"})
|
||||
return nil
|
||||
}
|
||||
|
||||
username := sanitizeUsername(r.FormValue("user"))
|
||||
if len(username) < 3 {
|
||||
return fail(username, Invalid("user.name_short", "Der Nutzername ist zu kurz.").At("user"))
|
||||
return Invalid("user.name_short", "Der Nutzername ist zu kurz.").At("user")
|
||||
}
|
||||
|
||||
// Gleiche Meldung für "Nutzer existiert nicht" und "Passwort falsch": sonst
|
||||
@@ -112,33 +75,24 @@ func handleLogin(w http.ResponseWriter, r *http.Request) error {
|
||||
var hash []byte
|
||||
err := db.QueryRow(`SELECT uid, password FROM user WHERE username = ?`, username).Scan(&uid, &hash)
|
||||
if err == sql.ErrNoRows {
|
||||
return fail(username, errBadCredentials)
|
||||
return errBadCredentials
|
||||
} else if err != nil {
|
||||
return Internal(err)
|
||||
}
|
||||
|
||||
if bcrypt.CompareHashAndPassword(hash, []byte(r.FormValue("pass"))) != nil {
|
||||
return fail(username, errBadCredentials)
|
||||
return errBadCredentials
|
||||
}
|
||||
|
||||
if err := startSession(w, r, uid); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// POST -> Redirect (303) statt einer gerenderten Seite: sonst zeigt ein
|
||||
// Reload den "Formular erneut senden?"-Dialog und legt eine zweite Session
|
||||
// an (Post/Redirect/Get).
|
||||
if !json {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return nil
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
return nil
|
||||
}
|
||||
|
||||
// startSession legt eine Session an und setzt den Cookie. Von Login und
|
||||
// Registrierung gemeinsam genutzt -- die Registrierung ohne JavaScript muss
|
||||
// direkt einloggen, weil es sonst zwei Formulare hintereinander bräuchte.
|
||||
// startSession legt eine Session an und setzt den Cookie.
|
||||
func startSession(w http.ResponseWriter, r *http.Request, uid int64) error {
|
||||
now := time.Now().Unix()
|
||||
// timeout ist clientgesteuert -> serverseitig deckeln, damit niemand eine
|
||||
@@ -200,7 +154,6 @@ func handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
// abzubrechen -- der Nutzer soll nicht Feld für Feld durchprobieren müssen.
|
||||
// Deshalb hier Fields (Feld -> Meldung) statt eines einzelnen Field.
|
||||
func handleNewUser(w http.ResponseWriter, r *http.Request) error {
|
||||
json := wantsJSON(r)
|
||||
fields := map[string]string{}
|
||||
|
||||
username := sanitizeUsername(r.FormValue("user"))
|
||||
@@ -224,62 +177,25 @@ func handleNewUser(w http.ResponseWriter, r *http.Request) error {
|
||||
fields["pass2"] = "Passwort und Passwortbestätigung sind ungleich."
|
||||
}
|
||||
|
||||
// fail wählt die zum Client passende Fehlerdarstellung -- wie bei
|
||||
// handleLogin: JSON-Clients bekommen den Fehler über den Wrapper, ein
|
||||
// Browser-Formular wird mit Meldungen neu gerendert.
|
||||
fail := func(err error) error {
|
||||
if json {
|
||||
return err
|
||||
}
|
||||
failForm(w, r, "register", pageData{Title: "Registrieren", Form: registerForm{User: username}}, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(fields) > 0 {
|
||||
e := Invalid("user.invalid", "Die Angaben sind unvollständig oder ungültig.")
|
||||
e.Fields = fields
|
||||
return fail(e)
|
||||
return e
|
||||
}
|
||||
|
||||
uid, err := createUser(username, pass1)
|
||||
_, err := createUser(username, pass1)
|
||||
if errors.Is(err, errUsernameTaken) {
|
||||
// Rennen zwischen Vorab-Prüfung und INSERT: der UNIQUE-Constraint hat
|
||||
// gegriffen -> als normale Validierungsmeldung statt 500 melden.
|
||||
return fail(errUsernameTaken)
|
||||
return errUsernameTaken
|
||||
} else if err != nil {
|
||||
return Internal(err)
|
||||
}
|
||||
|
||||
// Ohne JavaScript direkt einloggen und weiterleiten: das JS-Frontend macht
|
||||
// genau das (zweiter Aufruf auf /auth/login), und ein zweites Formular nur
|
||||
// zum Anmelden wäre eine unnötige Hürde.
|
||||
if !json {
|
||||
if err := startSession(w, r, uid); err != nil {
|
||||
return err
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return nil
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]string{"username": username})
|
||||
return nil
|
||||
}
|
||||
|
||||
// registerForm trägt die Eingaben über einen fehlgeschlagenen Versuch hinweg --
|
||||
// wieder ohne die Passwörter.
|
||||
type registerForm struct {
|
||||
User string
|
||||
}
|
||||
|
||||
// showRegister ist die serverseitig gerenderte Registrierungsseite.
|
||||
func showRegister(w http.ResponseWriter, r *http.Request) error {
|
||||
if _, ok := getSession(r); ok {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return nil
|
||||
}
|
||||
render(w, r, "register", pageData{Title: "Registrieren"})
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleSessionInfo(w http.ResponseWriter, r *http.Request) error {
|
||||
s, ok := getSession(r)
|
||||
if !ok {
|
||||
|
||||
+1
-1067
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
|
||||
-539
@@ -1,539 +0,0 @@
|
||||
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
|
||||
}
|
||||
|
||||
// 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 <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
|
||||
|
||||
// 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 <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)
|
||||
}
|
||||
+4
-8
@@ -9,16 +9,12 @@ import "net/http"
|
||||
// ResponseWriter. Mit "return err" kann das strukturell nicht mehr passieren.
|
||||
type handlerFunc func(http.ResponseWriter, *http.Request) error
|
||||
|
||||
// api hängt einen fehlerliefernden Handler in chi ein. Der Fehler geht durch
|
||||
// failRequest und wird damit in dem Format ausgegeben, das der Client erwartet
|
||||
// -- diese Endpunkte bedienen inzwischen beide Seiten (siehe wantsJSON).
|
||||
//
|
||||
// Das Gegenstück ist page(): dort ist die Antwort immer HTML, weil es sich um
|
||||
// Seiten und nicht um Endpunkte handelt.
|
||||
// api hängt einen fehlerliefernden Handler in chi ein; der Fehler geht als
|
||||
// JSON über writeAPIError hinaus.
|
||||
func api(h handlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h(w, r); err != nil {
|
||||
failRequest(w, r, err)
|
||||
writeAPIError(w, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,7 +26,7 @@ func guard(check func(http.ResponseWriter, *http.Request) error) func(http.Handl
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := check(w, r); err != nil {
|
||||
failRequest(w, r, err)
|
||||
writeAPIError(w, err)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
|
||||
-273
@@ -1,273 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Templates werden einkompiliert statt zur Laufzeit gelesen. Zwei Gründe: das
|
||||
// Deployment braucht kein zusätzliches Verzeichnis, und -- wichtiger -- sie
|
||||
// dürfen NICHT unter web/ liegen, weil das komplett per FileServer ausgeliefert
|
||||
// wird (siehe routes()). Eine Vorlage dort wäre über HTTP abrufbar.
|
||||
//
|
||||
//go:embed templates/*.html
|
||||
var tmplFS embed.FS
|
||||
|
||||
// pages hält je Seite ein eigenes Template-Set aus base.html + der Seitendatei.
|
||||
// Getrennte Sets, weil jede Seite ihren eigenen "content"-Block definiert --
|
||||
// in einem gemeinsamen Set würde der letzte alle anderen überschreiben.
|
||||
var pages = map[string]*template.Template{}
|
||||
|
||||
// tmplFuncs.fieldErr vereinheitlicht die beiden Fehlerformen für die Vorlage:
|
||||
// ein Fehler mit einzelnem Field (Login) und einer mit Fields-Map
|
||||
// (Registrierung, alle Felder auf einmal) werden gleich abgefragt.
|
||||
var tmplFuncs = template.FuncMap{
|
||||
"fieldErr": func(e *Error, field string) string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
if msg, ok := e.Fields[field]; ok {
|
||||
return msg
|
||||
}
|
||||
if e.Field == field {
|
||||
return e.Msg
|
||||
}
|
||||
return ""
|
||||
},
|
||||
"content": renderContent,
|
||||
"avatar": avatarURL,
|
||||
"profile": profileURL,
|
||||
"time": fmtTime,
|
||||
"timeAttr": fmtTimeAttr,
|
||||
}
|
||||
|
||||
// pageFiles listet je Seite die zusätzlich benötigten Vorlagen. base.html
|
||||
// kommt überall dazu; Seiten mit Beitragslisten brauchen zusätzlich die
|
||||
// gemeinsamen entry-/pager-Blöcke.
|
||||
var pageFiles = map[string][]string{
|
||||
"login": nil,
|
||||
"register": nil,
|
||||
"feed": {"templates/entries.html"},
|
||||
"userfeed": {"templates/entries.html"},
|
||||
"reported": nil,
|
||||
"thread": {"templates/entries.html"},
|
||||
"edit": nil,
|
||||
"delete": nil,
|
||||
}
|
||||
|
||||
func init() {
|
||||
for name, extra := range pageFiles {
|
||||
files := append([]string{"templates/base.html", "templates/" + name + ".html"}, extra...)
|
||||
pages[name] = template.Must(
|
||||
template.New(name).Funcs(tmplFuncs).ParseFS(tmplFS, files...))
|
||||
}
|
||||
}
|
||||
|
||||
// pageData ist der Rahmen jeder Seite. Form trägt die zuletzt eingegebenen
|
||||
// Werte, damit ein Fehler den Nutzer nicht sein Formular neu tippen lässt.
|
||||
// ReturnTo ist der Ort, an den Aktions-Formulare zurückführen sollen.
|
||||
type pageData struct {
|
||||
Title string
|
||||
Err *Error
|
||||
Form any
|
||||
ReturnTo string
|
||||
}
|
||||
|
||||
// render gibt eine Seite mit Status 200 aus.
|
||||
func render(w http.ResponseWriter, r *http.Request, name string, data pageData) {
|
||||
data.ReturnTo = returnTo(r)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
pages[name].ExecuteTemplate(w, "base", data)
|
||||
}
|
||||
|
||||
// returnTo bestimmt den Rücksprungort für die Formulare einer Seite.
|
||||
//
|
||||
// Bei GET ist das die Seite selbst -- inklusive Query, damit Filter (Nutzer-
|
||||
// Feed, Suche, Seitenzahl) automatisch mitreisen, ohne dass die Formulare sie
|
||||
// einzeln kennen müssen. Bei POST wird das mitgeschickte return_to
|
||||
// weitergereicht, damit ein neu gerendertes Formular sein Ziel behält.
|
||||
func returnTo(r *http.Request) string {
|
||||
if r.Method == http.MethodGet || r.Method == http.MethodHead {
|
||||
return safeReturn(r.URL.RequestURI(), "/")
|
||||
}
|
||||
return safeReturn(r.FormValue("return_to"), "/")
|
||||
}
|
||||
|
||||
// safeReturn akzeptiert nur seiteneigene Pfade. Ungeprüft in http.Redirect
|
||||
// gesteckt wäre return_to ein Open-Redirect: ein Link auf die eigene Domain,
|
||||
// der auf einer fremden landet -- die klassische Phishing-Tarnung.
|
||||
//
|
||||
// Abgelehnt wird deshalb alles, was nicht eindeutig ein absoluter Pfad auf
|
||||
// dieser Seite ist:
|
||||
//
|
||||
// - "//host/x" protokoll-relativ, zeigt auf eine fremde Domain
|
||||
// - `/\host/x` manche Browser normalisieren \ zu / -> wie oben
|
||||
// - "https://x" absolute URL
|
||||
// - "u/alice" relativ; würde je nach Basis irgendwo landen
|
||||
// - "\r\n..." Steuerzeichen könnten weitere Header einschleusen
|
||||
func safeReturn(v, fallback string) string {
|
||||
if v == "" || len(v) > 512 {
|
||||
return fallback
|
||||
}
|
||||
if v[0] != '/' || strings.HasPrefix(v, "//") || strings.HasPrefix(v, `/\`) {
|
||||
return fallback
|
||||
}
|
||||
if strings.ContainsAny(v, "\r\n\x00") {
|
||||
return fallback
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// redirectBack schickt nach einer erfolgreichen Aktion dorthin zurück, wo sie
|
||||
// ausgelöst wurde -- der noscript-Ersatz für "DOM aktualisieren, Seite stehen
|
||||
// lassen". 303 statt 302, damit der Browser die Zielseite per GET holt und ein
|
||||
// Reload die Aktion nicht wiederholt.
|
||||
func redirectBack(w http.ResponseWriter, r *http.Request, fallback string) {
|
||||
http.Redirect(w, r, safeReturn(r.FormValue("return_to"), fallback), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// failForm rendert ein Formular nach einem Fehler neu -- mit Meldung und den
|
||||
// bisherigen Eingaben. Das ist der noscript-Ersatz für die Fehlermeldung, die
|
||||
// das JS-Frontend sonst per fetch nachlädt.
|
||||
//
|
||||
// Serverfehler landen bewusst NICHT im Formular: bei einem 500 ist unklar, ob
|
||||
// die Eingabe überhaupt das Problem war, und ein "bitte nochmal absenden" wäre
|
||||
// die falsche Aufforderung.
|
||||
func failForm(w http.ResponseWriter, r *http.Request, name string, data pageData, err error) {
|
||||
e := asError(err)
|
||||
if e.Kind == KindInternal {
|
||||
renderError(w, e)
|
||||
return
|
||||
}
|
||||
data.Err = e
|
||||
data.ReturnTo = returnTo(r)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(statusOf(e.Kind))
|
||||
pages[name].ExecuteTemplate(w, "base", data)
|
||||
}
|
||||
|
||||
// failRequest gibt einen Fehler in dem Format aus, das der Client erwartet.
|
||||
// Für Endpunkte, die beide Seiten bedienen: ein Browser-Formular darf niemals
|
||||
// rohes JSON zu sehen bekommen -- ohne JavaScript gibt es nichts, was es
|
||||
// übersetzen könnte.
|
||||
func failRequest(w http.ResponseWriter, r *http.Request, err error) {
|
||||
if wantsJSON(r) {
|
||||
writeAPIError(w, err)
|
||||
return
|
||||
}
|
||||
renderError(w, err)
|
||||
}
|
||||
|
||||
// wantsJSON entscheidet, welcher Adapter antwortet. Das JS-Frontend schickt
|
||||
// Accept: application/json (siehe api in feed.js), ein Browser-Formular
|
||||
// schickt text/html -- derselbe Endpunkt bedient damit beide, ohne dass die
|
||||
// Logik doppelt existiert.
|
||||
func wantsJSON(r *http.Request) bool {
|
||||
return strings.Contains(r.Header.Get("Accept"), "application/json")
|
||||
}
|
||||
|
||||
// HTML-Adapter: das Gegenstück zu writeAPIError. Derselbe Fehler aus derselben
|
||||
// Taxonomie, nur als Seite statt als JSON -- welchen Statuscode er bekommt,
|
||||
// entscheidet weiterhin statusOf, damit die beiden Adapter nicht auseinander
|
||||
// laufen können.
|
||||
//
|
||||
// Die Seite kommt bewusst ohne JavaScript aus: sie ist der Weg, auf dem ein
|
||||
// Nutzer mit abgeschaltetem JS (TOR Browser auf "Safest", I2P) überhaupt
|
||||
// erfährt, was schiefgegangen ist.
|
||||
|
||||
type errorPageData struct {
|
||||
Status int
|
||||
Title string
|
||||
Message string
|
||||
Code string
|
||||
}
|
||||
|
||||
var errorTmpl = template.Must(template.New("error").Parse(`<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title>{{.Status}} · {{.Title}}</title>
|
||||
<link rel="stylesheet" href="/css/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>{{.Status}} · {{.Title}}</h1>
|
||||
<p>{{.Message}}</p>
|
||||
<p class="muted">Fehlercode: {{.Code}}</p>
|
||||
<p><a href="/feed">Zum Feed</a></p>
|
||||
</main>
|
||||
</body>
|
||||
</html>`))
|
||||
|
||||
// titleForKind liefert die Überschrift der Fehlerseite. Bewusst getrennt von
|
||||
// der Msg des Fehlers: die Überschrift ordnet grob ein ("Nicht gefunden"), die
|
||||
// Meldung sagt konkret, was los ist ("Beitrag nicht gefunden").
|
||||
func titleForKind(k Kind) string {
|
||||
switch k {
|
||||
case KindInvalid:
|
||||
return "Ungültige Anfrage"
|
||||
case KindUnauth:
|
||||
return "Anmeldung erforderlich"
|
||||
case KindForbidden:
|
||||
return "Nicht erlaubt"
|
||||
case KindNotFound:
|
||||
return "Nicht gefunden"
|
||||
case KindConflict:
|
||||
return "Konflikt"
|
||||
case KindTooLarge:
|
||||
return "Zu groß"
|
||||
case KindRateLimited:
|
||||
return "Zu viele Anfragen"
|
||||
case KindUnavailable:
|
||||
return "Nicht verfügbar"
|
||||
default:
|
||||
return "Serverfehler"
|
||||
}
|
||||
}
|
||||
|
||||
// renderError ist der einzige Ausgang für Fehler im HTML-Adapter.
|
||||
func renderError(w http.ResponseWriter, err error) {
|
||||
e := asError(err)
|
||||
logInternal(e)
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(statusOf(e.Kind))
|
||||
// Execute-Fehler werden verschluckt: der Status steht schon in der
|
||||
// Leitung, eine zweite Antwort ginge nicht mehr.
|
||||
errorTmpl.Execute(w, errorPageData{
|
||||
Status: statusOf(e.Kind),
|
||||
Title: titleForKind(e.Kind),
|
||||
Message: e.Msg,
|
||||
Code: e.Code,
|
||||
})
|
||||
}
|
||||
|
||||
// page hängt einen fehlerliefernden Handler als HTML-Seite ein -- das
|
||||
// Gegenstück zu api(). Derselbe Handler kann an beiden Wrappern hängen; das
|
||||
// Ausgabeformat entscheidet allein die Route.
|
||||
func page(h handlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h(w, r); err != nil {
|
||||
renderError(w, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pageMiddleware ist das HTML-Gegenstück zu apiMiddleware. Der Prüfschritt darf
|
||||
// über den ResponseWriter, weil manche Prüfungen vor der Antwort noch Header
|
||||
// setzen müssen (Basic-Auth braucht WWW-Authenticate).
|
||||
func pageMiddleware(check func(http.ResponseWriter, *http.Request) error) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := check(w, r); err != nil {
|
||||
renderError(w, err)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestSafeReturn deckt die Open-Redirect-Abwehr ab. Jede hier durchgelassene
|
||||
// Eingabe landet später in einem Location-Header -- die abgelehnten Fälle sind
|
||||
// deshalb wichtiger als die erlaubten.
|
||||
func TestSafeReturn(t *testing.T) {
|
||||
const fallback = "/"
|
||||
|
||||
erlaubt := []string{
|
||||
"/",
|
||||
"/u/alice",
|
||||
"/u/alice?page=3",
|
||||
"/?page=2#e123",
|
||||
"/u/alice?q=%22katzen%22&page=1#e42",
|
||||
"/e/123",
|
||||
}
|
||||
for _, v := range erlaubt {
|
||||
if got := safeReturn(v, fallback); got != v {
|
||||
t.Errorf("safeReturn(%q) = %q, erwartet unverändert", v, got)
|
||||
}
|
||||
}
|
||||
|
||||
abgelehnt := map[string]string{
|
||||
"leer": "",
|
||||
"protokoll-relativ": "//evil.example/x",
|
||||
"drei slashes": "///evil.example",
|
||||
"backslash-variante": `/\evil.example`,
|
||||
"absolute https": "https://evil.example",
|
||||
"absolute http": "http://evil.example",
|
||||
"javascript-schema": "javascript:alert(1)",
|
||||
"data-schema": "data:text/html,<script>",
|
||||
"relativ ohne slash": "u/alice",
|
||||
"header-injection CR": "/x\r\nSet-Cookie: a=b",
|
||||
"header-injection LF": "/x\nSet-Cookie: a=b",
|
||||
"nullbyte": "/x\x00y",
|
||||
"zu lang": "/" + strings.Repeat("a", 512),
|
||||
"backslash am anfang": `\/evil.example`,
|
||||
"leerzeichen davor": " /u/alice",
|
||||
}
|
||||
for name, v := range abgelehnt {
|
||||
if got := safeReturn(v, fallback); got != fallback {
|
||||
t.Errorf("%s: safeReturn(%q) = %q, erwartet Fallback %q", name, v, got, fallback)
|
||||
}
|
||||
}
|
||||
|
||||
// Der Fallback wird durchgereicht, nicht hartkodiert.
|
||||
if got := safeReturn("//evil.example", "/u/bob"); got != "/u/bob" {
|
||||
t.Errorf("eigener Fallback: bekam %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReturnTo prüft die beiden Quellen: bei GET die aufgerufene URL (damit
|
||||
// Filter und Seitenzahl automatisch mitreisen), bei POST das mitgeschickte
|
||||
// Feld (damit ein neu gerendertes Formular sein Ziel behält).
|
||||
func TestReturnTo(t *testing.T) {
|
||||
get := httptest.NewRequest("GET", "/u/alice?page=3&q=katzen", nil)
|
||||
if got, want := returnTo(get), "/u/alice?page=3&q=katzen"; got != want {
|
||||
t.Errorf("GET: returnTo = %q, erwartet %q", got, want)
|
||||
}
|
||||
|
||||
// Eine bösartige URL kann per GET gar nicht erst entstehen (der Pfad kommt
|
||||
// vom Router), aber die Prüfung muss trotzdem greifen.
|
||||
odd := httptest.NewRequest("GET", "http://example.com//evil.example", nil)
|
||||
if got := returnTo(odd); got != "/" {
|
||||
t.Errorf("GET mit // : returnTo = %q, erwartet /", got)
|
||||
}
|
||||
|
||||
post := httptest.NewRequest("POST", "/entry/1/vote",
|
||||
strings.NewReader("return_to=%2Fu%2Falice%3Fpage%3D3%23e42&mode=left"))
|
||||
post.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
if got, want := returnTo(post), "/u/alice?page=3#e42"; got != want {
|
||||
t.Errorf("POST: returnTo = %q, erwartet %q", got, want)
|
||||
}
|
||||
|
||||
evil := httptest.NewRequest("POST", "/entry/1/vote",
|
||||
strings.NewReader("return_to=https%3A%2F%2Fevil.example"))
|
||||
evil.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
if got := returnTo(evil); got != "/" {
|
||||
t.Errorf("POST mit fremder URL: returnTo = %q, erwartet /", got)
|
||||
}
|
||||
|
||||
// Ohne Feld: Fallback, nicht die POST-Ziel-URL.
|
||||
bare := httptest.NewRequest("POST", "/entry/1/vote", nil)
|
||||
if got := returnTo(bare); got != "/" {
|
||||
t.Errorf("POST ohne return_to: returnTo = %q, erwartet /", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedirectBack stellt sicher, dass die Umleitung nach einer Aktion ein 303
|
||||
// ist (GET auf das Ziel, Reload wiederholt die Aktion nicht) und dass ein
|
||||
// manipuliertes return_to nicht im Location-Header landet.
|
||||
func TestRedirectBack(t *testing.T) {
|
||||
post := func(body string) *httptest.ResponseRecorder {
|
||||
r := httptest.NewRequest("POST", "/entry/1/vote", strings.NewReader(body))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
w := httptest.NewRecorder()
|
||||
redirectBack(w, r, "/")
|
||||
return w
|
||||
}
|
||||
|
||||
w := post("return_to=%2Fu%2Falice%23e42")
|
||||
if w.Code != 303 {
|
||||
t.Errorf("Status = %d, erwartet 303", w.Code)
|
||||
}
|
||||
if got, want := w.Header().Get("Location"), "/u/alice#e42"; got != want {
|
||||
t.Errorf("Location = %q, erwartet %q", got, want)
|
||||
}
|
||||
|
||||
w = post("return_to=%2F%2Fevil.example")
|
||||
if got := w.Header().Get("Location"); got != "/" {
|
||||
t.Errorf("manipuliertes Ziel: Location = %q, erwartet /", got)
|
||||
}
|
||||
}
|
||||
@@ -103,9 +103,6 @@ func routes() http.Handler {
|
||||
// zusätzlich, aber das Limit kappt automatisiertes Durchprobieren früh.
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(httprate.LimitByIP(20, time.Minute))
|
||||
// handleLogin bedient JSON- und Formular-Clients (siehe wantsJSON) und
|
||||
// schreibt HTML-Antworten selbst -- api() greift nur noch für den
|
||||
// JSON-Zweig.
|
||||
r.Post("/auth/login", api(handleLogin))
|
||||
r.Post("/auth/newuser", api(handleNewUser))
|
||||
})
|
||||
@@ -113,19 +110,6 @@ func routes() http.Handler {
|
||||
// Navigationen mit -- als GET könnte eine fremde Seite Nutzer per Link
|
||||
// ausloggen (CSRF). POST ist durch Lax + checkOrigin gedeckt.
|
||||
r.Post("/auth/logout", handleLogout)
|
||||
// Serverseitig gerenderte Auth-Seiten (noscript-Weg). Kein Rate-Limit: das
|
||||
// bloße Anzeigen der Formulare ist kein Brute-Force-Vektor, die POSTs oben
|
||||
// sind gedrosselt.
|
||||
r.Get("/auth/login", page(showLogin))
|
||||
r.Get("/auth/register", page(showRegister))
|
||||
|
||||
// Serverseitig gerenderte Feeds. Eigene Pfade, solange "/" und "/u/{name}"
|
||||
// noch die JS-Varianten ausliefern -- so bleibt die Seite während des
|
||||
// Umbaus durchgehend benutzbar. Sobald das Frontend auf gerendertem Markup
|
||||
// aufsetzt, übernehmen diese Handler die ursprünglichen Pfade.
|
||||
r.With(trackImpression).Get("/feed", page(showFeed))
|
||||
r.With(trackImpression).Get("/u/{username}/beitraege", page(showUserFeed))
|
||||
r.With(trackImpression).Get("/beitrag/{pid}", page(showThread))
|
||||
r.Get("/auth/sessioninfo", api(handleSessionInfo))
|
||||
r.Get("/auth/headerbar", handleHeaderbar)
|
||||
r.Get("/stats", handleStats)
|
||||
@@ -135,7 +119,7 @@ func routes() http.Handler {
|
||||
// weil TOR-Zugriffe die Go-App direkt erreichen und NICHT über Caddy laufen --
|
||||
// ein rein proxy-seitiger Schutz hätte diese Lücke offen gelassen. Caddy
|
||||
// erzwingt zusätzlich TLS für den Clearnet-Weg.
|
||||
r.With(modBasicAuth).Get("/moderation", page(handleModeration))
|
||||
r.With(modBasicAuth).Get("/moderation", api(handleModeration))
|
||||
|
||||
// HTML-Seiten: mit Impression-Tracking (anonymisiert, siehe stats.go). Nur
|
||||
// echte Seitenaufrufe, nicht API/Assets.
|
||||
@@ -150,9 +134,6 @@ func routes() http.Handler {
|
||||
// Melden: öffentlich (jeder darf melden, Moderationshilfe), aber pro IP
|
||||
// gedrosselt, damit der auth-lose Endpunkt nicht zum Spam-Kanal wird.
|
||||
r.With(httprate.LimitByIP(10, time.Minute)).Post("/entry/{pid}/report", api(handleReport))
|
||||
// Bestätigungsseite nach einer Meldung ohne JavaScript. Reines Anzeigen,
|
||||
// deshalb ohne Rate-Limit -- gemeldet wird im POST darüber.
|
||||
r.Get("/entry/{pid}/gemeldet", page(showReported))
|
||||
|
||||
// --- User (öffentlich) ---
|
||||
r.With(trackImpression).Get("/u/{username}", serveUserPage)
|
||||
@@ -166,12 +147,6 @@ func routes() http.Handler {
|
||||
r.Post("/entry/{pid}/vote", api(handleVote))
|
||||
r.Post("/entry/{pid}/bump", api(handleBump))
|
||||
r.Post("/entry/{pid}/edit", api(handleEditEntry))
|
||||
// Bearbeitungsformular (noscript-Weg). In der requireAuth-Gruppe, weil
|
||||
// nur der Autor den bisherigen Text zu sehen bekommen soll.
|
||||
r.Get("/beitrag/{pid}/bearbeiten", page(showEditEntry))
|
||||
// Löschbestätigung: das eigentliche Löschen bleibt ein POST, damit ein
|
||||
// gefolgter Link (Linkvorschau, Crawler) nichts vernichtet.
|
||||
r.Get("/beitrag/{pid}/loeschen", page(showDeleteEntry))
|
||||
r.Post("/entry/{pid}/delete", api(handleDeleteEntry))
|
||||
r.Get("/user/info", api(handleUserInfo))
|
||||
r.Post("/user/rename", api(handleUserRename))
|
||||
|
||||
+2
-6
@@ -51,9 +51,7 @@ var blockTOR = guard(func(_ http.ResponseWriter, r *http.Request) error {
|
||||
|
||||
var errTORBlocked = Forbidden("tor.blocked", "Beiträge über TOR sind derzeit nicht möglich.")
|
||||
|
||||
// isTOR erkennt Anfragen aus dem TOR-Netz. Ausgelagert, damit die
|
||||
// serverseitig gerenderten Seiten das Beitragsformular erst gar nicht anbieten,
|
||||
// statt es anzuzeigen und beim Absenden abzulehnen.
|
||||
// isTOR erkennt Anfragen aus dem TOR-Netz.
|
||||
func isTOR(r *http.Request) bool {
|
||||
if torNet == nil {
|
||||
return false
|
||||
@@ -71,9 +69,7 @@ func requireAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
s, ok := getSession(r)
|
||||
if !ok {
|
||||
// Über failRequest: ohne JavaScript ist die Session-Abfrage sonst
|
||||
// eine JSON-Zeile mitten im Browser.
|
||||
failRequest(w, r, errAuthRequired)
|
||||
writeAPIError(w, errAuthRequired)
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), uidKey, s.UID)
|
||||
|
||||
+3
-2
@@ -14,7 +14,8 @@ import (
|
||||
// laufen -- der proxy-seitige Schutz greift dort also nicht. Credentials kommen
|
||||
// aus KVER_MOD_USER/KVER_MOD_PASS und werden einmalig beim Routing-Aufbau
|
||||
// gelesen. Fail-closed: sind sie nicht gesetzt, bleibt die Seite gesperrt (503),
|
||||
// damit sie nie versehentlich offen ist. Vergleich über SHA-256 + ConstantTime,
|
||||
// damit sie nie versehentlich offen ist. Fehler gehen wie überall als JSON
|
||||
// hinaus; der Browser zeigt den Login-Dialog trotzdem (WWW-Authenticate). Vergleich über SHA-256 + ConstantTime,
|
||||
// damit weder Inhalt noch Länge der Credentials über Timing durchsickern.
|
||||
func modBasicAuth(next http.Handler) http.Handler {
|
||||
wantUser := envOr("KVER_MOD_USER", "")
|
||||
@@ -26,7 +27,7 @@ func modBasicAuth(next http.Handler) http.Handler {
|
||||
if wantUser == "" || wantPass == "" {
|
||||
log.Printf("moderation gesperrt: KVER_MOD_USER/KVER_MOD_PASS nicht gesetzt")
|
||||
}
|
||||
return pageMiddleware(func(w http.ResponseWriter, r *http.Request) error {
|
||||
return guard(func(w http.ResponseWriter, r *http.Request) error {
|
||||
if wantUser == "" || wantPass == "" {
|
||||
return Unavailable("mod.not_configured", "Die Moderationsseite ist nicht konfiguriert.")
|
||||
}
|
||||
|
||||
+1
-1
@@ -10,6 +10,6 @@
|
||||
- Direct Messages
|
||||
- Multi-Account-Management
|
||||
- RSS / Atom Feed
|
||||
- Feed ohne JS
|
||||
- Feed ohne JS (früherer SSR-Stand: Tag `ssr-archiv`)
|
||||
- robots.txt / Sitemap
|
||||
- Links/Rechts Auswertung
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
{{define "base"}}<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.Title}} · Kontrollverlust</title>
|
||||
<link rel="stylesheet" href="/css/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<nav id="legal">
|
||||
<a href="/static/docs/impressum.html">Impressum</a> -
|
||||
<a href="/static/docs/datenschutz.html">Datenschutz</a> -
|
||||
<a href="/static/docs/beitragsordnung.html">Beitragsordnung</a> -
|
||||
<a href="/stats.html">Statistik</a>
|
||||
</nav>
|
||||
<a href="/feed"><h1>Kontrollverlust</h1></a>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
{{template "content" .}}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -1,30 +0,0 @@
|
||||
{{define "content"}}
|
||||
{{with .Form}}
|
||||
<section id="confirm-delete">
|
||||
<h2>Beitrag löschen?</h2>
|
||||
|
||||
<article class="card">
|
||||
<div class="threadline-wrapper">
|
||||
<div class="threadline"></div>
|
||||
<div class="body">
|
||||
<div class="content">{{content .Content}}</div>
|
||||
{{if .HasImage}}<p class="muted">Das angehängte Bild wird mit gelöscht.</p>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<p>Der Text und das Bild werden entfernt. Der Beitrag bleibt als
|
||||
<span class="muted">[deleted]</span>-Platzhalter stehen, damit
|
||||
{{if .IsRoot}}der Thread{{else}}die Antworten darunter{{end}} nicht
|
||||
verwaisen.
|
||||
{{if .Replies}}Die {{.Replies}} Antwort(en) darauf bleiben erhalten.{{end}}</p>
|
||||
<p><strong>Das lässt sich nicht rückgängig machen.</strong></p>
|
||||
|
||||
<form method="post" action="/entry/{{.PID}}/delete">
|
||||
<input type="hidden" name="return_to" value="{{.BackURL}}">
|
||||
<button type="submit">Endgültig löschen</button>
|
||||
</form>
|
||||
<p class="muted"><a href="{{.BackURL}}">Abbrechen</a></p>
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -1,19 +0,0 @@
|
||||
{{define "content"}}
|
||||
{{with .Form}}
|
||||
<section id="compose">
|
||||
{{/* Kein enctype/Dateifeld: das angehängte Bild bleibt beim Bearbeiten
|
||||
unverändert (siehe handleEditEntry). Ein Dateifeld hier würde
|
||||
versprechen, was der Endpunkt nicht tut. */}}
|
||||
<form method="post" action="/entry/{{.PID}}/edit">
|
||||
<input type="hidden" name="return_to" value="{{.BackURL}}">
|
||||
<h2>Beitrag bearbeiten</h2>
|
||||
{{with $.Err}}<p class="msg">{{.Msg}}</p>{{end}}
|
||||
<label for="content">Text</label>
|
||||
<textarea id="content" name="content" rows="6" maxlength="1000"
|
||||
required>{{.Content}}</textarea>
|
||||
<button type="submit">Speichern</button>
|
||||
</form>
|
||||
<p class="muted"><a href="{{.BackURL}}">Abbrechen</a></p>
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -1,83 +0,0 @@
|
||||
{{/*
|
||||
Ein Beitrag und die Blätternavigation -- als eigene Blöcke, damit Hauptfeed
|
||||
und Nutzerfeed dieselbe Darstellung benutzen. Das Gegenstück zu renderEntry
|
||||
in web/js/feed.js.
|
||||
|
||||
Jede Aktion ist ein echtes Formular mit return_to: ohne JavaScript ist eine
|
||||
Interaktion immer ein Seitenwechsel, und der Anker #e{{.PID}} bringt den
|
||||
Nutzer danach an genau den Beitrag zurück, den er bedient hat.
|
||||
*/}}
|
||||
|
||||
{{define "entry"}}
|
||||
<article class="card" id="e{{.PID}}">
|
||||
{{if .Deleted}}
|
||||
<div class="muted byline">
|
||||
<span>[deleted] · <time datetime="{{timeAttr .CreatedAt}}">{{time .CreatedAt}}</time></span>
|
||||
</div>
|
||||
<div class="threadline-wrapper">
|
||||
<div class="threadline"></div>
|
||||
<div class="body"><div class="content">[deleted]</div></div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="muted byline">
|
||||
<a href="{{profile .Username}}"><img class="avatar avatar-sm" src="{{avatar .Avatar}}" alt="" loading="lazy"></a>
|
||||
<span><a href="{{profile .Username}}">{{.Username}}</a> ·
|
||||
<time datetime="{{timeAttr .CreatedAt}}">{{time .CreatedAt}}</time></span>
|
||||
<details class="report">
|
||||
<summary>Melden</summary>
|
||||
<form class="report-form" method="post" action="/entry/{{.PID}}/report">
|
||||
<input type="hidden" name="return_to" value="{{.ReturnTo}}#e{{.PID}}">
|
||||
<label for="reason-{{.PID}}">Grund <span class="muted">(optional)</span></label>
|
||||
<input id="reason-{{.PID}}" name="reason" type="text" maxlength="500" autocomplete="off">
|
||||
<button type="submit">Absenden</button>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
<div class="threadline-wrapper">
|
||||
<div class="threadline"></div>
|
||||
<div class="body">
|
||||
<div class="content">{{content .Content}}</div>
|
||||
{{if .Filepath}}<img src="/{{.Filepath}}" alt="" loading="lazy">{{end}}
|
||||
<div class="interactions">
|
||||
<a class="button reply" href="/beitrag/{{.PID}}">Antworten ({{.ReplyCount}})</a>
|
||||
{{if .EditURL}}<a class="button" href="{{.EditURL}}">Bearbeiten</a>{{end}}
|
||||
{{if .DeleteURL}}<a class="button" href="{{.DeleteURL}}">Löschen</a>{{end}}
|
||||
{{if .LoggedIn}}
|
||||
<form class="row" method="post" action="/entry/{{.PID}}/vote">
|
||||
<input type="hidden" name="return_to" value="{{.ReturnTo}}#e{{.PID}}">
|
||||
<button class="ghost{{if eq .Selected "left"}} selected{{end}}"
|
||||
name="mode" value="left">Links</button>
|
||||
<span class="muted">{{.Left}} / {{.Right}}</span>
|
||||
<button class="ghost{{if eq .Selected "right"}} selected{{end}}"
|
||||
name="mode" value="right">Rechts</button>
|
||||
</form>
|
||||
{{/* Repost hebt den Beitrag im Feed wieder nach oben. Der Cooldown
|
||||
sitzt am Beitrag (global) und wächst pro Repost; ist er aktiv,
|
||||
ist der Knopf gesperrt und die Restzeit steht daneben. */}}
|
||||
<form method="post" action="/entry/{{.PID}}/bump">
|
||||
<input type="hidden" name="return_to" value="{{.ReturnTo}}#e{{.PID}}">
|
||||
<button class="bump" type="submit"{{if .BumpLocked}} disabled{{end}}>Repost ({{.BumpCount}})</button>
|
||||
{{if .BumpLocked}}<span class="muted"> · in {{.BumpWaitHours}}h</span>{{end}}
|
||||
</form>
|
||||
{{else}}
|
||||
{{/* Ohne Anmeldung nur der Stand -- ein Formular hier würde
|
||||
ausschließlich 401 liefern. */}}
|
||||
<div class="row">
|
||||
<span class="muted">{{.Left}} / {{.Right}} ·
|
||||
<a href="/auth/login">anmelden zum Abstimmen</a></span>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</article>
|
||||
{{end}}
|
||||
|
||||
{{define "pager"}}
|
||||
<nav class="pager">
|
||||
{{if .PrevURL}}<a class="button" href="{{.PrevURL}}">← Neuere</a>{{end}}
|
||||
<span class="muted">Seite {{.HumanPage}} von {{.Pages}}</span>
|
||||
{{if .NextURL}}<a class="button" href="{{.NextURL}}">Ältere →</a>{{end}}
|
||||
</nav>
|
||||
{{end}}
|
||||
@@ -1,37 +0,0 @@
|
||||
{{define "content"}}
|
||||
{{with .Form}}
|
||||
{{if .CanPost}}
|
||||
<section id="compose">
|
||||
{{/* enctype ist Pflicht, sonst schickt der Browser nur den Dateinamen
|
||||
statt der Datei -- ohne JavaScript gibt es keinen zweiten Versuch. */}}
|
||||
<form method="post" action="/entry/create" enctype="multipart/form-data">
|
||||
<input type="hidden" name="page" value="{{.Page}}">
|
||||
{{/* return_to überlebt einen abgelehnten Post: sonst rendert failCompose
|
||||
den Feed mit Interaktions-Knöpfen, die auf "/" statt "/feed" zeigen. */}}
|
||||
<input type="hidden" name="return_to" value="{{.ReturnTo}}">
|
||||
{{with $.Err}}<p class="msg">{{.Msg}}</p>{{end}}
|
||||
<label for="content">Neuer Beitrag</label>
|
||||
<textarea id="content" name="content" rows="4" maxlength="1000"
|
||||
placeholder="Was gibt's Neues?">{{.Compose.Content}}</textarea>
|
||||
<label for="file">Bild <span class="muted">(optional)</span></label>
|
||||
<input id="file" name="file" type="file" accept="image/*">
|
||||
{{if .Compose.HadFile}}
|
||||
<p class="msg">Dein Bild musst du erneut auswählen – Browser können
|
||||
Dateifelder aus Sicherheitsgründen nicht vorbelegen.</p>
|
||||
{{end}}
|
||||
<button type="submit">Eintrag erstellen</button>
|
||||
</form>
|
||||
</section>
|
||||
{{else if .IsTOR}}
|
||||
<p class="muted">Über TOR ist das Schreiben von Beiträgen derzeit nicht
|
||||
möglich. Lesen, Abstimmen und Melden funktionieren.</p>
|
||||
{{end}}
|
||||
|
||||
<section id="feed">
|
||||
{{range .Entries}}{{template "entry" .}}{{else}}
|
||||
<p class="muted">Noch keine Beiträge.</p>
|
||||
{{end}}
|
||||
</section>
|
||||
{{template "pager" .}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -1,16 +0,0 @@
|
||||
{{define "content"}}
|
||||
<section id="auth-box">
|
||||
<form method="post" action="/auth/login">
|
||||
<h2>Login</h2>
|
||||
{{with .Err}}<p class="msg">{{.Msg}}</p>{{end}}
|
||||
<label for="user">Nutzername</label>
|
||||
<input id="user" name="user" autocomplete="username" required
|
||||
{{if .Form}}value="{{.Form.User}}"{{end}}>
|
||||
<label for="pass">Passwort</label>
|
||||
<input id="pass" name="pass" type="password" autocomplete="current-password" required>
|
||||
<input name="timeout" type="hidden" value="86400">
|
||||
<button type="submit">Einloggen</button>
|
||||
</form>
|
||||
<p class="muted">Noch kein Konto? <a href="/auth/register">Registrieren</a></p>
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -1,23 +0,0 @@
|
||||
{{define "content"}}
|
||||
<section id="auth-box">
|
||||
<form method="post" action="/auth/newuser">
|
||||
<h2>Registrieren</h2>
|
||||
|
||||
<label for="user">Nutzername</label>
|
||||
<input id="user" name="user" autocomplete="username" required minlength="3" maxlength="32"
|
||||
{{if .Form}}value="{{.Form.User}}"{{end}}>
|
||||
{{with fieldErr .Err "user"}}<p class="msg">{{.}}</p>{{end}}
|
||||
|
||||
<label for="pass1">Passwort <span class="muted">(mindestens 10 Zeichen)</span></label>
|
||||
<input id="pass1" name="pass1" type="password" autocomplete="new-password" required minlength="10">
|
||||
{{with fieldErr .Err "pass1"}}<p class="msg">{{.}}</p>{{end}}
|
||||
|
||||
<label for="pass2">Passwort wiederholen</label>
|
||||
<input id="pass2" name="pass2" type="password" autocomplete="new-password" required minlength="10">
|
||||
{{with fieldErr .Err "pass2"}}<p class="msg">{{.}}</p>{{end}}
|
||||
|
||||
<button type="submit">Konto erstellen</button>
|
||||
</form>
|
||||
<p class="muted">Schon registriert? <a href="/auth/login">Zum Login</a></p>
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -1,15 +0,0 @@
|
||||
{{define "content"}}
|
||||
{{with .Form}}
|
||||
<section id="report-done">
|
||||
<h2>Meldung eingegangen</h2>
|
||||
<p>Danke – deine Meldung zu Beitrag
|
||||
<a href="/beitrag/{{.PID}}">#{{.PID}}</a> ist eingegangen und wird geprüft.</p>
|
||||
{{if .Reason}}
|
||||
<p class="muted">Dein Grund: {{.Reason}}</p>
|
||||
{{else}}
|
||||
<p class="muted">Du hast keinen Grund angegeben.</p>
|
||||
{{end}}
|
||||
<p><a class="button" href="{{.BackURL}}">Zurück zum Beitrag</a></p>
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -1,44 +0,0 @@
|
||||
{{define "content"}}
|
||||
{{with .Form}}
|
||||
{{if .Ancestors}}
|
||||
<section id="ancestors" class="muted">
|
||||
{{range .Ancestors}}{{template "entry" .}}{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
<section id="focus">
|
||||
{{template "entry" .Entry}}
|
||||
</section>
|
||||
|
||||
{{if .CanPost}}
|
||||
<section id="compose">
|
||||
<form method="post" action="/entry/create" enctype="multipart/form-data">
|
||||
<input type="hidden" name="reply_to" value="{{.Entry.PID}}">
|
||||
{{/* return_to überlebt eine abgelehnte Antwort: sonst zeigen die neu
|
||||
gerenderten Interaktions-Knöpfe auf "/" statt auf den Thread. */}}
|
||||
<input type="hidden" name="return_to" value="{{.ReturnTo}}">
|
||||
{{with $.Err}}<p class="msg">{{.Msg}}</p>{{end}}
|
||||
<label for="content">Antworten</label>
|
||||
<textarea id="content" name="content" rows="4" maxlength="1000"
|
||||
placeholder="Deine Antwort">{{.Compose.Content}}</textarea>
|
||||
<label for="file">Bild <span class="muted">(optional)</span></label>
|
||||
<input id="file" name="file" type="file" accept="image/*">
|
||||
{{if .Compose.HadFile}}
|
||||
<p class="msg">Dein Bild musst du erneut auswählen – Browser können
|
||||
Dateifelder aus Sicherheitsgründen nicht vorbelegen.</p>
|
||||
{{end}}
|
||||
<button type="submit">Antwort abschicken</button>
|
||||
</form>
|
||||
</section>
|
||||
{{else if .IsTOR}}
|
||||
<p class="muted">Über TOR ist das Schreiben von Beiträgen derzeit nicht
|
||||
möglich. Lesen, Abstimmen und Melden funktionieren.</p>
|
||||
{{end}}
|
||||
|
||||
<section id="replies">
|
||||
{{range .Replies}}{{template "entry" .}}{{else}}
|
||||
<p class="muted">Noch keine Antworten.</p>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -1,11 +0,0 @@
|
||||
{{define "content"}}
|
||||
{{with .Form}}
|
||||
<h2>Beiträge von {{.Username}}</h2>
|
||||
<section id="feed">
|
||||
{{range .Entries}}{{template "entry" .}}{{else}}
|
||||
<p class="muted">Dieser Nutzer hat noch nichts geschrieben.</p>
|
||||
{{end}}
|
||||
</section>
|
||||
{{template "pager" .}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -22,9 +22,7 @@ var errUsernameTaken = Conflict("user.name_taken", "Der Nutzername wird bereits
|
||||
// nur die Existenz von Accounts verraten.
|
||||
var errUserNotFound = NotFound("user.not_found", "Nutzer nicht gefunden")
|
||||
|
||||
// createUser legt den Account an und liefert dessen uid zurück -- der Aufrufer
|
||||
// braucht sie, um direkt eine Session zu eröffnen (Registrierung ohne
|
||||
// JavaScript loggt in einem Schritt ein).
|
||||
// createUser legt den Account an und liefert dessen uid zurück.
|
||||
func createUser(username, password string) (int64, error) {
|
||||
now := time.Now().Unix()
|
||||
|
||||
|
||||
+1
-18
@@ -305,8 +305,7 @@ img.avatar-lg { width: 72px; height: 72px; }
|
||||
/* Melden sitzt rechtsbündig in der Meta-Zeile (weit weg von den Interaktionen).
|
||||
margin-left:auto schiebt es im Byline-Flex ganz nach rechts; position:relative
|
||||
verankert das aufklappende Grund-Feld. Der <details>-Zweischritt (erst öffnen,
|
||||
dann absenden) verhindert versehentliche Meldungen; nativ (details/summary +
|
||||
echtes Formular), damit es später auch ohne JS funktioniert. */
|
||||
dann absenden) verhindert versehentliche Meldungen. */
|
||||
details.report {
|
||||
margin-left: auto;
|
||||
position: relative;
|
||||
@@ -440,19 +439,3 @@ hr {
|
||||
flex-basis: 13rem;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* Blätternavigation des serverseitig gerenderten Feeds. Ohne JavaScript gibt
|
||||
es kein Nachladen beim Scrollen -- stattdessen Zurück/Weiter am Seitenende.
|
||||
space-between hält die Seitenanzeige mittig, auch wenn auf der ersten bzw.
|
||||
letzten Seite einer der beiden Links fehlt. */
|
||||
.pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
.pager > span {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
+1
-4
@@ -21,10 +21,7 @@
|
||||
|
||||
<main>
|
||||
<noscript>
|
||||
<p class="msg">Diese Startseite braucht derzeit noch JavaScript. Ohne JS
|
||||
nutzbar ist bereits alles Wesentliche – Lesen, Schreiben, Antworten,
|
||||
Abstimmen und Melden: <a href="/feed">zum Feed</a> ·
|
||||
<a href="/auth/login">Login</a> · <a href="/auth/register">Registrieren</a>.</p>
|
||||
<p class="msg">Diese Seite braucht JavaScript.</p>
|
||||
</noscript>
|
||||
|
||||
<!-- Login / Registrierung -->
|
||||
|
||||
+3
-6
@@ -1,8 +1,6 @@
|
||||
"use strict";
|
||||
|
||||
// Accept: application/json markiert diese Aufrufe als API-Zugriffe. Endpunkte,
|
||||
// die auch ein Browser-Formular bedienen (siehe wantsJSON im Backend),
|
||||
// antworten daraufhin mit JSON statt mit einem Redirect auf eine HTML-Seite.
|
||||
// Die API antwortet ausschließlich mit JSON; der Accept-Header sagt das explizit.
|
||||
const JSON_HEADERS = { Accept: "application/json" };
|
||||
|
||||
const api = {
|
||||
@@ -230,9 +228,8 @@ function setupBump(el, e) {
|
||||
|
||||
// setupReport verdrahtet den Melden-Aufklapper. Der <details>-Zweischritt
|
||||
// (öffnen -> absenden) verhindert versehentliche Meldungen und hält das Grund-
|
||||
// Feld eingeklappt. Das Formular ist ein echtes method=post-Form, damit es
|
||||
// später auch ohne JS funktioniert; hier fangen wir den Submit nur ab, um
|
||||
// Inline-Feedback zu geben statt die Seite neu zu laden. stopPropagation, weil
|
||||
// Feld eingeklappt. Der Submit wird abgefangen, um Inline-Feedback zu geben
|
||||
// statt die Seite neu zu laden. stopPropagation, weil
|
||||
// die Karte sonst bei jedem Klick nach /e/{pid} navigieren würde.
|
||||
function setupReport(el, pid) {
|
||||
const details = el.querySelector("details.report");
|
||||
|
||||
Reference in New Issue
Block a user