Umbau-Projekt (SSR und Rest)
This commit is contained in:
@@ -2,15 +2,18 @@ package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/gif"
|
||||
"image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"log"
|
||||
"math/rand/v2"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -71,19 +74,17 @@ func entryByPID(pid int64) (Entry, error) {
|
||||
return e, err
|
||||
}
|
||||
|
||||
// feedPage liefert eine Feed-Seite (20 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.
|
||||
func feedPage(page int, uid int64, rootsOnly bool) ([]Entry, error) {
|
||||
if page < 0 {
|
||||
page = 0
|
||||
}
|
||||
if page > 100 {
|
||||
page = 100
|
||||
}
|
||||
const count = 20
|
||||
const (
|
||||
feedPageSize = 20
|
||||
// maxFeedPage deckelt den Offset: sehr große Seitenzahlen zwingen SQLite
|
||||
// sonst dazu, entsprechend viele Zeilen zu überspringen.
|
||||
maxFeedPage = 100
|
||||
)
|
||||
|
||||
query := entrySelect
|
||||
// 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.
|
||||
func feedWhere(uid int64, rootsOnly bool) (string, []any) {
|
||||
args := []any{}
|
||||
where := []string{}
|
||||
if uid != 0 {
|
||||
@@ -93,16 +94,43 @@ func feedPage(page int, uid int64, rootsOnly bool) ([]Entry, error) {
|
||||
if rootsOnly {
|
||||
where = append(where, "e.reply_to = 0")
|
||||
}
|
||||
if len(where) > 0 {
|
||||
query += " WHERE " + strings.Join(where, " AND ")
|
||||
if len(where) == 0 {
|
||||
return "", args
|
||||
}
|
||||
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.
|
||||
func feedPage(page int, uid int64, rootsOnly bool) ([]Entry, error) {
|
||||
if page < 0 {
|
||||
page = 0
|
||||
}
|
||||
if page > maxFeedPage {
|
||||
page = maxFeedPage
|
||||
}
|
||||
|
||||
where, args := feedWhere(uid, rootsOnly)
|
||||
query := entrySelect + where
|
||||
if rootsOnly {
|
||||
query += ` ORDER BY e.last_activity DESC`
|
||||
} else {
|
||||
query += ` ORDER BY e.created_at DESC`
|
||||
}
|
||||
query += ` LIMIT ? OFFSET ?`
|
||||
args = append(args, count, page*count)
|
||||
args = append(args, feedPageSize, page*feedPageSize)
|
||||
|
||||
rows, err := db.Query(query, args...)
|
||||
if err != nil {
|
||||
@@ -111,31 +139,30 @@ func feedPage(page int, uid int64, rootsOnly bool) ([]Entry, error) {
|
||||
return scanEntries(rows)
|
||||
}
|
||||
|
||||
func handleFeed(w http.ResponseWriter, r *http.Request) {
|
||||
func handleFeed(w http.ResponseWriter, r *http.Request) error {
|
||||
page, _ := strconv.Atoi(chi.URLParam(r, "page"))
|
||||
entries, err := feedPage(page, 0, true)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Datenbankfehler")
|
||||
return
|
||||
return Internal(err)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, entries)
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleUserFeed liefert den Feed eines einzelnen Nutzers (öffentlich, inkl. Antworten).
|
||||
func handleUserFeed(w http.ResponseWriter, r *http.Request) {
|
||||
func handleUserFeed(w http.ResponseWriter, r *http.Request) error {
|
||||
var uid int64
|
||||
if err := db.QueryRow(`SELECT uid FROM user WHERE username = ?`, chi.URLParam(r, "username")).Scan(&uid); err != nil {
|
||||
writeError(w, http.StatusNotFound, "Nutzer nicht gefunden")
|
||||
return
|
||||
return errUserNotFound
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(chi.URLParam(r, "page"))
|
||||
entries, err := feedPage(page, uid, false)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Datenbankfehler")
|
||||
return
|
||||
return Internal(err)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, entries)
|
||||
return nil
|
||||
}
|
||||
|
||||
// serveEntryPage liefert die statische Focus-Seite; das Frontend lädt die Daten
|
||||
@@ -144,127 +171,205 @@ func serveEntryPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "web/entry.html")
|
||||
}
|
||||
|
||||
// handleThread liefert einen Beitrag mit seiner Ahnenkette (Root zuerst) und den
|
||||
// direkten Antworten (öffentlich).
|
||||
func handleThread(w http.ResponseWriter, r *http.Request) {
|
||||
var (
|
||||
errEntryNotFound = NotFound("entry.not_found", "Beitrag nicht gefunden")
|
||||
errEntryNotOwned = Forbidden("entry.not_owner", "Das ist nicht dein Beitrag.")
|
||||
)
|
||||
|
||||
// pidParam liest den {pid}-Routenparameter. Eigene Funktion, weil praktisch
|
||||
// jeder Entry-Handler damit anfängt und die Fehlermeldung überall dieselbe ist.
|
||||
func pidParam(r *http.Request) (int64, error) {
|
||||
pid, err := strconv.ParseInt(chi.URLParam(r, "pid"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige pid")
|
||||
return
|
||||
return 0, Invalid("entry.bad_pid", "Ungültige pid").WithCause(err)
|
||||
}
|
||||
return pid, nil
|
||||
}
|
||||
|
||||
// handleThread liefert einen Beitrag mit seiner Ahnenkette (Root zuerst) und den
|
||||
// direkten Antworten (öffentlich).
|
||||
func handleThread(w http.ResponseWriter, r *http.Request) error {
|
||||
pid, err := pidParam(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
t, err := threadData(pid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"entry": t.Entry,
|
||||
"ancestors": t.Ancestors,
|
||||
"replies": t.Replies,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// thread ist ein Beitrag mit seinem Kontext: Ahnenkette (Root zuerst) und
|
||||
// direkte Antworten.
|
||||
type thread struct {
|
||||
Entry Entry
|
||||
Ancestors []Entry
|
||||
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.
|
||||
func threadData(pid int64) (thread, error) {
|
||||
entry, err := entryByPID(pid)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "Beitrag nicht gefunden")
|
||||
return
|
||||
return thread{}, errEntryNotFound
|
||||
}
|
||||
t := thread{Entry: entry, Ancestors: []Entry{}, Replies: []Entry{}}
|
||||
|
||||
// Ahnenkette von Eltern bis Root hochlaufen, dann umdrehen (Root zuerst).
|
||||
ancestors := []Entry{}
|
||||
for cur := entry.ReplyTo; cur != 0 && len(ancestors) < 50; {
|
||||
a, err := entryByPID(cur)
|
||||
// Ahnenkette (Root zuerst) per rekursiver CTE in einem Query statt bis zu
|
||||
// 50 Einzelqueries. depth zählt vom Beitrag aufwärts; ORDER BY depth DESC
|
||||
// dreht die Kette auf Root-zuerst. Das Limit deckelt die Kettenlänge und
|
||||
// bricht auch einen (theoretischen) Zyklus ab.
|
||||
if entry.ReplyTo != 0 {
|
||||
rows, err := db.Query(
|
||||
`WITH RECURSIVE chain(pid, depth) AS (
|
||||
SELECT ?, 1
|
||||
UNION ALL
|
||||
SELECT e.reply_to, c.depth + 1
|
||||
FROM entry e JOIN chain c ON e.pid = c.pid
|
||||
WHERE e.reply_to != 0 AND c.depth < 50
|
||||
)`+entrySelect+`
|
||||
JOIN chain c ON c.pid = e.pid
|
||||
ORDER BY c.depth DESC`, entry.ReplyTo)
|
||||
if err != nil {
|
||||
break
|
||||
return thread{}, Internal(err)
|
||||
}
|
||||
if t.Ancestors, err = scanEntries(rows); err != nil {
|
||||
return thread{}, Internal(err)
|
||||
}
|
||||
ancestors = append(ancestors, a)
|
||||
cur = a.ReplyTo
|
||||
}
|
||||
for i, j := 0, len(ancestors)-1; i < j; i, j = i+1, j-1 {
|
||||
ancestors[i], ancestors[j] = ancestors[j], ancestors[i]
|
||||
}
|
||||
|
||||
rows, err := db.Query(entrySelect+` WHERE e.reply_to = ? ORDER BY e.last_activity DESC LIMIT 100`, pid)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Datenbankfehler")
|
||||
return
|
||||
return thread{}, Internal(err)
|
||||
}
|
||||
replies, err := scanEntries(rows)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Datenbankfehler")
|
||||
return
|
||||
if t.Replies, err = scanEntries(rows); err != nil {
|
||||
return thread{}, Internal(err)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"entry": entry,
|
||||
"ancestors": ancestors,
|
||||
"replies": replies,
|
||||
})
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func handleCreateEntry(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
// Temp-Dateien auf die Platte.
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes)
|
||||
if err := r.ParseMultipartForm(16 << 20); err != nil && err != http.ErrNotMultipart {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
|
||||
return
|
||||
}
|
||||
|
||||
content := strings.TrimSpace(r.FormValue("content"))
|
||||
if len(content) > 1000 {
|
||||
content = content[:1000]
|
||||
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)
|
||||
}
|
||||
|
||||
content := truncateRunes(strings.TrimSpace(r.FormValue("content")), 1000)
|
||||
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 {
|
||||
writeError(w, http.StatusBadRequest, "Elternbeitrag nicht gefunden")
|
||||
return
|
||||
return fail(Invalid("entry.parent_missing", "Elternbeitrag nicht gefunden").At("reply_to"))
|
||||
}
|
||||
}
|
||||
|
||||
filepath := ""
|
||||
if r.MultipartForm != nil {
|
||||
if files := r.MultipartForm.File["file"]; len(files) > 0 {
|
||||
if stored, err := storeImage(files[0]); err == nil {
|
||||
filepath = stored
|
||||
}
|
||||
if hadFile {
|
||||
stored, err := storeImage(r.MultipartForm.File["file"][0])
|
||||
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))
|
||||
}
|
||||
filepath = stored
|
||||
}
|
||||
|
||||
if filepath == "" && content == "" {
|
||||
writeError(w, http.StatusBadRequest, "Leerer Beitrag!")
|
||||
return
|
||||
return fail(Invalid("entry.empty", "Leerer Beitrag!").At("content"))
|
||||
}
|
||||
|
||||
pid := int64(rand.IntN(999999999999))
|
||||
now := time.Now().Unix()
|
||||
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Beitrag konnte nicht gespeichert werden")
|
||||
return
|
||||
return Internal(err)
|
||||
}
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO entry (pid, uid, created_at, content, filepath, reply_to, reply_count, last_activity)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 0, ?)`,
|
||||
pid, uid, now, content, filepath, replyTo, now,
|
||||
); err != nil {
|
||||
tx.Rollback()
|
||||
writeError(w, http.StatusInternalServerError, "Beitrag konnte nicht gespeichert werden")
|
||||
return
|
||||
// pid würfeln; +1, weil 0 der Sentinel für "kein Elternbeitrag"/"gelöschter
|
||||
// Autor" ist. Bei der seltenen UNIQUE-Kollision neu würfeln statt 500.
|
||||
var pid int64
|
||||
for try := 0; ; try++ {
|
||||
pid = int64(rand.IntN(999999999999)) + 1
|
||||
_, err := tx.Exec(
|
||||
`INSERT INTO entry (pid, uid, created_at, content, filepath, reply_to, reply_count, last_activity)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 0, ?)`,
|
||||
pid, uid, now, content, filepath, replyTo, now,
|
||||
)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
if try >= 4 || !strings.Contains(err.Error(), "UNIQUE") {
|
||||
tx.Rollback()
|
||||
return Internal(err)
|
||||
}
|
||||
}
|
||||
// Antwort: reply_count und last_activity aller Vorfahren bis zum Root anheben.
|
||||
for cur, i := replyTo, 0; cur != 0 && i < 50; i++ {
|
||||
// Antwort: reply_count und last_activity aller Vorfahren bis zum Root
|
||||
// anheben -- per rekursiver CTE in einem Statement statt bis zu 50
|
||||
// Einzelqueries (hält die Transaktion kurz). depth deckelt die Kette und
|
||||
// bricht auch einen (theoretischen) Zyklus ab.
|
||||
if replyTo != 0 {
|
||||
if _, err := tx.Exec(
|
||||
`UPDATE entry SET reply_count = reply_count + 1,
|
||||
last_activity = MAX(COALESCE(last_activity, 0), ?) WHERE pid = ?`,
|
||||
now, cur,
|
||||
`WITH RECURSIVE chain(pid, depth) AS (
|
||||
SELECT ?, 0
|
||||
UNION ALL
|
||||
SELECT e.reply_to, c.depth + 1
|
||||
FROM entry e JOIN chain c ON e.pid = c.pid
|
||||
WHERE e.reply_to != 0 AND c.depth < 50
|
||||
)
|
||||
UPDATE entry SET reply_count = reply_count + 1,
|
||||
last_activity = MAX(last_activity, ?)
|
||||
WHERE pid IN (SELECT pid FROM chain)`,
|
||||
replyTo, now,
|
||||
); err != nil {
|
||||
tx.Rollback()
|
||||
writeError(w, http.StatusInternalServerError, "Beitrag konnte nicht gespeichert werden")
|
||||
return
|
||||
}
|
||||
if err := tx.QueryRow(`SELECT reply_to FROM entry WHERE pid = ?`, cur).Scan(&cur); err != nil {
|
||||
break
|
||||
return Internal(err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Beitrag konnte nicht gespeichert werden")
|
||||
return
|
||||
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{
|
||||
@@ -273,77 +378,80 @@ func handleCreateEntry(w http.ResponseWriter, r *http.Request) {
|
||||
"filepath": filepath,
|
||||
"reply_to": replyTo,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleEditEntry ändert den Inhalt eines eigenen Beitrags. Das angehängte Bild
|
||||
// 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) {
|
||||
pid, err := strconv.ParseInt(chi.URLParam(r, "pid"), 10, 64)
|
||||
func handleEditEntry(w http.ResponseWriter, r *http.Request) error {
|
||||
json := wantsJSON(r)
|
||||
|
||||
pid, err := pidParam(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige pid")
|
||||
return
|
||||
return err
|
||||
}
|
||||
uid := uidFromContext(r.Context())
|
||||
|
||||
var owner int64
|
||||
if err := db.QueryRow(`SELECT uid FROM entry WHERE pid = ?`, pid).Scan(&owner); err != nil {
|
||||
writeError(w, http.StatusNotFound, "Beitrag nicht gefunden")
|
||||
return
|
||||
}
|
||||
if owner != uid {
|
||||
writeError(w, http.StatusForbidden, "Das ist nicht dein Beitrag.")
|
||||
return
|
||||
// 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 := strings.TrimSpace(r.FormValue("content"))
|
||||
content := truncateRunes(strings.TrimSpace(r.FormValue("content")), 1000)
|
||||
if content == "" {
|
||||
writeError(w, http.StatusBadRequest, "Inhalt darf nicht leer sein.")
|
||||
return
|
||||
}
|
||||
if len(content) > 1000 {
|
||||
content = content[:1000]
|
||||
e := Invalid("entry.empty", "Inhalt darf nicht leer sein.").At("content")
|
||||
if json {
|
||||
return e
|
||||
}
|
||||
return failEdit(w, r, pid, content, e)
|
||||
}
|
||||
|
||||
if _, err := db.Exec(`UPDATE entry SET content = ? WHERE pid = ?`, content, pid); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Bearbeiten fehlgeschlagen")
|
||||
return
|
||||
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
|
||||
}
|
||||
|
||||
// handleDeleteEntry löscht einen eigenen Beitrag "weich": die Zeile bleibt als
|
||||
// [deleted]-Platzhalter erhalten (damit Antworten nicht verwaisen), Inhalt, Bild
|
||||
// und Autor werden entfernt.
|
||||
func handleDeleteEntry(w http.ResponseWriter, r *http.Request) {
|
||||
pid, err := strconv.ParseInt(chi.URLParam(r, "pid"), 10, 64)
|
||||
func handleDeleteEntry(w http.ResponseWriter, r *http.Request) error {
|
||||
pid, err := pidParam(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige pid")
|
||||
return
|
||||
return err
|
||||
}
|
||||
uid := uidFromContext(r.Context())
|
||||
|
||||
var owner int64
|
||||
var filepath string
|
||||
if err := db.QueryRow(`SELECT uid, filepath FROM entry WHERE pid = ?`, pid).Scan(&owner, &filepath); err != nil {
|
||||
writeError(w, http.StatusNotFound, "Beitrag nicht gefunden")
|
||||
return
|
||||
}
|
||||
if owner != uid {
|
||||
writeError(w, http.StatusForbidden, "Das ist nicht dein Beitrag.")
|
||||
return
|
||||
// Dieselbe Prüfung wie auf der Bestätigungsseite; entryByPID liefert dabei
|
||||
// auch den Bildpfad, der nach dem UPDATE nicht mehr in der Zeile steht.
|
||||
e, err := ownEntry(r, pid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := db.Exec(
|
||||
`UPDATE entry SET deleted = 1, content = '', filepath = '', uid = 0 WHERE pid = ?`, pid,
|
||||
); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Löschen fehlgeschlagen")
|
||||
return
|
||||
return Internal(err)
|
||||
}
|
||||
if filepath != "" {
|
||||
os.Remove(filepath)
|
||||
if e.Filepath != "" {
|
||||
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
|
||||
}
|
||||
|
||||
// bumpStep ist der Cooldown-Zuwachs je bereits erfolgtem Bump: nach dem 1. Bump
|
||||
@@ -357,46 +465,53 @@ const bumpStep = 24 * 60 * 60
|
||||
// Spam wächst der Cooldown linear mit der Zahl bisheriger Bumps (siehe bumpStep);
|
||||
// er sitzt am Beitrag selbst (global), nicht am bumpenden Nutzer. Jeder
|
||||
// Eingeloggte darf bumpen (Route in der requireAuth-Gruppe).
|
||||
func handleBump(w http.ResponseWriter, r *http.Request) {
|
||||
pid, err := strconv.ParseInt(chi.URLParam(r, "pid"), 10, 64)
|
||||
func handleBump(w http.ResponseWriter, r *http.Request) error {
|
||||
pid, err := pidParam(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige pid")
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
// Cooldown = bisherige Bumps in Tagen. Beim ersten Bump (bump_count 0) ist
|
||||
// die Sperre 0 -> sofort erlaubt; danach wächst sie mit jedem Bump. Die
|
||||
// Bedingung sitzt direkt im UPDATE (statt Read-then-Write), damit zwei
|
||||
// gleichzeitige Bumps den Cooldown nicht umgehen können.
|
||||
now := time.Now().Unix()
|
||||
res, err := db.Exec(
|
||||
`UPDATE entry SET last_activity = ?, bump_count = bump_count + 1, last_bump = ?
|
||||
WHERE pid = ? AND deleted = 0 AND last_bump + bump_count * ? <= ?`,
|
||||
now, now, pid, bumpStep, now,
|
||||
)
|
||||
if err != nil {
|
||||
return Internal(err)
|
||||
}
|
||||
|
||||
var bumpCount, lastBump, deleted int64
|
||||
if err := db.QueryRow(`SELECT bump_count, last_bump, deleted FROM entry WHERE pid = ?`, pid).
|
||||
Scan(&bumpCount, &lastBump, &deleted); err != nil {
|
||||
writeError(w, http.StatusNotFound, "Beitrag nicht gefunden")
|
||||
return
|
||||
}
|
||||
if deleted != 0 {
|
||||
writeError(w, http.StatusNotFound, "Beitrag nicht gefunden")
|
||||
return
|
||||
Scan(&bumpCount, &lastBump, &deleted); err != nil || deleted != 0 {
|
||||
return errEntryNotFound
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
// Cooldown = bisherige Bumps in Tagen. Beim ersten Bump (bumpCount 0) ist die
|
||||
// Sperre 0 -> sofort erlaubt; danach wächst sie mit jedem Bump.
|
||||
if remaining := lastBump + bumpCount*bumpStep - now; remaining > 0 {
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
remaining := lastBump + bumpCount*bumpStep - now
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
// Bewusst kein Taxonomie-Fehler: die Antwort trägt Nutzdaten
|
||||
// (retry_after, bump_count), die das Frontend zum Sperren des Buttons
|
||||
// braucht -- ein reiner Fehlerkörper kann die nicht transportieren.
|
||||
// Der Cooldown ist auch kein Fehlerfall, sondern ein regulärer
|
||||
// Ausgang des Bumpens.
|
||||
w.Header().Set("Retry-After", strconv.FormatInt(remaining, 10))
|
||||
writeJSON(w, http.StatusTooManyRequests, map[string]any{
|
||||
"error": "Dieser Beitrag wurde erst kürzlich repostet.",
|
||||
"error": apiErrorBody{
|
||||
Code: "entry.bump_cooldown",
|
||||
Message: "Dieser Beitrag wurde erst kürzlich repostet.",
|
||||
},
|
||||
"retry_after": remaining,
|
||||
"bump_count": bumpCount,
|
||||
})
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := db.Exec(
|
||||
`UPDATE entry SET last_activity = ?, bump_count = bump_count + 1, last_bump = ? WHERE pid = ?`,
|
||||
now, now, pid,
|
||||
); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Bumpen fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
|
||||
bumpCount++
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"pid": pid,
|
||||
"bump_count": bumpCount,
|
||||
@@ -404,6 +519,7 @@ func handleBump(w http.ResponseWriter, r *http.Request) {
|
||||
"last_activity": now,
|
||||
"retry_after": bumpCount * bumpStep, // bis zum nächsten erlaubten Bump
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// voteTally liefert die Zähler und die eigene Auswahl (selected) für einen Beitrag.
|
||||
@@ -416,11 +532,106 @@ func voteTally(pid, uid int64) (left, right int, selected string) {
|
||||
selected = cur
|
||||
}
|
||||
}
|
||||
db.QueryRow(`SELECT COUNT(*) FROM vote WHERE pid = ? AND mode = 'left'`, pid).Scan(&left)
|
||||
db.QueryRow(`SELECT COUNT(*) FROM vote WHERE pid = ? AND mode = 'right'`, pid).Scan(&right)
|
||||
// Beide Zähler in einem Query statt zwei COUNTs.
|
||||
rows, err := db.Query(`SELECT mode, COUNT(*) FROM vote WHERE pid = ? GROUP BY mode`, pid)
|
||||
if err != nil {
|
||||
log.Printf("voteTally pid=%d: %v", pid, err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var mode string
|
||||
var n int
|
||||
if err := rows.Scan(&mode, &n); err != nil {
|
||||
log.Printf("voteTally pid=%d: %v", pid, err)
|
||||
return
|
||||
}
|
||||
switch mode {
|
||||
case "left":
|
||||
left = n
|
||||
case "right":
|
||||
right = n
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// tally ist der Abstimmungsstand eines Beitrags aus Sicht eines Betrachters.
|
||||
type tallyState struct {
|
||||
Left int
|
||||
Right int
|
||||
Selected string // "left", "right" oder "none"
|
||||
}
|
||||
|
||||
// voteTallies holt die Stände für eine ganze Feed-Seite in zwei Abfragen statt
|
||||
// in zwei pro Beitrag. Bei 20 Beiträgen wären das sonst 40 Roundtrips für eine
|
||||
// einzige Seitenansicht.
|
||||
func voteTallies(pids []int64, uid int64) (map[int64]tallyState, error) {
|
||||
out := make(map[int64]tallyState, len(pids))
|
||||
for _, pid := range pids {
|
||||
out[pid] = tallyState{Selected: "none"}
|
||||
}
|
||||
if len(pids) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(pids)), ",")
|
||||
args := make([]any, 0, len(pids)+1)
|
||||
for _, pid := range pids {
|
||||
args = append(args, pid)
|
||||
}
|
||||
|
||||
rows, err := db.Query(
|
||||
`SELECT pid, mode, COUNT(*) FROM vote WHERE pid IN (`+placeholders+`) GROUP BY pid, mode`, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var pid int64
|
||||
var mode string
|
||||
var n int
|
||||
if err := rows.Scan(&pid, &mode, &n); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
t := out[pid]
|
||||
switch mode {
|
||||
case "left":
|
||||
t.Left = n
|
||||
case "right":
|
||||
t.Right = n
|
||||
}
|
||||
out[pid] = t
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Eigene Stimmen nur, wenn überhaupt jemand angemeldet ist.
|
||||
if uid == 0 {
|
||||
return out, nil
|
||||
}
|
||||
rows, err = db.Query(
|
||||
`SELECT pid, mode FROM vote WHERE uid = ? AND pid IN (`+placeholders+`)`,
|
||||
append([]any{uid}, args...)...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var pid int64
|
||||
var mode string
|
||||
if err := rows.Scan(&pid, &mode); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t := out[pid]
|
||||
t.Selected = mode
|
||||
out[pid] = t
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func writeTally(w http.ResponseWriter, pid, uid int64) {
|
||||
left, right, selected := voteTally(pid, uid)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
@@ -432,11 +643,10 @@ func writeTally(w http.ResponseWriter, pid, uid int64) {
|
||||
}
|
||||
|
||||
// handleVotes liest den Abstimmungsstand eines Beitrags (read-only, öffentlich).
|
||||
func handleVotes(w http.ResponseWriter, r *http.Request) {
|
||||
pid, err := strconv.ParseInt(chi.URLParam(r, "pid"), 10, 64)
|
||||
func handleVotes(w http.ResponseWriter, r *http.Request) error {
|
||||
pid, err := pidParam(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige pid")
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
var uid int64
|
||||
@@ -444,54 +654,120 @@ func handleVotes(w http.ResponseWriter, r *http.Request) {
|
||||
uid = s.UID
|
||||
}
|
||||
writeTally(w, pid, uid)
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleReport nimmt eine Meldung zu einem Beitrag entgegen (DSA-Melde-
|
||||
// verfahren). Bewusst ohne Auth -- jeder darf melden, weil es eine
|
||||
// Moderationshilfe ist. Die Melder-uid wird vorerst nicht gespeichert (immer
|
||||
// 0); nur der optionale Grund landet in der DB. Die Route ist per IP
|
||||
// rate-limited (siehe main.go), damit der offene Endpunkt kein Spam-Kanal wird.
|
||||
func handleReport(w http.ResponseWriter, r *http.Request) error {
|
||||
pid, err := pidParam(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Nur existierende Beiträge melden, sonst entstehen Waisen-Meldungen.
|
||||
if _, err := entryByPID(pid); err != nil {
|
||||
return errEntryNotFound
|
||||
}
|
||||
reason := truncateRunes(strings.TrimSpace(r.FormValue("reason")), 500)
|
||||
if _, err := db.Exec(
|
||||
`INSERT INTO report (pid, uid, reason, created_at, handled) VALUES (?, 0, ?, ?, 0)`,
|
||||
pid, reason, time.Now().Unix(),
|
||||
); err != nil {
|
||||
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)
|
||||
// - andere Stimme -> auf den neuen Modus wechseln
|
||||
func handleVote(w http.ResponseWriter, r *http.Request) {
|
||||
pid, err := strconv.ParseInt(chi.URLParam(r, "pid"), 10, 64)
|
||||
// - gleiche Stimme erneut -> Stimme zurückziehen (Toggle)
|
||||
// - andere Stimme -> auf den neuen Modus wechseln
|
||||
func handleVote(w http.ResponseWriter, r *http.Request) error {
|
||||
pid, err := pidParam(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige pid")
|
||||
return
|
||||
return err
|
||||
}
|
||||
uid := uidFromContext(r.Context())
|
||||
|
||||
mode := r.FormValue("mode")
|
||||
if mode != "left" && mode != "right" {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiger Modus (left oder right)")
|
||||
return
|
||||
return Invalid("vote.bad_mode", "Ungültiger Modus (left oder right)").At("mode")
|
||||
}
|
||||
|
||||
// Nur auf existierende Beiträge abstimmen, sonst entstehen Waisen-Votes.
|
||||
if _, err := entryByPID(pid); err != nil {
|
||||
writeError(w, http.StatusNotFound, "Beitrag nicht gefunden")
|
||||
return
|
||||
return errEntryNotFound
|
||||
}
|
||||
|
||||
var prev string
|
||||
readErr := db.QueryRow(`SELECT mode FROM vote WHERE uid = ? AND pid = ?`, uid, pid).Scan(&prev)
|
||||
if readErr != nil && readErr != sql.ErrNoRows {
|
||||
writeError(w, http.StatusInternalServerError, "Datenbankfehler")
|
||||
return
|
||||
// Toggle-Logik in zwei jeweils atomaren Statements statt Read-then-Write
|
||||
// (parallele Requests konnten den Toggle sonst doppelt anwenden):
|
||||
// 1. Gleiche Stimme löschen -> hat das getroffen, war es ein Toggle-off.
|
||||
// 2. Sonst Upsert: neue Stimme anlegen bzw. bestehende ummodeln.
|
||||
res, err := db.Exec(`DELETE FROM vote WHERE uid = ? AND pid = ? AND mode = ?`, uid, pid, mode)
|
||||
if err != nil {
|
||||
return Internal(err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
if _, err := db.Exec(
|
||||
`INSERT INTO vote (uid, pid, mode) VALUES (?, ?, ?)
|
||||
ON CONFLICT(uid, pid) DO UPDATE SET mode = excluded.mode`,
|
||||
uid, pid, mode,
|
||||
); err != nil {
|
||||
return Internal(err)
|
||||
}
|
||||
}
|
||||
|
||||
var execErr error
|
||||
switch {
|
||||
case readErr == sql.ErrNoRows:
|
||||
_, execErr = db.Exec(`INSERT INTO vote (uid, pid, mode) VALUES (?, ?, ?)`, uid, pid, mode)
|
||||
case prev == mode:
|
||||
_, execErr = db.Exec(`DELETE FROM vote WHERE uid = ? AND pid = ?`, uid, pid)
|
||||
default:
|
||||
_, execErr = db.Exec(`UPDATE vote SET mode = ? WHERE uid = ? AND pid = ?`, mode, uid, pid)
|
||||
// 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
|
||||
}
|
||||
if execErr != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Datenbankfehler")
|
||||
return
|
||||
}
|
||||
|
||||
writeTally(w, pid, uid)
|
||||
return nil
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -506,6 +782,18 @@ const (
|
||||
maxUploadBytes = 17 << 20
|
||||
)
|
||||
|
||||
// errUploadTooLargeOrBroken unterscheidet die beiden Gründe, an denen
|
||||
// ParseMultipartForm scheitern kann: das Limit von MaxBytesReader (der Upload
|
||||
// war zu groß -> 413) oder kaputtes Multipart (-> 400). Vorher endete beides
|
||||
// als 400, was dem Nutzer nicht sagte, dass die Datei schlicht zu groß war.
|
||||
func errUploadTooLargeOrBroken(err error) error {
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
return TooLarge("upload.too_large", "Die Datei ist zu groß.").WithCause(err)
|
||||
}
|
||||
return Invalid("request.malformed", "Ungültige Anfrage").WithCause(err)
|
||||
}
|
||||
|
||||
// storeImage speichert ein hochgeladenes Bild unter static/media.
|
||||
// Standbilder (JPEG/PNG/Einzelbild-GIF) werden auf max. 1024px skaliert und
|
||||
// dabei durch Decode+Re-Encode sanitisiert. Animierte GIFs würden durch das
|
||||
|
||||
Reference in New Issue
Block a user