diff --git a/.containerignore b/.containerignore index f754b95..caa8d63 100644 --- a/.containerignore +++ b/.containerignore @@ -12,7 +12,6 @@ kver *.db.bak *.env static/media/ -migrate.sh Containerfile .containerignore frontend/node_modules/ diff --git a/Containerfile b/Containerfile index e887373..5388e0a 100644 --- a/Containerfile +++ b/Containerfile @@ -8,8 +8,7 @@ RUN npm ci COPY frontend/ ./ RUN npm run build -# Build-Stage: statisches Binary, CGO aus (pgx und modernc.org/sqlite für den -# einmaligen Import sind pures Go). +# Build-Stage: statisches Binary, CGO aus (pgx ist pures Go). FROM docker.io/library/golang:1.26-alpine AS build WORKDIR /src COPY go.mod go.sum ./ @@ -24,8 +23,7 @@ WORKDIR /app COPY --from=build /kver /usr/local/bin/kver COPY --from=frontend /src/web web/ COPY static/ static/ -# data: GeoLite2-ASN-DB (und die alte SQLite-DB für den Import); -# static/media: Uploads. Beide als Volume persistieren. +# data: GeoLite2-ASN-DB; static/media: Uploads. Beide als Volume persistieren. RUN mkdir -p data static/media && chown kver:kver data static/media USER kver ENV KVER_ADDR=:8080 diff --git a/auth.go b/auth.go index e4bbc77..f26eb6e 100644 --- a/auth.go +++ b/auth.go @@ -69,8 +69,6 @@ func handleLogin(w http.ResponseWriter, r *http.Request) error { return Invalid("user.name_short", "Der Nutzername ist zu kurz.").At("user") } - // Gleiche Meldung für "Nutzer existiert nicht" und "Passwort falsch": sonst - // wird der Endpunkt zum Nutzernamen-Orakel. var uid int64 var hash []byte err := db.QueryRow(`SELECT uid, password FROM account WHERE username = $1`, username).Scan(&uid, &hash) @@ -142,6 +140,12 @@ func handleLogout(w http.ResponseWriter, r *http.Request) error { return Internal(err) } } + clearSessionCookie(w, r) + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) + return nil +} + +func clearSessionCookie(w http.ResponseWriter, r *http.Request) { http.SetCookie(w, &http.Cookie{ Name: "session", Value: "", @@ -152,8 +156,6 @@ func handleLogout(w http.ResponseWriter, r *http.Request) error { Secure: isHTTPS(r), SameSite: http.SameSiteLaxMode, }) - writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) - return nil } // handleNewUser sammelt alle Validierungsfehler ein, statt beim ersten @@ -165,7 +167,7 @@ func handleNewUser(w http.ResponseWriter, r *http.Request) error { username := sanitizeUsername(r.FormValue("user")) if len(username) < 3 { fields["user"] = "Der Nutzername ist zu kurz." - } else if idFromUsername(username) != 0 { + } else if usernameTaken(username) { fields["user"] = "Der Nutzername wird bereits verwendet." } @@ -217,12 +219,7 @@ func handleSessionInfo(w http.ResponseWriter, r *http.Request) error { } func sanitizeUsername(u string) string { - u = strings.TrimSpace(u) - // Auf 32 Zeichen (Runen) kürzen, nicht 32 Bytes -- sonst kann der Schnitt - // mitten in eine Multibyte-Rune fallen und kaputtes UTF-8 erzeugen. - if r := []rune(u); len(r) > 32 { - u = string(r[:32]) - } + u = truncateRunes(strings.TrimSpace(u), 32) u = reNonWord.ReplaceAllString(u, "") u = reSpace.ReplaceAllString(u, "_") return u diff --git a/entry.go b/entry.go index a6f640f..5a2a16f 100644 --- a/entry.go +++ b/entry.go @@ -1,7 +1,6 @@ package main import ( - "database/sql" "errors" "fmt" "image" @@ -56,15 +55,26 @@ const entrySelect = ` COALESCE(u.username, ''), COALESCE(u.avatar, '') FROM entry e LEFT JOIN account u ON u.uid = e.uid` -// scanEntries liest Entry-Zeilen aus einem Query mit entrySelect-Spalten. -func scanEntries(rows *sql.Rows) ([]Entry, error) { +// scanEntry liest eine Zeile mit entrySelect-Spalten (aus *sql.Row oder *sql.Rows). +func scanEntry(row interface{ Scan(...any) error }) (Entry, error) { + var e Entry + err := row.Scan(&e.PID, &e.UID, &e.CreatedAt, &e.Content, &e.Filepath, + &e.ReplyTo, &e.ReplyCount, &e.LastActivity, &e.Deleted, + &e.BumpCount, &e.LastBump, &e.Username, &e.Avatar) + return e, err +} + +// queryEntries führt einen Query mit entrySelect-Spalten aus. +func queryEntries(query string, args ...any) ([]Entry, error) { + rows, err := db.Query(query, args...) + if err != nil { + return nil, err + } 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.BumpCount, &e.LastBump, &e.Username, &e.Avatar); err != nil { + e, err := scanEntry(rows) + if err != nil { return nil, err } entries = append(entries, e) @@ -74,12 +84,7 @@ func scanEntries(rows *sql.Rows) ([]Entry, error) { // entryByPID liefert einen einzelnen Beitrag oder sql.ErrNoRows. func entryByPID(pid int64) (Entry, error) { - var e Entry - err := db.QueryRow(entrySelect+` WHERE e.pid = $1`, pid).Scan( - &e.PID, &e.UID, &e.CreatedAt, &e.Content, &e.Filepath, - &e.ReplyTo, &e.ReplyCount, &e.LastActivity, &e.Deleted, - &e.BumpCount, &e.LastBump, &e.Username, &e.Avatar) - return e, err + return scanEntry(db.QueryRow(entrySelect+` WHERE e.pid = $1`, pid)) } const ( @@ -89,49 +94,14 @@ const ( maxFeedPage = 100 ) -// feedWhere baut die Filterbedingung für Feed-Abfragen. -func feedWhere(uid int64, rootsOnly bool) (string, []any) { - args := []any{} - where := []string{} - if uid != 0 { - args = append(args, uid) - where = append(where, fmt.Sprintf("e.uid = $%d", len(args))) - } - if rootsOnly { - where = append(where, "e.reply_to = 0") - } - if len(where) == 0 { - return "", args - } - return " WHERE " + strings.Join(where, " AND "), args -} - -// 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 += fmt.Sprintf(` LIMIT $%d OFFSET $%d`, len(args)+1, len(args)+2) - args = append(args, feedPageSize, page*feedPageSize) - - rows, err := db.Query(query, args...) - if err != nil { - return nil, err - } - return scanEntries(rows) +// feedPage liefert Seite page (Routenparameter {page}) eines Feed-Queries mit +// entrySelect-Spalten; LIMIT und OFFSET werden hinter args angehängt. +func feedPage(r *http.Request, query string, args ...any) ([]Entry, error) { + page, _ := strconv.Atoi(chi.URLParam(r, "page")) + page = min(max(page, 0), maxFeedPage) + n := len(args) + query += fmt.Sprintf(` LIMIT $%d OFFSET $%d`, n+1, n+2) + return queryEntries(query, append(args, feedPageSize, page*feedPageSize)...) } // attachLatest hängt an jeden Beitrag den jüngsten nicht gelöschten Beitrag @@ -141,7 +111,7 @@ func feedPage(page int, uid int64, rootsOnly bool) ([]Entry, error) { // // Ein Query für alle Beiträge: die rekursive CTE steigt von allen zugleich // ab, top merkt sich den Ausgangsbeitrag, depth die Tiefe darunter. Das Limit -// deckelt die Tiefe wie bei der Ahnenkette (threadData). +// deckelt die Tiefe wie bei der Ahnenkette (handleThread). func attachLatest(entries []Entry) error { if len(entries) == 0 { return nil @@ -187,11 +157,7 @@ func attachLatest(entries []Entry) error { return nil } - rows, err = db.Query(entrySelect+` WHERE e.pid = ANY($1)`, latestPIDs) - if err != nil { - return err - } - latest, err := scanEntries(rows) + latest, err := queryEntries(entrySelect+` WHERE e.pid = ANY($1)`, latestPIDs) if err != nil { return err } @@ -209,9 +175,10 @@ func attachLatest(entries []Entry) error { return nil } +// handleFeed liefert die Wurzelbeiträge nach letzter Aktivität, jeweils mit +// ihrer jüngsten Antwort. func handleFeed(w http.ResponseWriter, r *http.Request) error { - page, _ := strconv.Atoi(chi.URLParam(r, "page")) - entries, err := feedPage(page, 0, true) + entries, err := feedPage(r, entrySelect+` WHERE e.reply_to = 0 ORDER BY e.last_activity DESC`) if err != nil { return Internal(err) } @@ -222,15 +189,15 @@ func handleFeed(w http.ResponseWriter, r *http.Request) error { return nil } -// handleUserFeed liefert den Feed eines einzelnen Nutzers (öffentlich, inkl. Antworten). +// handleUserFeed liefert alle Beiträge eines Nutzers chronologisch, Antworten +// eingeschlossen. func handleUserFeed(w http.ResponseWriter, r *http.Request) error { var uid int64 if err := db.QueryRow(`SELECT uid FROM account WHERE username = $1`, chi.URLParam(r, "username")).Scan(&uid); err != nil { return errUserNotFound } - page, _ := strconv.Atoi(chi.URLParam(r, "page")) - entries, err := feedPage(page, uid, false) + entries, err := feedPage(r, entrySelect+` WHERE e.uid = $1 ORDER BY e.created_at DESC`, uid) if err != nil { return Internal(err) } @@ -253,49 +220,30 @@ func pidParam(r *http.Request) (int64, error) { return pid, nil } -// handleThread liefert einen Beitrag mit seiner Ahnenkette (Root zuerst) und den -// direkten Antworten samt deren jüngstem Nachfahren (öffentlich). +// thread ist ein Beitrag mit seinem Kontext: Ahnenkette (Root zuerst) und +// direkte Antworten samt deren jüngstem Nachfahren. +type thread struct { + Entry Entry `json:"entry"` + Ancestors []Entry `json:"ancestors"` + Replies []Entry `json:"replies"` +} + 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. -func threadData(pid int64) (thread, error) { entry, err := entryByPID(pid) if err != nil { - return thread{}, errEntryNotFound + return errEntryNotFound } - t := thread{Entry: entry, Ancestors: []Entry{}, Replies: []Entry{}} + t := thread{Entry: entry, Ancestors: []Entry{}} - // 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. + // 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( + t.Ancestors, err = queryEntries( `WITH RECURSIVE chain(pid, depth) AS ( SELECT $1::bigint, 1 UNION ALL @@ -306,24 +254,20 @@ func threadData(pid int64) (thread, error) { JOIN chain c ON c.pid = e.pid ORDER BY c.depth DESC`, entry.ReplyTo) if err != nil { - return thread{}, Internal(err) - } - if t.Ancestors, err = scanEntries(rows); err != nil { - return thread{}, Internal(err) + return Internal(err) } } - rows, err := db.Query(entrySelect+` WHERE e.reply_to = $1 ORDER BY e.last_activity DESC LIMIT 100`, pid) + t.Replies, err = queryEntries(entrySelect+` WHERE e.reply_to = $1 ORDER BY e.last_activity DESC LIMIT 100`, pid) if err != nil { - return thread{}, Internal(err) - } - if t.Replies, err = scanEntries(rows); err != nil { - return thread{}, Internal(err) + return Internal(err) } if err := attachLatest(t.Replies); err != nil { - return thread{}, Internal(err) + return Internal(err) } - return t, nil + + writeJSON(w, http.StatusOK, t) + return nil } func handleCreateEntry(w http.ResponseWriter, r *http.Request) error { @@ -504,10 +448,9 @@ const bumpStep = 24 * 60 * 60 // handleBump hebt last_activity eines Beitrags auf jetzt -> er steigt im Feed // (Roots, sortiert nach last_activity) bzw. in der Antwortliste eines Threads -// wieder nach oben. Tritt an die Stelle der früheren "Bump"-Kommentare. Gegen -// 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). +// wieder nach oben. Gegen Spam wächst der Cooldown linear mit der Zahl +// bisheriger Bumps (siehe bumpStep); er sitzt am Beitrag selbst (global), nicht +// am bumpenden Nutzer. func handleBump(w http.ResponseWriter, r *http.Request) error { pid, err := pidParam(r) if err != nil { @@ -539,9 +482,8 @@ func handleBump(w http.ResponseWriter, r *http.Request) error { 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, und der Cooldown ist ein regulärer Ausgang. + // Kein Taxonomie-Fehler: die Antwort trägt neben dem Fehler Nutzdaten + // (retry_after, bump_count), mit denen das Frontend den Button sperrt. w.Header().Set("Retry-After", strconv.FormatInt(remaining, 10)) writeJSON(w, http.StatusTooManyRequests, map[string]any{ "error": apiErrorBody{ @@ -656,8 +598,7 @@ const ( // 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. +// war zu groß -> 413) oder kaputtes Multipart (-> 400). func errUploadTooLargeOrBroken(err error) error { var tooLarge *http.MaxBytesError if errors.As(err, &tooLarge) { @@ -749,7 +690,7 @@ func storeImage(fh *multipart.FileHeader) (string, error) { if format == "gif" { ext = "gif" } - name := fmt.Sprintf("static/media/%d-%d.%s", time.Now().Unix(), rand.IntN(999999), ext) + name := newMediaName(ext) dst := image.NewRGBA(image.Rect(0, 0, nw, nh)) xdraw.CatmullRom.Scale(dst, dst.Bounds(), img, b, xdraw.Over, nil) @@ -776,7 +717,7 @@ func storeImage(fh *multipart.FileHeader) (string, error) { // serverseitig erzeugten Namen nach static/media. Der hochgeladene Dateiname // fließt nie ein (kein Path-Traversal). func copyUpload(src io.Reader, ext string) (string, error) { - name := fmt.Sprintf("static/media/%d-%d.%s", time.Now().Unix(), rand.IntN(999999), ext) + name := newMediaName(ext) out, err := os.Create(name) if err != nil { return "", err @@ -795,3 +736,8 @@ func copyUpload(src io.Reader, ext string) (string, error) { } return name, nil } + +// newMediaName erzeugt einen serverseitigen Dateinamen unter static/media. +func newMediaName(ext string) string { + return fmt.Sprintf("static/media/%d-%d.%s", time.Now().Unix(), rand.IntN(999999), ext) +} diff --git a/errors.go b/errors.go index 5a08c3b..f1db245 100644 --- a/errors.go +++ b/errors.go @@ -6,9 +6,8 @@ import ( "net/http" ) -// Fehler-Taxonomie: trennt die drei Informationen, die früher an jeder -// Fehler-Aufrufstelle verklebt waren -- wie schwer der Fehler ist (Kind), -// was genau passiert ist (Code) und was der Nutzer lesen soll (Msg). +// Fehler-Taxonomie: trennt, wie schwer ein Fehler ist (Kind), was genau +// passiert ist (Code) und was der Nutzer lesen soll (Msg). // // Der Sinn der Trennung: das Frontend zeigt Msg an und markiert Field, ein // Client braucht Code, um ohne Parsen deutscher Strings reagieren zu können, @@ -92,9 +91,7 @@ func (e *Error) WithCause(cause error) *Error { return e } -// asError normalisiert alles, was kein *Error ist, zu einem Internal. Damit -// müssen die Adapter keinen Sonderfall für "nackte" Fehler kennen, die aus -// Bibliotheken oder noch nicht umgestellten Stellen kommen. +// asError normalisiert alles, was kein *Error ist, zu einem Internal. func asError(err error) *Error { var e *Error if errors.As(err, &e) { diff --git a/frontend/src/Layout.tsx b/frontend/src/Layout.tsx index bf1813f..8bc59f0 100644 --- a/frontend/src/Layout.tsx +++ b/frontend/src/Layout.tsx @@ -10,9 +10,8 @@ function plural(n: number, one: string, many: string) { return `${n} ${n === 1 ? one : many}`; } -// Layout ist der gemeinsame Seitenrahmen. Markup und IDs entsprechen dem alten -// Frontend (web/*.html), damit app.css unverändert greift: die Startseite hat -// Tagline und Headerbar, alle anderen Seiten den "zurück zum Feed"-Knopf. +// Layout ist der gemeinsame Seitenrahmen: die Startseite hat Tagline und +// Headerbar, alle anderen Seiten den "zurück zum Feed"-Knopf. export function Layout() { const isHome = useLocation().pathname === "/"; const days = Math.floor((Date.now() - dataLossStart) / 86400000); diff --git a/frontend/src/app.css b/frontend/src/app.css index a794286..18e0aa9 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -87,23 +87,6 @@ h2 { color: black; } -/* Dezente Reihe anarchistischer Sigillen unter der Tagline. Einheitliche Höhe, - gedämpft per opacity (die SVGs sind teils Strich-, teils Flächenzeichnungen, - eine gemeinsame Farbe gäbe es nicht -> Transparenz vereinheitlicht den Look). - aria-hidden im Markup: reine Dekoration, kein Vorlesen. */ -#sigils { - display: flex; - justify-content: center; - align-items: center; - gap: 1.2rem; - margin: 0.3rem 0; -} - -#sigils img { - height: 50px; - width: auto; -} - #headerbar { background-color: white; text-align: center; @@ -258,9 +241,13 @@ img.avatar-lg { width: 72px; height: 72px; } Die Strich-Spalte ist halb so breit wie der Avatar, ihr rechter Rand (= der Strich) liegt damit unter der Mitte des Byline-Avatars. Der Abstand zum Inhalt ist ebenfalls die halbe Avatarbreite -> Inhalt beginnt unter dem - rechten Avatarrand. Der .body füllt per flex:1 den Rest. */ + rechten Avatarrand. Der .body füllt per flex:1 den Rest. + Dasselbe gilt für die Diskussionskette in Feed und Antworten (Discussion: + Beitrag, ggf. "k weitere Beiträge", jüngste Antwort) an allen Gliedern + außer dem letzten. */ #ancestors .threadline, -#replies .threadline { +#replies .threadline, +.chain > :not(:last-child) .threadline { display: block; /* Box um die Linienbreite schmaler -> die rechts gezeichnete border-right landet genau auf der Avatarmitte statt rechts daneben. */ @@ -268,16 +255,10 @@ img.avatar-lg { width: 72px; height: 72px; } margin-right: calc(var(--avatar-sm) / 2); } -/* Nur die Vorfahren bilden einen Thread -> Linie. Antworten nur eingerückt. */ -#ancestors .threadline { border-right: var(--line-w) solid gray; } - -/* Diskussionskette in Feed und Antworten (Discussion): Beitrag, ggf. - "k weitere Beiträge", jüngste Antwort. Gleiches Bild wie die Ahnenkette -- - Strich an allen Gliedern außer dem letzten. */ +/* Strich nur, wo eine Kette ist: Vorfahren und Diskussionsketten. Direkte + Antworten ohne Kette sind nur eingerückt. */ +#ancestors .threadline, .chain > :not(:last-child) .threadline { - display: block; - flex: 0 0 calc(var(--avatar-sm) / 2 - var(--line-w)); - margin-right: calc(var(--avatar-sm) / 2); border-right: var(--line-w) solid gray; } @@ -299,7 +280,7 @@ img.avatar-lg { width: 72px; height: 72px; } /* Interaktionsleiste: Antworten-Button links, Vote-Buttons mittig, Repost rechts. Antwort- und Repost-Zahl stehen in den Buttons; der Repost-Cooldown - wandert in die Byline (siehe setupBump). */ + wandert in die Byline (siehe useBump). */ .interactions { display: flex; align-items: center; @@ -308,8 +289,7 @@ img.avatar-lg { width: 72px; height: 72px; } margin-top: 0.25rem; } -/* Bump-Button: tritt an die Stelle der früheren Bump-Kommentare. Im Cooldown - gesperrt (gilt pro Beitrag für alle), der Button zeigt dann die Restzeit. */ +/* Repost-Button: im Cooldown gesperrt (gilt pro Beitrag für alle). */ .bump:disabled { color: gray; border-color: #ccc; @@ -328,12 +308,6 @@ img.avatar-lg { width: 72px; height: 72px; } .ghost.selected { font-weight: bold; } .msg { color: red; } -p.warning { - background-color: #faa; - padding: 10px 15px; - font-weight: bold; -} - /* --- Formulare --- */ form { background-color: white; @@ -360,11 +334,6 @@ form input[type="file"] { outline: none; } -hr { - border: none; - border-top: 1px solid #ccc; -} - /* --- Statistik-Seite --- */ .statgrid { display: flex; @@ -381,8 +350,8 @@ hr { line-height: 1.1; } -/* Horizontale Balken (Aufrufe/Tag, beliebteste Seiten). Breite wird per JS - gesetzt (CSSOM, kein Inline-style -> CSP-konform). */ +/* Horizontale Balken (Aufrufe/Tag, Betreiber). Breite setzt React per CSSOM + (kein Inline-style im HTML -> CSP-konform). */ .bars { padding: 0.3rem 0.5rem 0.6rem; } @@ -414,7 +383,7 @@ hr { font-size: 0.95rem; } -/* ASN-Namen sind länger als Datum/Pfad -> mehr Platz, Umbruch statt Ellipse. */ +/* ASN-Namen sind länger als ein Datum -> mehr Platz, Umbruch statt Ellipse. */ #stat-asns .bar-label { flex-basis: 13rem; white-space: normal; diff --git a/frontend/src/auth.ts b/frontend/src/auth.ts index bd15319..dbd5c7e 100644 --- a/frontend/src/auth.ts +++ b/frontend/src/auth.ts @@ -2,7 +2,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { ApiError, api } from "./api"; import type { Me } from "./types"; -export const meKey = ["me"]; +const meKey = ["me"]; // useMe liefert das eigene Konto oder null, wenn niemand angemeldet ist. 401 // ist hier kein Fehler, sondern die Antwort "nicht angemeldet". diff --git a/frontend/src/components/AuthBox.tsx b/frontend/src/components/AuthBox.tsx index c71db62..864b28e 100644 --- a/frontend/src/components/AuthBox.tsx +++ b/frontend/src/components/AuthBox.tsx @@ -2,9 +2,8 @@ import { useState, type FormEvent, type MouseEvent } from "react"; import { api, errMsg } from "../api"; import { useAuthChanged } from "../auth"; -// AuthBox: Login und Registrierung im Wechsel. Die {" "} bilden die -// Leerzeichen des alten Markups nach -- Überschrift, Inputs und Knöpfe sind -// inline, der Abstand zwischen ihnen ist also sichtbar. +// AuthBox: Login und Registrierung im Wechsel. Die {" "} sind Absicht: +// Überschrift, Inputs und Knöpfe sind inline, der Abstand ist sichtbar. export function AuthBox() { const [mode, setMode] = useState<"login" | "register">("login"); const [msg, setMsg] = useState(""); diff --git a/frontend/src/components/Bump.tsx b/frontend/src/components/Bump.tsx index a78e58c..182a587 100644 --- a/frontend/src/components/Bump.tsx +++ b/frontend/src/components/Bump.tsx @@ -8,7 +8,7 @@ const nowSec = () => Math.floor(Date.now() / 1000); // cooldownText ist der Hinweis in der Byline: Restzeit auf Stunden // aufgerundet (mindestens 1 -> nie "0h", solange Restzeit übrig ist). -export function cooldownText(retryAfter: number): string { +function cooldownText(retryAfter: number): string { return ` · Repost in ${Math.max(1, Math.ceil(retryAfter / 3600))}h`; } diff --git a/frontend/src/components/Compose.tsx b/frontend/src/components/Compose.tsx deleted file mode 100644 index 5f23cd6..0000000 --- a/frontend/src/components/Compose.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { useState, type FormEvent } from "react"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { api, errMsg } from "../api"; -import { pasteImage } from "../paste"; -import type { CreatedEntry } from "../types"; - -// Compose: neuer Beitrag. Nach Erfolg springt der Feed zurück auf Seite 0, -// damit der eigene Beitrag oben steht. -export function Compose() { - const qc = useQueryClient(); - const [pasteMsg, setPasteMsg] = useState(""); - const create = useMutation({ - mutationFn: (fd: FormData) => api.post("/entry/create", fd), - }); - - const onSubmit = (e: FormEvent) => { - e.preventDefault(); - const form = e.currentTarget; - create.mutate(new FormData(form), { - onSuccess: () => { - form.reset(); - setPasteMsg(""); - qc.resetQueries({ queryKey: ["feed"] }); - }, - }); - }; - - return ( -
-
{ - if (pasteImage(e)) setPasteMsg("Bild aus Zwischenablage übernommen."); - }} - > - {" "} -

{pasteMsg}

{" "} - {" "} - -
-

{create.isError ? errMsg(create.error, "Beitrag fehlgeschlagen.") : ""}

-
- ); -} diff --git a/frontend/src/components/EntryCard.tsx b/frontend/src/components/EntryCard.tsx index 48cecf4..29acf20 100644 --- a/frontend/src/components/EntryCard.tsx +++ b/frontend/src/components/EntryCard.tsx @@ -14,28 +14,28 @@ function useOpenOnClick(pid: number) { }; } -export function EntryCard({ entry: e }: { entry: Entry }) { - const open = useOpenOnClick(e.pid); - const when = formatTime(e.created_at); - - // Soft-gelöschter Beitrag: nur Platzhalter, damit der Thread navigierbar bleibt. - if (e.deleted) { - return ( -
-
[deleted] · {when}
-
-
-
-
[deleted]
-
-
-
- ); - } - return ; +export function EntryCard({ entry }: { entry: Entry }) { + return entry.deleted ? : ; } -function LiveCard({ entry: e, when, open }: { entry: Entry; when: string; open: (ev: MouseEvent) => void }) { +// Soft-gelöschter Beitrag: nur Platzhalter, damit der Thread navigierbar bleibt. +function DeletedCard({ entry: e }: { entry: Entry }) { + const open = useOpenOnClick(e.pid); + return ( +
+
[deleted] · {formatTime(e.created_at)}
+
+
+
+
[deleted]
+
+
+
+ ); +} + +function LiveCard({ entry: e }: { entry: Entry }) { + const open = useOpenOnClick(e.pid); const bump = useBump(e); const profile = `/u/${encodeURIComponent(e.username)}`; @@ -46,7 +46,7 @@ function LiveCard({ entry: e, when, open }: { entry: Entry; when: string; open: - {e.username} · {when} + {e.username} · {formatTime(e.created_at)} {bump.cooldown} diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index e40ea08..8b91620 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -10,8 +10,8 @@ const maxPage = 100; // Feed lädt Seiten per Infinite-Scroll: queryKey identifiziert den Feed im // Cache (zum Invalidieren nach eigenen Beiträgen), urlFor(page) baut den -// API-Pfad der Seite. as ist das Container-Element wie im alten Markup: -//
auf der Startseite,
innerhalb des Profils. +// API-Pfad der Seite. as ist das Container-Element:
auf der +// Startseite,
innerhalb des Profils. export function Feed({ queryKey, urlFor, as: Container = "section" }: { queryKey: unknown[]; urlFor: (page: number) => string; diff --git a/frontend/src/components/PostForm.tsx b/frontend/src/components/PostForm.tsx new file mode 100644 index 0000000..550ffeb --- /dev/null +++ b/frontend/src/components/PostForm.tsx @@ -0,0 +1,50 @@ +import { useState, type FormEvent } from "react"; +import { useMutation } from "@tanstack/react-query"; +import { api, errMsg } from "../api"; +import { pasteImage } from "../paste"; + +// PostForm verfasst einen Beitrag, mit replyTo eine Antwort darauf. +export function PostForm({ replyTo, rows, placeholder, submitLabel, fallback, onDone }: { + replyTo?: string; + rows: number; + placeholder: string; + submitLabel: string; + fallback: string; + onDone: () => void; +}) { + const [pasteMsg, setPasteMsg] = useState(""); + const create = useMutation({ + mutationFn: (fd: FormData) => api.post("/entry/create", fd), + }); + + const onSubmit = (e: FormEvent) => { + e.preventDefault(); + const form = e.currentTarget; + const fd = new FormData(form); + if (replyTo) fd.append("reply_to", replyTo); + create.mutate(fd, { + onSuccess: () => { + form.reset(); + setPasteMsg(""); + onDone(); + }, + }); + }; + + return ( + <> +
{ + if (pasteImage(e)) setPasteMsg("Bild aus Zwischenablage übernommen."); + }} + > + {" "} +

{pasteMsg}

{" "} + {" "} + +
+

{create.isError ? errMsg(create.error, fallback) : ""}

+ + ); +} diff --git a/frontend/src/format.tsx b/frontend/src/format.tsx index c259bef..5648e04 100644 --- a/frontend/src/format.tsx +++ b/frontend/src/format.tsx @@ -11,9 +11,8 @@ export function formatTime(unix: number): string { const urlPattern = /https?:\/\/[^\s<]+/g; -// linkify macht aus URLs im Text Links und aus Zeilenumbrüchen
. React -// escaped den Text selbst -- anders als im alten Frontend gibt es hier kein -// innerHTML und damit keinen Escaping-Schritt, der vergessen werden könnte. +// linkify macht aus URLs im Text Links und aus Zeilenumbrüchen
. Den Text +// escaped React selbst. export function linkify(text: string): ReactNode[] { const out: ReactNode[] = []; text.split("\n").forEach((line, i) => { diff --git a/frontend/src/pages/EntryPage.tsx b/frontend/src/pages/EntryPage.tsx index 9d8861e..93b026b 100644 --- a/frontend/src/pages/EntryPage.tsx +++ b/frontend/src/pages/EntryPage.tsx @@ -3,9 +3,9 @@ import { useParams } from "react-router"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { api, errMsg } from "../api"; import { useMe } from "../auth"; -import { pasteImage } from "../paste"; import { Discussion, EntryCard } from "../components/EntryCard"; -import type { CreatedEntry, Entry, Thread } from "../types"; +import { PostForm } from "../components/PostForm"; +import type { Entry, Thread } from "../types"; // Beim Wechsel zu einem anderen Beitrag frisch anfangen (offene Bearbeitung, // Meldungen) -- deshalb hängt die Seite per key an der pid. @@ -51,7 +51,14 @@ function ThreadView({ pid }: { pid: string }) {
@@ -63,8 +70,6 @@ function ThreadView({ pid }: { pid: string }) { } // OwnerActions: Bearbeiten und Löschen für den eigenen, nicht gelöschten Beitrag. -// Die {" "} bilden die Leerzeichen des alten Markups nach -- Inputs und Knöpfe -// sind inline, der Abstand zwischen ihnen ist also sichtbar. function OwnerActions({ entry, onChange }: { entry: Entry; onChange: () => void }) { const [editing, setEditing] = useState(false); @@ -111,43 +116,3 @@ function OwnerActions({ entry, onChange }: { entry: Entry; onChange: () => void ); } - -function ReplyForm({ pid, onDone }: { pid: string; onDone: () => void }) { - const [pasteMsg, setPasteMsg] = useState(""); - - const reply = useMutation({ - mutationFn: (fd: FormData) => api.post("/entry/create", fd), - }); - - const onSubmit = (e: FormEvent) => { - e.preventDefault(); - const form = e.currentTarget; - const fd = new FormData(form); - fd.append("reply_to", pid); - reply.mutate(fd, { - onSuccess: () => { - form.reset(); - setPasteMsg(""); - onDone(); - }, - }); - }; - - return ( - <> -
{ - if (pasteImage(e)) setPasteMsg("Bild aus Zwischenablage übernommen."); - }} - > - {" "} -

{pasteMsg}

{" "} - {" "} - -
-

{reply.isError ? errMsg(reply.error, "Antwort fehlgeschlagen.") : ""}

- - ); -} diff --git a/frontend/src/pages/FeedPage.tsx b/frontend/src/pages/FeedPage.tsx index ece080c..c020d9f 100644 --- a/frontend/src/pages/FeedPage.tsx +++ b/frontend/src/pages/FeedPage.tsx @@ -1,7 +1,8 @@ +import { useQueryClient } from "@tanstack/react-query"; import { useMe } from "../auth"; import { AuthBox } from "../components/AuthBox"; -import { Compose } from "../components/Compose"; import { Feed } from "../components/Feed"; +import { PostForm } from "../components/PostForm"; // Solange der Anmeldestatus noch lädt, weder Login noch Formular zeigen -- // sonst blitzt bei Angemeldeten kurz die Login-Box auf. @@ -14,3 +15,20 @@ export function FeedPage() { ); } + +// Nach einem neuen Beitrag springt der Feed zurück auf Seite 0, damit er oben +// steht. +function Compose() { + const qc = useQueryClient(); + return ( +
+ qc.resetQueries({ queryKey: ["feed"] })} + /> +
+ ); +} diff --git a/frontend/src/pages/UserPage.tsx b/frontend/src/pages/UserPage.tsx index 3ce5187..2200262 100644 --- a/frontend/src/pages/UserPage.tsx +++ b/frontend/src/pages/UserPage.tsx @@ -36,7 +36,7 @@ function ProfileView({ username }: { username: string }) {
@@ -74,8 +74,7 @@ function useFormAction(path: string, fallback: string, onSuccess: (form: HTML return { onSubmit, busy: m.isPending, msg: m.isError ? errMsg(m.error, fallback) : "" }; } -// Settings: nur für den Eigentümer. Die {" "} bilden die Leerzeichen des alten -// Markups nach -- Inputs und Knöpfe sind inline. +// Settings: nur für den Eigentümer. function Settings({ onAvatar }: { onAvatar: () => void }) { const qc = useQueryClient(); const navigate = useNavigate(); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index d8cc697..66108e0 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -43,13 +43,6 @@ export interface BumpResult { retry_after: number; } -export interface CreatedEntry { - pid: number; - content: string; - filepath: string; - reply_to: number; -} - // GET /api/user/info -- das eigene Konto. export interface Me { uid: number; diff --git a/go.mod b/go.mod index d445e92..55f7f2c 100644 --- a/go.mod +++ b/go.mod @@ -9,24 +9,15 @@ require ( github.com/oschwald/maxminddb-golang v1.13.1 golang.org/x/crypto v0.52.0 golang.org/x/image v0.41.0 - modernc.org/sqlite v1.59.0 ) require ( - github.com/dustin/go-humanize v1.0.1 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect - github.com/mattn/go-isatty v0.0.24 // indirect - github.com/ncruces/go-strftime v1.0.0 // indirect - github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/zeebo/xxh3 v1.0.2 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.37.0 // indirect - modernc.org/libc v1.75.7 // indirect - modernc.org/mathutil v1.7.1 // indirect - modernc.org/memory v1.12.1 // indirect ) diff --git a/go.sum b/go.sum index bdb34d1..825683d 100644 --- a/go.sum +++ b/go.sum @@ -1,18 +1,10 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g= github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4= -github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= -github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= -github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -23,16 +15,10 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= -github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= -github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= -github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= -github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE= github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -46,45 +32,13 @@ golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo= golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= -golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= -golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= -golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= -golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8= -modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= -modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w= -modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I= -modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= -modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= -modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= -modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= -modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc= -modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= -modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= -modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.75.7 h1:o3DTP9/0p9pKmY2WCKQaySW6wIiZhNM7wc2lUoyhfew= -modernc.org/libc v1.75.7/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ= -modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= -modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= -modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g= -modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= -modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= -modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= -modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.59.0 h1:X1es1GpqBlS/5T+vbM4HLUdaa8OtQx468DF2vrx+38A= -modernc.org/sqlite v1.59.0/go.mod h1:+paeT2A3iPRHkQDwG7oA6Tk0zQd5woMEI8q7orfry8k= -modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= -modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= -modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= -modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/handler.go b/handler.go index 53ada73..da88b47 100644 --- a/handler.go +++ b/handler.go @@ -3,10 +3,8 @@ package main import "net/http" // handlerFunc ist die Handler-Signatur dieser App: wie http.HandlerFunc, aber -// mit Fehler-Rückgabe. Der Gewinn ist nicht nur Kürze -- die alte Form -// (writeError(...); return) machte ein vergessenes return zu einem stillen Bug: -// der Handler lief weiter und schrieb eine zweite Antwort in denselben -// ResponseWriter. Mit "return err" kann das strukturell nicht mehr passieren. +// mit Fehler-Rückgabe. "return err" beendet den Handler zwingend -- ein +// vergessenes return nach dem Fehlerschreiben kann es so nicht geben. type handlerFunc func(http.ResponseWriter, *http.Request) error // api hängt einen fehlerliefernden Handler in chi ein; der Fehler geht als diff --git a/importsqlite.go b/importsqlite.go deleted file mode 100644 index 87bbab9..0000000 --- a/importsqlite.go +++ /dev/null @@ -1,134 +0,0 @@ -package main - -import ( - "database/sql" - "fmt" - "strings" - - _ "modernc.org/sqlite" -) - -// importTable beschreibt eine Tabelle für den einmaligen Umzug SQLite -> Postgres. -// Die Spalten heißen in beiden Datenbanken gleich; nur user heißt jetzt account. -type importTable struct { - src, dst string - cols []string - identity bool // hat eine id-Identity-Spalte, deren Sequenz nachgezogen werden muss -} - -var importTables = []importTable{ - {"user", "account", []string{"id", "uid", "username", "password", "created_at", "last_login", "avatar"}, true}, - {"session", "session", []string{"id", "uid", "value", "created_at", "expires", "description"}, true}, - {"entry", "entry", []string{"id", "pid", "uid", "created_at", "content", "filepath", "reply_to", - "reply_count", "last_activity", "deleted", "bump_count", "last_bump"}, true}, - {"vote", "vote", []string{"id", "uid", "pid", "mode"}, true}, - {"impression", "impression", []string{"day", "anon_ip", "path", "asn", "hits"}, false}, -} - -// importSQLite kopiert alle Daten aus der SQLite-Datei path in die (per initDB -// bereits verbundene und mit Schema versehene) Postgres-Datenbank. Alles in einer -// Transaktion: entweder ist der Umzug vollständig oder es ist nichts passiert. -// Bricht ab, wenn Zieltabellen schon Daten enthalten -- ein zweiter Lauf soll -// nichts doppeln. -func importSQLite(path string) error { - src, err := sql.Open("sqlite", "file:"+path+"?mode=ro&immutable=1") - if err != nil { - return err - } - defer src.Close() - if err := src.Ping(); err != nil { - return fmt.Errorf("sqlite öffnen: %w", err) - } - var check string - if err := src.QueryRow(`PRAGMA integrity_check`).Scan(&check); err != nil || check != "ok" { - return fmt.Errorf("sqlite integrity_check: %q %v", check, err) - } - - for _, t := range importTables { - var n int64 - if err := db.QueryRow(`SELECT COUNT(*) FROM ` + t.dst).Scan(&n); err != nil { - return err - } - if n > 0 { - return fmt.Errorf("zieltabelle %s ist nicht leer (%d zeilen) -- abbruch", t.dst, n) - } - } - - tx, err := db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - - for _, t := range importTables { - var exists int - src.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?`, t.src).Scan(&exists) - if exists == 0 { - fmt.Printf("%-10s -> %-10s fehlt in sqlite, übersprungen\n", t.src, t.dst) - continue - } - - n, err := copyTable(src, tx, t) - if err != nil { - return fmt.Errorf("%s: %w", t.src, err) - } - - var got int64 - if err := tx.QueryRow(`SELECT COUNT(*) FROM ` + t.dst).Scan(&got); err != nil { - return err - } - if got != n { - return fmt.Errorf("%s: %d gelesen, aber %d in postgres", t.src, n, got) - } - if t.identity { - if _, err := tx.Exec(fmt.Sprintf( - `SELECT setval(pg_get_serial_sequence('%s', 'id'), COALESCE(MAX(id), 0) + 1, false) FROM %s`, - t.dst, t.dst)); err != nil { - return fmt.Errorf("%s: sequenz: %w", t.dst, err) - } - } - fmt.Printf("%-10s -> %-10s %6d zeilen\n", t.src, t.dst, n) - } - return tx.Commit() -} - -func copyTable(src *sql.DB, tx *sql.Tx, t importTable) (int64, error) { - cols := strings.Join(t.cols, ", ") - rows, err := src.Query(`SELECT ` + cols + ` FROM "` + t.src + `"`) - if err != nil { - return 0, err - } - defer rows.Close() - - ph := make([]string, len(t.cols)) - for i := range ph { - ph[i] = fmt.Sprintf("$%d", i+1) - } - override := "" - if t.identity { - override = " OVERRIDING SYSTEM VALUE" - } - stmt, err := tx.Prepare(`INSERT INTO ` + t.dst + ` (` + cols + `)` + override + - ` VALUES (` + strings.Join(ph, ", ") + `)`) - if err != nil { - return 0, err - } - defer stmt.Close() - - var n int64 - vals := make([]any, len(t.cols)) - ptrs := make([]any, len(t.cols)) - for i := range vals { - ptrs[i] = &vals[i] - } - for rows.Next() { - if err := rows.Scan(ptrs...); err != nil { - return n, err - } - if _, err := stmt.Exec(vals...); err != nil { - return n, fmt.Errorf("zeile %d: %w", n+1, err) - } - n++ - } - return n, rows.Err() -} diff --git a/main.go b/main.go index 01bade2..2503763 100644 --- a/main.go +++ b/main.go @@ -37,15 +37,6 @@ func main() { } defer db.Close() - // Einmaliger Umzug: kver import-sqlite /pfad/zur/kver.db - if len(os.Args) == 3 && os.Args[1] == "import-sqlite" { - if err := importSQLite(os.Args[2]); err != nil { - log.Fatalf("import: %v", err) - } - log.Print("import abgeschlossen") - return - } - // Optionale GeoLite2-ASN-Datenbank für die netzwerktopologische Einordnung // der Impressions. Inaktiv, wenn KVER_GEOIP_ASN nicht gesetzt ist. initGeoASN() @@ -69,7 +60,7 @@ func main() { } // Graceful Shutdown: auf SIGINT/SIGTERM laufende Requests zu Ende bedienen, - // dann die DB sauber schließen (WAL-Checkpoint) statt hart abzubrechen. + // dann über die defers Worker und DB sauber schließen. idle := make(chan struct{}) go func() { sig := make(chan os.Signal, 1) diff --git a/migrate.sh b/migrate.sh deleted file mode 100755 index 9085496..0000000 --- a/migrate.sh +++ /dev/null @@ -1,196 +0,0 @@ -#!/usr/bin/env bash -# -# migrate.sh — liest kver-original.db und erzeugt daraus eine frische kver.db -# mit dem aktuellen Go-Schema. Die Quelldatenbank wird nie verändert. -# Idempotent: kver.db wird bei jedem Lauf neu erstellt. -# -# Verwendung: -# ./migrate.sh [quelldatei [zieldatei]] # Standard: kver-original.db -> kver.db -# -# Schalter (als Umgebungsvariablen): -# REPLIES=keep|drop Antworten (reply_to != 0) übernehmen oder verwerfen (Default: keep) -# -set -euo pipefail - -SRC="${1:-original-kver.db}" -DST="${2:-kver.db}" -REPLIES="${REPLIES:-keep}" - -if [[ ! -f "$SRC" ]]; then - echo "FEHLER: Quelldatenbank '$SRC' nicht gefunden." >&2 - exit 1 -fi -if [[ "$REPLIES" != "keep" && "$REPLIES" != "drop" ]]; then - echo "FEHLER: REPLIES muss 'keep' oder 'drop' sein (war: '$REPLIES')." >&2 - exit 1 -fi -if [[ "$SRC" -ef "$DST" ]]; then - echo "FEHLER: Quelle und Ziel sind dieselbe Datei." >&2 - exit 1 -fi - -qs() { sqlite3 "$SRC" "$1"; } -qd() { sqlite3 "$DST" "$1"; } - -echo "== kver-Migration ==" -echo "Quelle: $SRC" -echo "Ziel: $DST" -echo "REPLIES: $REPLIES" -echo - -echo "Quelle:" -echo " Einträge gesamt: $(qs 'SELECT COUNT(*) FROM entry;')" -echo " davon deleted=1: $(qs 'SELECT COUNT(*) FROM entry WHERE deleted=1;')" -echo " davon Antworten: $(qs 'SELECT COUNT(*) FROM entry WHERE reply_to!=0;')" -echo " User: $(qs 'SELECT COUNT(*) FROM user;')" - -# Zieldatenbank immer frisch anlegen -rm -f "$DST" -echo -echo "Erstelle $DST mit aktuellem Schema..." - -sqlite3 "$DST" <<'SCHEMA' -.bail on -CREATE TABLE user ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - uid INTEGER UNIQUE, - username TEXT UNIQUE, - password BLOB, - created_at INTEGER, - last_login INTEGER, - avatar TEXT NOT NULL DEFAULT '' -); -CREATE TABLE session ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - uid INTEGER, - value TEXT UNIQUE, - created_at INTEGER, - expires INTEGER, - description TEXT -); -CREATE TABLE entry ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - pid INTEGER UNIQUE NOT NULL, - uid INTEGER NOT NULL, - created_at INTEGER NOT NULL, - content TEXT NOT NULL DEFAULT '', - filepath TEXT NOT NULL DEFAULT '', - reply_to INTEGER NOT NULL DEFAULT 0, - reply_count INTEGER NOT NULL DEFAULT 0, - last_activity INTEGER NOT NULL DEFAULT 0, - deleted INTEGER NOT NULL DEFAULT 0 -); -CREATE TABLE vote ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - uid INTEGER, - pid INTEGER, - mode TEXT, - UNIQUE(uid, pid) -); -SCHEMA - -# Prüfen, welche optionalen Spalten in der Quelle vorhanden sind. -# Ältere Python-DBs haben reply_count und last_activity noch nicht. -has_col() { - sqlite3 "$SRC" "SELECT COUNT(*) FROM pragma_table_info('$1') WHERE name='$2';" -} - -reply_count_expr="0" -[[ "$(has_col entry reply_count)" == "1" ]] && reply_count_expr="COALESCE(e.reply_count, 0)" - -last_activity_expr="e.created_at" -[[ "$(has_col entry last_activity)" == "1" ]] && last_activity_expr="COALESCE(e.last_activity, e.created_at)" - -# Profilbild: alte DBs haben die Spalte nicht -> leerer String. -avatar_expr="''" -[[ "$(has_col user avatar)" == "1" ]] && avatar_expr="COALESCE(u.avatar, '')" - -replies_filter="" -[[ "$REPLIES" == "drop" ]] && replies_filter="AND e.reply_to = 0" - -has_vote_table=$(qs "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='vote';") - -# Quell-Pfad für SQL-String-Literal escapen (einfache Anführungszeichen verdoppeln) -src_sql="${SRC//\'/\'\'}" - -sqlite3 "$DST" < '' (Go-Zero-Value; scanEntries erwartet string, nicht *string) --- content NULL -> '' --- deleted: nur nicht-gelöschte Einträge werden übernommen --- orphans: Einträge ohne bekannten User werden verworfen -INSERT INTO entry (pid, uid, created_at, content, filepath, reply_to, reply_count, last_activity, deleted) -SELECT - e.pid, - e.uid, - e.created_at, - COALESCE(e.content, ''), - COALESCE(NULLIF(e.filepath, 'none'), ''), - COALESCE(e.reply_to, 0), - $reply_count_expr, - $last_activity_expr, - 0 -FROM src.entry e -WHERE e.deleted = 0 - AND e.uid IN (SELECT uid FROM src.user) - $replies_filter; - --- Absolute /srv/kver/-Pfade aus der alten Python-Prod-Umgebung normalisieren. -UPDATE entry SET filepath = REPLACE(filepath, '/srv/kver/', '') WHERE filepath LIKE '/srv/kver/%'; - --- Sessions leer lassen: Alt-Tokens sind Integer (Python-Format), --- Go erzeugt crypto/rand-Hex-Tokens -> alle Alt-Sessions sind wertlos. - -COMMIT; -DETACH src; -VACUUM; -SQL - -# Votes separat, da die Tabelle in alten DBs fehlen kann -if [[ "$has_vote_table" == "1" ]]; then - sqlite3 "$DST" < static/media/ aus der Produktion kopieren, sonst liefern diese Bilder 404." -fi - -echo -echo "Fertig." diff --git a/notes/deploy.md b/notes/deploy.md index 844af7b..ebb7c89 100644 --- a/notes/deploy.md +++ b/notes/deploy.md @@ -95,23 +95,6 @@ REVOKE CONNECT, TEMPORARY ON DATABASE kontrollverlust FROM PUBLIC; Tabellen und Indizes legt die App beim Start selbst an (`CREATE ... IF NOT EXISTS`, als `kver` → gehören `kver`). Nicht als Admin anlegen. -## Umzug SQLite → Postgres (einmalig) - -Dienst stoppen, dann mit dem neuen Image die alte Datei aus dem Volume -importieren. Der Import läuft in einer Transaktion, prüft die Zeilenzahlen und -bricht ab, wenn in Postgres schon Daten liegen: - -```sh -systemctl --user stop kver -podman run --rm --env-file /etc/kver/kver.env \ - -v kver-data:/app/data:ro \ - kver import-sqlite /app/data/kver.db -systemctl --user start kver -``` - -Der Container braucht dabei (und im Betrieb) dieselbe Netzwerkanbindung an -Postgres wie der Dienst selbst. - ## Bestehende Medien übernehmen Medien einmalig ins Volume kopieren (Volumes müssen existieren; diff --git a/notes/migrations.md b/notes/migrations.md deleted file mode 100644 index e2a203e..0000000 --- a/notes/migrations.md +++ /dev/null @@ -1,120 +0,0 @@ -# Datenbank-Migrationen - -Manuelle Schritte, die beim Deploy gegen eine **bestehende** Datenbank nötig sind. -Eine frische DB (Schema aus `db.go`) braucht keine davon. - ---- - -## `avatar`-Spalte in `user` - -**Wann:** beim Deploy der Profilbild-Version gegen eine bestehende `kver.db`. - -**Hintergrund:** Profilbilder werden als Pfad (`static/media/...`) in der neuen Spalte -`user.avatar` gespeichert (leerer String = kein Bild, Frontend zeigt dann das -Platzhalterbild). `CREATE TABLE IF NOT EXISTS` ergänzt die Spalte bei einer -vorhandenen Tabelle nicht. - -**Migration:** -```sql -ALTER TABLE user ADD COLUMN avatar TEXT NOT NULL DEFAULT ''; -``` - -Bestehende Nutzer haben damit kein Bild (`''`). Nur einmalig ausführen. - ---- - -## `filepath` NULL → leerer String - -**Wann:** beim Umstieg auf die Go-Version, falls `entry.filepath` NULL-Werte enthält. - -**Hintergrund:** `scanEntries` scannt `filepath` in ein Go-`string`-Feld (kein Pointer). -SQLite NULL in ein `string` scannen schlägt fehl; `scanEntries` überspringt diese Zeilen -still mit `continue`. Ergebnis: Einträge ohne Bild fehlen im Feed komplett. -Das aktuelle Go-Schema definiert `filepath TEXT NOT NULL DEFAULT ''`. `migrate.sh` -normalisiert NULL → `''` beim Kopieren aus der Quelldatenbank. - -```sql -UPDATE entry SET filepath = '' WHERE filepath IS NULL; -``` - ---- - -## `filepath`-Sentinel `"none"` → leerer String - -**Wann:** beim Umstieg auf die Go-Version, falls eine alte `kver.db` aus der -Python-Zeit weiterverwendet wird. - -**Hintergrund:** Die Python-Version schrieb für Beiträge ohne Bild den -String `"none"` in `entry.filepath`. Die Go-Version nutzt stattdessen den -leeren String (Go-Zero-Value) und prüft nur noch auf `filepath == ""`. -Alte `"none"`-Zeilen würden sonst im Feed als Bildpfad interpretiert -(`` → kaputtes Bild). - -**Migration:** -```sql -UPDATE entry SET filepath = '' WHERE filepath = 'none'; -``` - -Idempotent — kann gefahrlos mehrfach laufen. - ---- - -## Threading-Spalten in `entry` - -**Wann:** beim Deploy der Threading-Version gegen eine bestehende `kver.db`, die -die Tabelle `entry` noch ohne die Threading-Spalten hat. - -**Hintergrund:** Das Threading führt `reply_to` (pid des Eltern-Posts, `0` = Root), -`reply_count` (Anzahl Antworten im gesamten Teilbaum) und `last_activity` -(Sortierschlüssel des Hauptfeeds) ein. `CREATE TABLE IF NOT EXISTS` ergänzt diese -Spalten bei einer vorhandenen Tabelle nicht. - -**Migration:** -```sql -ALTER TABLE entry ADD COLUMN reply_to INTEGER NOT NULL DEFAULT 0; -ALTER TABLE entry ADD COLUMN reply_count INTEGER NOT NULL DEFAULT 0; -ALTER TABLE entry ADD COLUMN last_activity INTEGER; -UPDATE entry SET last_activity = created_at WHERE last_activity IS NULL; -``` - -Bestehende Zeilen werden so zu Root-Beiträgen (`reply_to = 0`, `reply_count = 0`) -mit `last_activity = created_at`. Nicht idempotent — die `ALTER TABLE`-Zeilen nur -einmalig ausführen. - ---- - -## Soft-Delete-Spalte in `entry` - -**Wann:** beim Deploy der Soft-Delete-Version gegen eine bestehende `kver.db`. - -**Hintergrund:** Statt Beiträge hart zu löschen werden sie als `[deleted]`-Platzhalter -markiert (`deleted = 1`, Inhalt/Bild/Autor geleert), damit Antworten nicht verwaisen. -Das Frontend und die Feeds erkennen gelöschte Beiträge an dieser Spalte. - -**Migration:** -```sql -ALTER TABLE entry ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0; -``` - -Bestehende Beiträge bleiben damit sichtbar (`deleted = 0`). Nur einmalig ausführen. - ---- - -## `asn`-Spalte in `impression` - -**Wann:** beim Deploy der ASN-Auswertung gegen eine bestehende `impression`-Tabelle. - -**Hintergrund:** Pro Impression wird das autonome System (Netzbetreiber) gespeichert, -aufgelöst aus der vollen Client-IP *vor* dem Maskieren (siehe `lookupASN`). Die Spalte -liegt bewusst **nicht** im Primärschlüssel — pro `anon_ip` ist das ASN deterministisch, -also entstehen keine Zeilen-Duplikate, und ein simples `ADD COLUMN` genügt (kein -Tabellen-Rebuild). `CREATE TABLE IF NOT EXISTS` ergänzt die Spalte bei einer -vorhandenen Tabelle nicht. - -**Migration:** -```sql -ALTER TABLE impression ADD COLUMN asn TEXT NOT NULL DEFAULT ''; -``` - -Bestehende Zeilen haben damit kein ASN (`''`, in der Auswertung „(unbekannt)"). Nur -einmalig ausführen. diff --git a/static/docs/beitragsordnung.html b/static/docs/beitragsordnung.html index 292c299..4d76400 100644 --- a/static/docs/beitragsordnung.html +++ b/static/docs/beitragsordnung.html @@ -12,7 +12,7 @@ Impressum - Datenschutz - Beitragsordnung - - Statistik + Statistik

Kontrollverlust