15ef7db5f7
- user.avatar-Spalte (Schema, migrate.sh, Migrationsdoku)
- POST /user/avatar (geschützt): Upload via storeImage, ersetzt/löscht altes Bild
- avatar in /u/{name}/info, /user/info und im entrySelect-Join (Feed-Karten)
- Account-Löschung entfernt auch das Avatar-File
- Frontend: Avatar im Profilkopf + Byline der Karten (50px), Upload-Form,
Platzhalterbild no_profile_pic.jpg als Fallback
- .card img per :not(.avatar) von Beitragsbildern getrennt
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
501 lines
14 KiB
Go
501 lines
14 KiB
Go
package main
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"image"
|
|
"image/gif"
|
|
"image/jpeg"
|
|
_ "image/png"
|
|
"io"
|
|
"math/rand/v2"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
xdraw "golang.org/x/image/draw"
|
|
)
|
|
|
|
type Entry struct {
|
|
PID int64 `json:"pid"`
|
|
UID int64 `json:"uid"`
|
|
CreatedAt int64 `json:"created_at"`
|
|
Content string `json:"content"`
|
|
Filepath string `json:"filepath"`
|
|
ReplyTo int64 `json:"reply_to"`
|
|
ReplyCount int64 `json:"reply_count"`
|
|
LastActivity int64 `json:"last_activity"`
|
|
Deleted int64 `json:"deleted"`
|
|
Username string `json:"username"`
|
|
Avatar string `json:"avatar"`
|
|
}
|
|
|
|
// LEFT JOIN + COALESCE, damit soft-gelöschte Beiträge (uid auf 0 gesetzt) als
|
|
// [deleted]-Platzhalter erhalten bleiben und Threads nicht verwaisen.
|
|
const entrySelect = `
|
|
SELECT e.pid, e.uid, e.created_at, e.content, e.filepath,
|
|
e.reply_to, e.reply_count, e.last_activity, e.deleted,
|
|
COALESCE(u.username, ''), COALESCE(u.avatar, '')
|
|
FROM entry e LEFT JOIN user u ON u.uid = e.uid`
|
|
|
|
// scanEntries liest Entry-Zeilen aus einem Query mit entrySelect-Spalten.
|
|
func scanEntries(rows *sql.Rows) ([]Entry, error) {
|
|
defer rows.Close()
|
|
entries := []Entry{}
|
|
for rows.Next() {
|
|
var e Entry
|
|
if err := rows.Scan(&e.PID, &e.UID, &e.CreatedAt, &e.Content, &e.Filepath,
|
|
&e.ReplyTo, &e.ReplyCount, &e.LastActivity, &e.Deleted, &e.Username, &e.Avatar); err != nil {
|
|
return nil, err
|
|
}
|
|
entries = append(entries, e)
|
|
}
|
|
return entries, rows.Err()
|
|
}
|
|
|
|
// entryByPID liefert einen einzelnen Beitrag oder sql.ErrNoRows.
|
|
func entryByPID(pid int64) (Entry, error) {
|
|
var e Entry
|
|
err := db.QueryRow(entrySelect+` WHERE e.pid = ?`, pid).Scan(
|
|
&e.PID, &e.UID, &e.CreatedAt, &e.Content, &e.Filepath,
|
|
&e.ReplyTo, &e.ReplyCount, &e.LastActivity, &e.Deleted, &e.Username, &e.Avatar)
|
|
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
|
|
|
|
query := entrySelect
|
|
args := []any{}
|
|
where := []string{}
|
|
if uid != 0 {
|
|
where = append(where, "e.uid = ?")
|
|
args = append(args, uid)
|
|
}
|
|
if rootsOnly {
|
|
where = append(where, "e.reply_to = 0")
|
|
}
|
|
if len(where) > 0 {
|
|
query += " WHERE " + strings.Join(where, " AND ")
|
|
}
|
|
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)
|
|
|
|
rows, err := db.Query(query, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return scanEntries(rows)
|
|
}
|
|
|
|
func handleFeed(w http.ResponseWriter, r *http.Request) {
|
|
page, _ := strconv.Atoi(chi.URLParam(r, "page"))
|
|
entries, err := feedPage(page, 0, true)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "Datenbankfehler")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, entries)
|
|
}
|
|
|
|
// handleUserFeed liefert den Feed eines einzelnen Nutzers (öffentlich, inkl. Antworten).
|
|
func handleUserFeed(w http.ResponseWriter, r *http.Request) {
|
|
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
|
|
}
|
|
|
|
page, _ := strconv.Atoi(chi.URLParam(r, "page"))
|
|
entries, err := feedPage(page, uid, false)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "Datenbankfehler")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, entries)
|
|
}
|
|
|
|
// serveEntryPage liefert die statische Focus-Seite; das Frontend lädt die Daten
|
|
// dann über /entry/{pid}/thread.
|
|
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) {
|
|
pid, err := strconv.ParseInt(chi.URLParam(r, "pid"), 10, 64)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "Ungültige pid")
|
|
return
|
|
}
|
|
|
|
entry, err := entryByPID(pid)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "Beitrag nicht gefunden")
|
|
return
|
|
}
|
|
|
|
// 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)
|
|
if err != nil {
|
|
break
|
|
}
|
|
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
|
|
}
|
|
replies, err := scanEntries(rows)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "Datenbankfehler")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"entry": entry,
|
|
"ancestors": ancestors,
|
|
"replies": replies,
|
|
})
|
|
}
|
|
|
|
func handleCreateEntry(w http.ResponseWriter, r *http.Request) {
|
|
uid := uidFromContext(r.Context())
|
|
|
|
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]
|
|
}
|
|
|
|
replyTo, _ := strconv.ParseInt(r.FormValue("reply_to"), 10, 64)
|
|
if replyTo != 0 {
|
|
if _, err := entryByPID(replyTo); err != nil {
|
|
writeError(w, http.StatusBadRequest, "Elternbeitrag nicht gefunden")
|
|
return
|
|
}
|
|
}
|
|
|
|
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 filepath == "" && content == "" {
|
|
writeError(w, http.StatusBadRequest, "Leerer Beitrag!")
|
|
return
|
|
}
|
|
|
|
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
|
|
}
|
|
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
|
|
}
|
|
// Antwort: reply_count und last_activity aller Vorfahren bis zum Root anheben.
|
|
for cur, i := replyTo, 0; cur != 0 && i < 50; i++ {
|
|
if _, err := tx.Exec(
|
|
`UPDATE entry SET reply_count = reply_count + 1,
|
|
last_activity = MAX(COALESCE(last_activity, 0), ?) WHERE pid = ?`,
|
|
now, cur,
|
|
); 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
|
|
}
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "Beitrag konnte nicht gespeichert werden")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusCreated, map[string]any{
|
|
"pid": pid,
|
|
"content": content,
|
|
"filepath": filepath,
|
|
"reply_to": replyTo,
|
|
})
|
|
}
|
|
|
|
// 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)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "Ungültige pid")
|
|
return
|
|
}
|
|
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
|
|
}
|
|
|
|
content := strings.TrimSpace(r.FormValue("content"))
|
|
if content == "" {
|
|
writeError(w, http.StatusBadRequest, "Inhalt darf nicht leer sein.")
|
|
return
|
|
}
|
|
if len(content) > 1000 {
|
|
content = content[:1000]
|
|
}
|
|
|
|
if _, err := db.Exec(`UPDATE entry SET content = ? WHERE pid = ?`, content, pid); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "Bearbeiten fehlgeschlagen")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"pid": pid, "content": content})
|
|
}
|
|
|
|
// 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)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "Ungültige pid")
|
|
return
|
|
}
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
if filepath != "" {
|
|
os.Remove(filepath)
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
|
}
|
|
|
|
// voteTally liefert die Zähler und die eigene Auswahl (selected) für einen Beitrag.
|
|
// Ist uid == 0 (nicht eingeloggt), ist selected immer "none".
|
|
func voteTally(pid, uid int64) (left, right int, selected string) {
|
|
selected = "none"
|
|
if uid != 0 {
|
|
var cur string
|
|
if err := db.QueryRow(`SELECT mode FROM vote WHERE uid = ? AND pid = ?`, uid, pid).Scan(&cur); err == nil {
|
|
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)
|
|
return
|
|
}
|
|
|
|
func writeTally(w http.ResponseWriter, pid, uid int64) {
|
|
left, right, selected := voteTally(pid, uid)
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"pid": pid,
|
|
"left": left,
|
|
"right": right,
|
|
"selected": selected,
|
|
})
|
|
}
|
|
|
|
// 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)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "Ungültige pid")
|
|
return
|
|
}
|
|
|
|
var uid int64
|
|
if s, ok := getSession(r); ok {
|
|
uid = s.UID
|
|
}
|
|
writeTally(w, pid, uid)
|
|
}
|
|
|
|
// 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)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "Ungültige pid")
|
|
return
|
|
}
|
|
uid := uidFromContext(r.Context())
|
|
|
|
mode := r.FormValue("mode")
|
|
if mode != "left" && mode != "right" {
|
|
writeError(w, http.StatusBadRequest, "Ungültiger Modus (left oder right)")
|
|
return
|
|
}
|
|
|
|
// Nur auf existierende Beiträge abstimmen, sonst entstehen Waisen-Votes.
|
|
if _, err := entryByPID(pid); err != nil {
|
|
writeError(w, http.StatusNotFound, "Beitrag nicht gefunden")
|
|
return
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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)
|
|
}
|
|
if execErr != nil {
|
|
writeError(w, http.StatusInternalServerError, "Datenbankfehler")
|
|
return
|
|
}
|
|
|
|
writeTally(w, pid, uid)
|
|
}
|
|
|
|
// storeImage skaliert ein hochgeladenes Bild auf max. 1024px und speichert es unter static/media.
|
|
// Hinweis: animierte GIFs werden derzeit auf das erste Frame reduziert.
|
|
func storeImage(fh *multipart.FileHeader) (string, error) {
|
|
const maxSize = 1024
|
|
|
|
src, err := fh.Open()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer src.Close()
|
|
|
|
// Dimensionen aus dem Header prüfen, bevor wir die vollen Pixel allokieren:
|
|
// eine kleine Datei kann riesige Maße deklarieren (Decompression-Bomb) und
|
|
// sonst beim Decode den Speicher sprengen.
|
|
const maxPixels = 50_000_000 // ~50 MP
|
|
cfg, _, err := image.DecodeConfig(src)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if cfg.Width <= 0 || cfg.Height <= 0 || cfg.Width*cfg.Height > maxPixels {
|
|
return "", fmt.Errorf("bild zu groß: %dx%d", cfg.Width, cfg.Height)
|
|
}
|
|
if _, err := src.Seek(0, io.SeekStart); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
img, format, err := image.Decode(src)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
b := img.Bounds()
|
|
width, height := b.Dx(), b.Dy()
|
|
|
|
nw, nh := width, height
|
|
if width > maxSize || height > maxSize {
|
|
if width > height {
|
|
nw, nh = maxSize, height*maxSize/width
|
|
} else {
|
|
nw, nh = width*maxSize/height, maxSize
|
|
}
|
|
}
|
|
|
|
ext := "jpg"
|
|
if format == "gif" {
|
|
ext = "gif"
|
|
}
|
|
|
|
if err := os.MkdirAll("static/media", 0o755); err != nil {
|
|
return "", err
|
|
}
|
|
name := fmt.Sprintf("static/media/%d-%d.%s", time.Now().Unix(), rand.IntN(999999), ext)
|
|
|
|
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
|
|
xdraw.CatmullRom.Scale(dst, dst.Bounds(), img, b, xdraw.Over, nil)
|
|
|
|
out, err := os.Create(name)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer out.Close()
|
|
|
|
if ext == "gif" {
|
|
err = gif.Encode(out, dst, nil)
|
|
} else {
|
|
err = jpeg.Encode(out, dst, &jpeg.Options{Quality: 85})
|
|
}
|
|
if err != nil {
|
|
os.Remove(name)
|
|
return "", err
|
|
}
|
|
return name, nil
|
|
}
|