Compare commits

...
3 Commits
Author SHA1 Message Date
irrlichtandClaude Opus 5.5 2bae608d76 Sigillen der Beitragsordnung wieder auf 50px (#sigils zurück)
Die Regel war im Code-Review fälschlich als tot entfernt worden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
2026-09-27 15:17:33 +02:00
irrlichtandClaude Opus 5.5 43c640dee6 Rechtstexte wieder gestylt: als Vite-Einstiege mit app.css gebaut
Die Seiten verlinkten /css/app.css, das es seit dem React-Umbau nicht mehr
gibt. Jetzt liegen sie unter frontend/static/docs, Vite baut sie mit und
schreibt den Link auf das gehashte app.css um; serveDoc liefert sie unter
den bisherigen Adressen /static/docs/… aus web/ aus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
2026-09-27 15:14:53 +02:00
irrlichtandClaude Opus 5.5 df0cfec132 Code-Review: tote Stellen entfernt, Umwege vereinfacht
- SQLite-Import, migrate.sh, notes/migrations.md und altes jQuery-Skript entfernt
- Backend: scanEntry/queryEntries statt doppelter Scan-Liste, Feed-Queries
  direkt statt feedWhere, Thread ohne Map-Umweg, gemeinsame Helfer für
  Cookie-Löschen und Mediennamen, usernameTaken
- Frontend: PostForm für Beitrag und Antwort, EntryCard in Deleted/LiveCard
  aufgeteilt, tote CSS-Regeln und Typen entfernt, Strich-Regeln zusammengelegt
- Rechtstexte verlinken /stats; Kommentare ohne Vorgeschichte

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
2026-09-27 15:03:22 +02:00
36 changed files with 265 additions and 923 deletions
-1
View File
@@ -12,7 +12,6 @@ kver
*.db.bak *.db.bak
*.env *.env
static/media/ static/media/
migrate.sh
Containerfile Containerfile
.containerignore .containerignore
frontend/node_modules/ frontend/node_modules/
+2 -4
View File
@@ -8,8 +8,7 @@ RUN npm ci
COPY frontend/ ./ COPY frontend/ ./
RUN npm run build RUN npm run build
# Build-Stage: statisches Binary, CGO aus (pgx und modernc.org/sqlite für den # Build-Stage: statisches Binary, CGO aus (pgx ist pures Go).
# einmaligen Import sind pures Go).
FROM docker.io/library/golang:1.26-alpine AS build FROM docker.io/library/golang:1.26-alpine AS build
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
@@ -24,8 +23,7 @@ WORKDIR /app
COPY --from=build /kver /usr/local/bin/kver COPY --from=build /kver /usr/local/bin/kver
COPY --from=frontend /src/web web/ COPY --from=frontend /src/web web/
COPY static/ static/ COPY static/ static/
# data: GeoLite2-ASN-DB (und die alte SQLite-DB für den Import); # data: GeoLite2-ASN-DB; static/media: Uploads. Beide als Volume persistieren.
# static/media: Uploads. Beide als Volume persistieren.
RUN mkdir -p data static/media && chown kver:kver data static/media RUN mkdir -p data static/media && chown kver:kver data static/media
USER kver USER kver
ENV KVER_ADDR=:8080 ENV KVER_ADDR=:8080
+8 -11
View File
@@ -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") 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 uid int64
var hash []byte var hash []byte
err := db.QueryRow(`SELECT uid, password FROM account WHERE username = $1`, username).Scan(&uid, &hash) 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) 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{ http.SetCookie(w, &http.Cookie{
Name: "session", Name: "session",
Value: "", Value: "",
@@ -152,8 +156,6 @@ func handleLogout(w http.ResponseWriter, r *http.Request) error {
Secure: isHTTPS(r), Secure: isHTTPS(r),
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
}) })
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
return nil
} }
// handleNewUser sammelt alle Validierungsfehler ein, statt beim ersten // 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")) username := sanitizeUsername(r.FormValue("user"))
if len(username) < 3 { if len(username) < 3 {
fields["user"] = "Der Nutzername ist zu kurz." fields["user"] = "Der Nutzername ist zu kurz."
} else if idFromUsername(username) != 0 { } else if usernameTaken(username) {
fields["user"] = "Der Nutzername wird bereits verwendet." 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 { func sanitizeUsername(u string) string {
u = strings.TrimSpace(u) u = truncateRunes(strings.TrimSpace(u), 32)
// 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 = reNonWord.ReplaceAllString(u, "") u = reNonWord.ReplaceAllString(u, "")
u = reSpace.ReplaceAllString(u, "_") u = reSpace.ReplaceAllString(u, "_")
return u return u
+68 -122
View File
@@ -1,7 +1,6 @@
package main package main
import ( import (
"database/sql"
"errors" "errors"
"fmt" "fmt"
"image" "image"
@@ -56,15 +55,26 @@ const entrySelect = `
COALESCE(u.username, ''), COALESCE(u.avatar, '') COALESCE(u.username, ''), COALESCE(u.avatar, '')
FROM entry e LEFT JOIN account u ON u.uid = e.uid` FROM entry e LEFT JOIN account u ON u.uid = e.uid`
// scanEntries liest Entry-Zeilen aus einem Query mit entrySelect-Spalten. // scanEntry liest eine Zeile mit entrySelect-Spalten (aus *sql.Row oder *sql.Rows).
func scanEntries(rows *sql.Rows) ([]Entry, error) { 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() defer rows.Close()
entries := []Entry{} entries := []Entry{}
for rows.Next() { for rows.Next() {
var e Entry e, err := scanEntry(rows)
if err := rows.Scan(&e.PID, &e.UID, &e.CreatedAt, &e.Content, &e.Filepath, if err != nil {
&e.ReplyTo, &e.ReplyCount, &e.LastActivity, &e.Deleted,
&e.BumpCount, &e.LastBump, &e.Username, &e.Avatar); err != nil {
return nil, err return nil, err
} }
entries = append(entries, e) entries = append(entries, e)
@@ -74,12 +84,7 @@ func scanEntries(rows *sql.Rows) ([]Entry, error) {
// entryByPID liefert einen einzelnen Beitrag oder sql.ErrNoRows. // entryByPID liefert einen einzelnen Beitrag oder sql.ErrNoRows.
func entryByPID(pid int64) (Entry, error) { func entryByPID(pid int64) (Entry, error) {
var e Entry return scanEntry(db.QueryRow(entrySelect+` WHERE e.pid = $1`, pid))
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
} }
const ( const (
@@ -89,49 +94,14 @@ const (
maxFeedPage = 100 maxFeedPage = 100
) )
// feedWhere baut die Filterbedingung für Feed-Abfragen. // feedPage liefert Seite page (Routenparameter {page}) eines Feed-Queries mit
func feedWhere(uid int64, rootsOnly bool) (string, []any) { // entrySelect-Spalten; LIMIT und OFFSET werden hinter args angehängt.
args := []any{} func feedPage(r *http.Request, query string, args ...any) ([]Entry, error) {
where := []string{} page, _ := strconv.Atoi(chi.URLParam(r, "page"))
if uid != 0 { page = min(max(page, 0), maxFeedPage)
args = append(args, uid) n := len(args)
where = append(where, fmt.Sprintf("e.uid = $%d", len(args))) query += fmt.Sprintf(` LIMIT $%d OFFSET $%d`, n+1, n+2)
} return queryEntries(query, append(args, feedPageSize, page*feedPageSize)...)
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)
} }
// attachLatest hängt an jeden Beitrag den jüngsten nicht gelöschten Beitrag // 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 // 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 // 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 { func attachLatest(entries []Entry) error {
if len(entries) == 0 { if len(entries) == 0 {
return nil return nil
@@ -187,11 +157,7 @@ func attachLatest(entries []Entry) error {
return nil return nil
} }
rows, err = db.Query(entrySelect+` WHERE e.pid = ANY($1)`, latestPIDs) latest, err := queryEntries(entrySelect+` WHERE e.pid = ANY($1)`, latestPIDs)
if err != nil {
return err
}
latest, err := scanEntries(rows)
if err != nil { if err != nil {
return err return err
} }
@@ -209,9 +175,10 @@ func attachLatest(entries []Entry) error {
return nil 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 { func handleFeed(w http.ResponseWriter, r *http.Request) error {
page, _ := strconv.Atoi(chi.URLParam(r, "page")) entries, err := feedPage(r, entrySelect+` WHERE e.reply_to = 0 ORDER BY e.last_activity DESC`)
entries, err := feedPage(page, 0, true)
if err != nil { if err != nil {
return Internal(err) return Internal(err)
} }
@@ -222,15 +189,15 @@ func handleFeed(w http.ResponseWriter, r *http.Request) error {
return nil 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 { func handleUserFeed(w http.ResponseWriter, r *http.Request) error {
var uid int64 var uid int64
if err := db.QueryRow(`SELECT uid FROM account WHERE username = $1`, chi.URLParam(r, "username")).Scan(&uid); err != nil { if err := db.QueryRow(`SELECT uid FROM account WHERE username = $1`, chi.URLParam(r, "username")).Scan(&uid); err != nil {
return errUserNotFound return errUserNotFound
} }
page, _ := strconv.Atoi(chi.URLParam(r, "page")) entries, err := feedPage(r, entrySelect+` WHERE e.uid = $1 ORDER BY e.created_at DESC`, uid)
entries, err := feedPage(page, uid, false)
if err != nil { if err != nil {
return Internal(err) return Internal(err)
} }
@@ -253,49 +220,30 @@ func pidParam(r *http.Request) (int64, error) {
return pid, nil return pid, nil
} }
// handleThread liefert einen Beitrag mit seiner Ahnenkette (Root zuerst) und den // thread ist ein Beitrag mit seinem Kontext: Ahnenkette (Root zuerst) und
// direkten Antworten samt deren jüngstem Nachfahren (öffentlich). // 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 { func handleThread(w http.ResponseWriter, r *http.Request) error {
pid, err := pidParam(r) pid, err := pidParam(r)
if err != nil { if err != nil {
return err 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) entry, err := entryByPID(pid)
if err != nil { 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 // depth zählt vom Beitrag aufwärts; ORDER BY depth DESC dreht die Kette
// 50 Einzelqueries. depth zählt vom Beitrag aufwärts; ORDER BY depth DESC // auf Root-zuerst. Das Limit deckelt die Kettenlänge und bricht auch einen
// dreht die Kette auf Root-zuerst. Das Limit deckelt die Kettenlänge und // (theoretischen) Zyklus ab.
// bricht auch einen (theoretischen) Zyklus ab.
if entry.ReplyTo != 0 { if entry.ReplyTo != 0 {
rows, err := db.Query( t.Ancestors, err = queryEntries(
`WITH RECURSIVE chain(pid, depth) AS ( `WITH RECURSIVE chain(pid, depth) AS (
SELECT $1::bigint, 1 SELECT $1::bigint, 1
UNION ALL UNION ALL
@@ -306,24 +254,20 @@ func threadData(pid int64) (thread, error) {
JOIN chain c ON c.pid = e.pid JOIN chain c ON c.pid = e.pid
ORDER BY c.depth DESC`, entry.ReplyTo) ORDER BY c.depth DESC`, entry.ReplyTo)
if err != nil { if err != nil {
return thread{}, Internal(err) return Internal(err)
}
if t.Ancestors, err = scanEntries(rows); err != nil {
return thread{}, 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 { if err != nil {
return thread{}, Internal(err) return Internal(err)
}
if t.Replies, err = scanEntries(rows); err != nil {
return thread{}, Internal(err)
} }
if err := attachLatest(t.Replies); err != nil { 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 { 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 // handleBump hebt last_activity eines Beitrags auf jetzt -> er steigt im Feed
// (Roots, sortiert nach last_activity) bzw. in der Antwortliste eines Threads // (Roots, sortiert nach last_activity) bzw. in der Antwortliste eines Threads
// wieder nach oben. Tritt an die Stelle der früheren "Bump"-Kommentare. Gegen // wieder nach oben. Gegen Spam wächst der Cooldown linear mit der Zahl
// Spam wächst der Cooldown linear mit der Zahl bisheriger Bumps (siehe bumpStep); // bisheriger Bumps (siehe bumpStep); er sitzt am Beitrag selbst (global), nicht
// er sitzt am Beitrag selbst (global), nicht am bumpenden Nutzer. Jeder // am bumpenden Nutzer.
// Eingeloggte darf bumpen (Route in der requireAuth-Gruppe).
func handleBump(w http.ResponseWriter, r *http.Request) error { func handleBump(w http.ResponseWriter, r *http.Request) error {
pid, err := pidParam(r) pid, err := pidParam(r)
if err != nil { if err != nil {
@@ -539,9 +482,8 @@ func handleBump(w http.ResponseWriter, r *http.Request) error {
if remaining < 0 { if remaining < 0 {
remaining = 0 remaining = 0
} }
// Bewusst kein Taxonomie-Fehler -- die Antwort trägt // Kein Taxonomie-Fehler: die Antwort trägt neben dem Fehler Nutzdaten
// Nutzdaten (retry_after, bump_count), die das Frontend zum Sperren des // (retry_after, bump_count), mit denen das Frontend den Button sperrt.
// Buttons braucht, und der Cooldown ist ein regulärer Ausgang.
w.Header().Set("Retry-After", strconv.FormatInt(remaining, 10)) w.Header().Set("Retry-After", strconv.FormatInt(remaining, 10))
writeJSON(w, http.StatusTooManyRequests, map[string]any{ writeJSON(w, http.StatusTooManyRequests, map[string]any{
"error": apiErrorBody{ "error": apiErrorBody{
@@ -656,8 +598,7 @@ const (
// errUploadTooLargeOrBroken unterscheidet die beiden Gründe, an denen // errUploadTooLargeOrBroken unterscheidet die beiden Gründe, an denen
// ParseMultipartForm scheitern kann: das Limit von MaxBytesReader (der Upload // ParseMultipartForm scheitern kann: das Limit von MaxBytesReader (der Upload
// war zu groß -> 413) oder kaputtes Multipart (-> 400). Vorher endete beides // war zu groß -> 413) oder kaputtes Multipart (-> 400).
// als 400, was dem Nutzer nicht sagte, dass die Datei schlicht zu groß war.
func errUploadTooLargeOrBroken(err error) error { func errUploadTooLargeOrBroken(err error) error {
var tooLarge *http.MaxBytesError var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) { if errors.As(err, &tooLarge) {
@@ -749,7 +690,7 @@ func storeImage(fh *multipart.FileHeader) (string, error) {
if format == "gif" { if format == "gif" {
ext = "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)) dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
xdraw.CatmullRom.Scale(dst, dst.Bounds(), img, b, xdraw.Over, nil) 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 // serverseitig erzeugten Namen nach static/media. Der hochgeladene Dateiname
// fließt nie ein (kein Path-Traversal). // fließt nie ein (kein Path-Traversal).
func copyUpload(src io.Reader, ext string) (string, error) { 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) out, err := os.Create(name)
if err != nil { if err != nil {
return "", err return "", err
@@ -795,3 +736,8 @@ func copyUpload(src io.Reader, ext string) (string, error) {
} }
return name, nil 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)
}
+3 -6
View File
@@ -6,9 +6,8 @@ import (
"net/http" "net/http"
) )
// Fehler-Taxonomie: trennt die drei Informationen, die früher an jeder // Fehler-Taxonomie: trennt, wie schwer ein Fehler ist (Kind), was genau
// Fehler-Aufrufstelle verklebt waren -- wie schwer der Fehler ist (Kind), // passiert ist (Code) und was der Nutzer lesen soll (Msg).
// 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 // 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, // 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 return e
} }
// asError normalisiert alles, was kein *Error ist, zu einem Internal. Damit // asError normalisiert alles, was kein *Error ist, zu einem Internal.
// müssen die Adapter keinen Sonderfall für "nackte" Fehler kennen, die aus
// Bibliotheken oder noch nicht umgestellten Stellen kommen.
func asError(err error) *Error { func asError(err error) *Error {
var e *Error var e *Error
if errors.As(err, &e) { if errors.As(err, &e) {
+10
View File
@@ -2,7 +2,10 @@ package main
import ( import (
"net/http" "net/http"
"path"
"strings" "strings"
"github.com/go-chi/chi/v5"
) )
// webDir enthält das gebaute React-Frontend (frontend/ -> npm run build): // webDir enthält das gebaute React-Frontend (frontend/ -> npm run build):
@@ -19,6 +22,13 @@ func serveApp(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, webDir+"/index.html") http.ServeFile(w, r, webDir+"/index.html")
} }
// serveDoc liefert einen Rechtstext. Sie werden mit dem Frontend gebaut
// (frontend/static/docs), damit sie dasselbe app.css bekommen.
func serveDoc(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-cache")
http.ServeFile(w, r, webDir+"/static/docs/"+path.Base(chi.URLParam(r, "page")))
}
// serveAssets liefert die gebauten JS/CSS-Dateien. Ihre Namen enthalten einen // serveAssets liefert die gebauten JS/CSS-Dateien. Ihre Namen enthalten einen
// Inhalts-Hash, sie ändern sich also nie -> dauerhaft cachebar. Verzeichnisse // Inhalts-Hash, sie ändern sich also nie -> dauerhaft cachebar. Verzeichnisse
// gibt es nicht zu sehen (kein Listing). // gibt es nicht zu sehen (kein Listing).
+2 -3
View File
@@ -10,9 +10,8 @@ function plural(n: number, one: string, many: string) {
return `${n} ${n === 1 ? one : many}`; return `${n} ${n === 1 ? one : many}`;
} }
// Layout ist der gemeinsame Seitenrahmen. Markup und IDs entsprechen dem alten // Layout ist der gemeinsame Seitenrahmen: die Startseite hat Tagline und
// Frontend (web/*.html), damit app.css unverändert greift: die Startseite hat // Headerbar, alle anderen Seiten den "zurück zum Feed"-Knopf.
// Tagline und Headerbar, alle anderen Seiten den "zurück zum Feed"-Knopf.
export function Layout() { export function Layout() {
const isHome = useLocation().pathname === "/"; const isHome = useLocation().pathname === "/";
const days = Math.floor((Date.now() - dataLossStart) / 86400000); const days = Math.floor((Date.now() - dataLossStart) / 86400000);
+15 -31
View File
@@ -87,9 +87,7 @@ h2 {
color: black; color: black;
} }
/* Dezente Reihe anarchistischer Sigillen unter der Tagline. Einheitliche Höhe, /* Reihe anarchistischer Sigillen im Kopf der Beitragsordnung (Easter Egg).
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. */ aria-hidden im Markup: reine Dekoration, kein Vorlesen. */
#sigils { #sigils {
display: flex; display: flex;
@@ -258,9 +256,13 @@ img.avatar-lg { width: 72px; height: 72px; }
Die Strich-Spalte ist halb so breit wie der Avatar, ihr rechter Rand (= der 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 Strich) liegt damit unter der Mitte des Byline-Avatars. Der Abstand zum
Inhalt ist ebenfalls die halbe Avatarbreite -> Inhalt beginnt unter dem 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, #ancestors .threadline,
#replies .threadline { #replies .threadline,
.chain > :not(:last-child) .threadline {
display: block; display: block;
/* Box um die Linienbreite schmaler -> die rechts gezeichnete border-right /* Box um die Linienbreite schmaler -> die rechts gezeichnete border-right
landet genau auf der Avatarmitte statt rechts daneben. */ landet genau auf der Avatarmitte statt rechts daneben. */
@@ -268,16 +270,10 @@ img.avatar-lg { width: 72px; height: 72px; }
margin-right: calc(var(--avatar-sm) / 2); margin-right: calc(var(--avatar-sm) / 2);
} }
/* Nur die Vorfahren bilden einen Thread -> Linie. Antworten nur eingerückt. */ /* Strich nur, wo eine Kette ist: Vorfahren und Diskussionsketten. Direkte
#ancestors .threadline { border-right: var(--line-w) solid gray; } Antworten ohne Kette sind nur eingerückt. */
#ancestors .threadline,
/* 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. */
.chain > :not(:last-child) .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; border-right: var(--line-w) solid gray;
} }
@@ -299,7 +295,7 @@ img.avatar-lg { width: 72px; height: 72px; }
/* Interaktionsleiste: Antworten-Button links, Vote-Buttons mittig, Repost /* Interaktionsleiste: Antworten-Button links, Vote-Buttons mittig, Repost
rechts. Antwort- und Repost-Zahl stehen in den Buttons; der Repost-Cooldown 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 { .interactions {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -308,8 +304,7 @@ img.avatar-lg { width: 72px; height: 72px; }
margin-top: 0.25rem; margin-top: 0.25rem;
} }
/* Bump-Button: tritt an die Stelle der früheren Bump-Kommentare. Im Cooldown /* Repost-Button: im Cooldown gesperrt (gilt pro Beitrag für alle). */
gesperrt (gilt pro Beitrag für alle), der Button zeigt dann die Restzeit. */
.bump:disabled { .bump:disabled {
color: gray; color: gray;
border-color: #ccc; border-color: #ccc;
@@ -328,12 +323,6 @@ img.avatar-lg { width: 72px; height: 72px; }
.ghost.selected { font-weight: bold; } .ghost.selected { font-weight: bold; }
.msg { color: red; } .msg { color: red; }
p.warning {
background-color: #faa;
padding: 10px 15px;
font-weight: bold;
}
/* --- Formulare --- */ /* --- Formulare --- */
form { form {
background-color: white; background-color: white;
@@ -360,11 +349,6 @@ form input[type="file"] {
outline: none; outline: none;
} }
hr {
border: none;
border-top: 1px solid #ccc;
}
/* --- Statistik-Seite --- */ /* --- Statistik-Seite --- */
.statgrid { .statgrid {
display: flex; display: flex;
@@ -381,8 +365,8 @@ hr {
line-height: 1.1; line-height: 1.1;
} }
/* Horizontale Balken (Aufrufe/Tag, beliebteste Seiten). Breite wird per JS /* Horizontale Balken (Aufrufe/Tag, Betreiber). Breite setzt React per CSSOM
gesetzt (CSSOM, kein Inline-style -> CSP-konform). */ (kein Inline-style im HTML -> CSP-konform). */
.bars { .bars {
padding: 0.3rem 0.5rem 0.6rem; padding: 0.3rem 0.5rem 0.6rem;
} }
@@ -414,7 +398,7 @@ hr {
font-size: 0.95rem; 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 { #stat-asns .bar-label {
flex-basis: 13rem; flex-basis: 13rem;
white-space: normal; white-space: normal;
+1 -1
View File
@@ -2,7 +2,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ApiError, api } from "./api"; import { ApiError, api } from "./api";
import type { Me } from "./types"; import type { Me } from "./types";
export const meKey = ["me"]; const meKey = ["me"];
// useMe liefert das eigene Konto oder null, wenn niemand angemeldet ist. 401 // useMe liefert das eigene Konto oder null, wenn niemand angemeldet ist. 401
// ist hier kein Fehler, sondern die Antwort "nicht angemeldet". // ist hier kein Fehler, sondern die Antwort "nicht angemeldet".
+2 -3
View File
@@ -2,9 +2,8 @@ import { useState, type FormEvent, type MouseEvent } from "react";
import { api, errMsg } from "../api"; import { api, errMsg } from "../api";
import { useAuthChanged } from "../auth"; import { useAuthChanged } from "../auth";
// AuthBox: Login und Registrierung im Wechsel. Die {" "} bilden die // AuthBox: Login und Registrierung im Wechsel. Die {" "} sind Absicht:
// Leerzeichen des alten Markups nach -- Überschrift, Inputs und Knöpfe sind // Überschrift, Inputs und Knöpfe sind inline, der Abstand ist sichtbar.
// inline, der Abstand zwischen ihnen ist also sichtbar.
export function AuthBox() { export function AuthBox() {
const [mode, setMode] = useState<"login" | "register">("login"); const [mode, setMode] = useState<"login" | "register">("login");
const [msg, setMsg] = useState(""); const [msg, setMsg] = useState("");
+1 -1
View File
@@ -8,7 +8,7 @@ const nowSec = () => Math.floor(Date.now() / 1000);
// cooldownText ist der Hinweis in der Byline: Restzeit auf Stunden // cooldownText ist der Hinweis in der Byline: Restzeit auf Stunden
// aufgerundet (mindestens 1 -> nie "0h", solange Restzeit übrig ist). // 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`; return ` · Repost in ${Math.max(1, Math.ceil(retryAfter / 3600))}h`;
} }
-45
View File
@@ -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<CreatedEntry>("/entry/create", fd),
});
const onSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
create.mutate(new FormData(form), {
onSuccess: () => {
form.reset();
setPasteMsg("");
qc.resetQueries({ queryKey: ["feed"] });
},
});
};
return (
<section id="compose">
<form
id="entry-form"
onSubmit={onSubmit}
onPaste={(e) => {
if (pasteImage(e)) setPasteMsg("Bild aus Zwischenablage übernommen.");
}}
>
<textarea name="content" rows={4} maxLength={1000} placeholder="Was gibt's Neues?"></textarea>{" "}
<p className="muted" id="paste-msg">{pasteMsg}</p>{" "}
<input name="file" type="file" accept="image/*" />{" "}
<button type="submit" disabled={create.isPending}>Eintrag erstellen</button>
</form>
<p className="msg" id="entry-msg">{create.isError ? errMsg(create.error, "Beitrag fehlgeschlagen.") : ""}</p>
</section>
);
}
+9 -9
View File
@@ -14,15 +14,16 @@ function useOpenOnClick(pid: number) {
}; };
} }
export function EntryCard({ entry: e }: { entry: Entry }) { export function EntryCard({ entry }: { entry: Entry }) {
const open = useOpenOnClick(e.pid); return entry.deleted ? <DeletedCard entry={entry} /> : <LiveCard entry={entry} />;
const when = formatTime(e.created_at); }
// Soft-gelöschter Beitrag: nur Platzhalter, damit der Thread navigierbar bleibt. // Soft-gelöschter Beitrag: nur Platzhalter, damit der Thread navigierbar bleibt.
if (e.deleted) { function DeletedCard({ entry: e }: { entry: Entry }) {
const open = useOpenOnClick(e.pid);
return ( return (
<article className="card" onClick={open}> <article className="card" onClick={open}>
<div className="muted byline"><span>[deleted] · {when}</span></div> <div className="muted byline"><span>[deleted] · {formatTime(e.created_at)}</span></div>
<div className="threadline-wrapper"> <div className="threadline-wrapper">
<div className="threadline"></div> <div className="threadline"></div>
<div className="body"> <div className="body">
@@ -32,10 +33,9 @@ export function EntryCard({ entry: e }: { entry: Entry }) {
</article> </article>
); );
} }
return <LiveCard entry={e} when={when} open={open} />;
}
function LiveCard({ entry: e, when, open }: { entry: Entry; when: string; open: (ev: MouseEvent) => void }) { function LiveCard({ entry: e }: { entry: Entry }) {
const open = useOpenOnClick(e.pid);
const bump = useBump(e); const bump = useBump(e);
const profile = `/u/${encodeURIComponent(e.username)}`; const profile = `/u/${encodeURIComponent(e.username)}`;
@@ -46,7 +46,7 @@ function LiveCard({ entry: e, when, open }: { entry: Entry; when: string; open:
<img className="avatar avatar-sm" src={avatarUrl(e.avatar)} alt="" loading="lazy" /> <img className="avatar avatar-sm" src={avatarUrl(e.avatar)} alt="" loading="lazy" />
</Link> </Link>
<span> <span>
<Link to={profile}>{e.username}</Link> · {when} <Link to={profile}>{e.username}</Link> · {formatTime(e.created_at)}
<span className="cooldown">{bump.cooldown}</span> <span className="cooldown">{bump.cooldown}</span>
</span> </span>
</div> </div>
+2 -2
View File
@@ -10,8 +10,8 @@ const maxPage = 100;
// Feed lädt Seiten per Infinite-Scroll: queryKey identifiziert den Feed im // Feed lädt Seiten per Infinite-Scroll: queryKey identifiziert den Feed im
// Cache (zum Invalidieren nach eigenen Beiträgen), urlFor(page) baut den // Cache (zum Invalidieren nach eigenen Beiträgen), urlFor(page) baut den
// API-Pfad der Seite. as ist das Container-Element wie im alten Markup: // API-Pfad der Seite. as ist das Container-Element: <section> auf der
// <section> auf der Startseite, <div> innerhalb des Profils. // Startseite, <div> innerhalb des Profils.
export function Feed({ queryKey, urlFor, as: Container = "section" }: { export function Feed({ queryKey, urlFor, as: Container = "section" }: {
queryKey: unknown[]; queryKey: unknown[];
urlFor: (page: number) => string; urlFor: (page: number) => string;
+50
View File
@@ -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<HTMLFormElement>) => {
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 (
<>
<form
onSubmit={onSubmit}
onPaste={(e) => {
if (pasteImage(e)) setPasteMsg("Bild aus Zwischenablage übernommen.");
}}
>
<textarea name="content" rows={rows} maxLength={1000} placeholder={placeholder}></textarea>{" "}
<p className="muted">{pasteMsg}</p>{" "}
<input name="file" type="file" accept="image/*" />{" "}
<button type="submit" disabled={create.isPending}>{submitLabel}</button>
</form>
<p className="msg">{create.isError ? errMsg(create.error, fallback) : ""}</p>
</>
);
}
+2 -3
View File
@@ -11,9 +11,8 @@ export function formatTime(unix: number): string {
const urlPattern = /https?:\/\/[^\s<]+/g; const urlPattern = /https?:\/\/[^\s<]+/g;
// linkify macht aus URLs im Text Links und aus Zeilenumbrüchen <br>. React // linkify macht aus URLs im Text Links und aus Zeilenumbrüchen <br>. Den Text
// escaped den Text selbst -- anders als im alten Frontend gibt es hier kein // escaped React selbst.
// innerHTML und damit keinen Escaping-Schritt, der vergessen werden könnte.
export function linkify(text: string): ReactNode[] { export function linkify(text: string): ReactNode[] {
const out: ReactNode[] = []; const out: ReactNode[] = [];
text.split("\n").forEach((line, i) => { text.split("\n").forEach((line, i) => {
+10 -45
View File
@@ -3,9 +3,9 @@ import { useParams } from "react-router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, errMsg } from "../api"; import { api, errMsg } from "../api";
import { useMe } from "../auth"; import { useMe } from "../auth";
import { pasteImage } from "../paste";
import { Discussion, EntryCard } from "../components/EntryCard"; 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, // Beim Wechsel zu einem anderen Beitrag frisch anfangen (offene Bearbeitung,
// Meldungen) -- deshalb hängt die Seite per key an der pid. // Meldungen) -- deshalb hängt die Seite per key an der pid.
@@ -51,7 +51,14 @@ function ThreadView({ pid }: { pid: string }) {
</div> </div>
<section id="reply-box" hidden={!me}> <section id="reply-box" hidden={!me}>
<ReplyForm pid={pid} onDone={refresh} /> <PostForm
replyTo={pid}
rows={3}
placeholder="Antworten…"
submitLabel="Antworten"
fallback="Antwort fehlgeschlagen."
onDone={refresh}
/>
</section> </section>
<section id="replies"> <section id="replies">
@@ -63,8 +70,6 @@ function ThreadView({ pid }: { pid: string }) {
} }
// OwnerActions: Bearbeiten und Löschen für den eigenen, nicht gelöschten Beitrag. // 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 }) { function OwnerActions({ entry, onChange }: { entry: Entry; onChange: () => void }) {
const [editing, setEditing] = useState(false); 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<CreatedEntry>("/entry/create", fd),
});
const onSubmit = (e: FormEvent<HTMLFormElement>) => {
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 (
<>
<form
id="reply-form"
onSubmit={onSubmit}
onPaste={(e) => {
if (pasteImage(e)) setPasteMsg("Bild aus Zwischenablage übernommen.");
}}
>
<textarea name="content" rows={3} maxLength={1000} placeholder="Antworten…"></textarea>{" "}
<p className="muted" id="reply-paste-msg">{pasteMsg}</p>{" "}
<input name="file" type="file" accept="image/*" />{" "}
<button type="submit" disabled={reply.isPending}>Antworten</button>
</form>
<p className="msg" id="reply-msg">{reply.isError ? errMsg(reply.error, "Antwort fehlgeschlagen.") : ""}</p>
</>
);
}
+19 -1
View File
@@ -1,7 +1,8 @@
import { useQueryClient } from "@tanstack/react-query";
import { useMe } from "../auth"; import { useMe } from "../auth";
import { AuthBox } from "../components/AuthBox"; import { AuthBox } from "../components/AuthBox";
import { Compose } from "../components/Compose";
import { Feed } from "../components/Feed"; import { Feed } from "../components/Feed";
import { PostForm } from "../components/PostForm";
// Solange der Anmeldestatus noch lädt, weder Login noch Formular zeigen -- // Solange der Anmeldestatus noch lädt, weder Login noch Formular zeigen --
// sonst blitzt bei Angemeldeten kurz die Login-Box auf. // 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 (
<section id="compose">
<PostForm
rows={4}
placeholder="Was gibt's Neues?"
submitLabel="Eintrag erstellen"
fallback="Beitrag fehlgeschlagen."
onDone={() => qc.resetQueries({ queryKey: ["feed"] })}
/>
</section>
);
}
+2 -3
View File
@@ -36,7 +36,7 @@ function ProfileView({ username }: { username: string }) {
<img <img
id="profile-avatar" id="profile-avatar"
className="avatar avatar-lg" className="avatar avatar-lg"
src={info ? avatarUrl(info.avatar) + avatarBust : "/static/assets/no_profile_pic.jpg"} src={avatarUrl(info?.avatar ?? "") + avatarBust}
alt="" alt=""
/> />
<div> <div>
@@ -74,8 +74,7 @@ function useFormAction<T>(path: string, fallback: string, onSuccess: (form: HTML
return { onSubmit, busy: m.isPending, msg: m.isError ? errMsg(m.error, fallback) : "" }; 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 // Settings: nur für den Eigentümer.
// Markups nach -- Inputs und Knöpfe sind inline.
function Settings({ onAvatar }: { onAvatar: () => void }) { function Settings({ onAvatar }: { onAvatar: () => void }) {
const qc = useQueryClient(); const qc = useQueryClient();
const navigate = useNavigate(); const navigate = useNavigate();
-7
View File
@@ -43,13 +43,6 @@ export interface BumpResult {
retry_after: number; retry_after: number;
} }
export interface CreatedEntry {
pid: number;
content: string;
filepath: string;
reply_to: number;
}
// GET /api/user/info -- das eigene Konto. // GET /api/user/info -- das eigene Konto.
export interface Me { export interface Me {
uid: number; uid: number;
@@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Beitragsordnung - Kontrollverlust</title> <title>Beitragsordnung - Kontrollverlust</title>
<link rel="stylesheet" href="/css/app.css"> <link rel="stylesheet" href="/src/app.css">
</head> </head>
<body> <body>
<header> <header>
@@ -12,7 +12,7 @@
<a href="/static/docs/impressum.html">Impressum</a> - <a href="/static/docs/impressum.html">Impressum</a> -
<a href="/static/docs/datenschutz.html">Datenschutz</a> - <a href="/static/docs/datenschutz.html">Datenschutz</a> -
<a href="/static/docs/beitragsordnung.html">Beitragsordnung</a> - <a href="/static/docs/beitragsordnung.html">Beitragsordnung</a> -
<a href="/stats.html">Statistik</a> <a href="/stats">Statistik</a>
</nav> </nav>
<a href="/"><h1>Kontrollverlust</h1></a> <a href="/"><h1>Kontrollverlust</h1></a>
<div id="sigils" aria-hidden="true"> <div id="sigils" aria-hidden="true">
@@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Datenschutz - Kontrollverlust</title> <title>Datenschutz - Kontrollverlust</title>
<link rel="stylesheet" href="/css/app.css"> <link rel="stylesheet" href="/src/app.css">
</head> </head>
<body> <body>
<header> <header>
@@ -12,7 +12,7 @@
<a href="/static/docs/impressum.html">Impressum</a> - <a href="/static/docs/impressum.html">Impressum</a> -
<a href="/static/docs/datenschutz.html">Datenschutz</a> - <a href="/static/docs/datenschutz.html">Datenschutz</a> -
<a href="/static/docs/beitragsordnung.html">Beitragsordnung</a> - <a href="/static/docs/beitragsordnung.html">Beitragsordnung</a> -
<a href="/stats.html">Statistik</a> <a href="/stats">Statistik</a>
</nav> </nav>
<a href="/"><h1>Kontrollverlust</h1></a> <a href="/"><h1>Kontrollverlust</h1></a>
</header> </header>
@@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Impressum - Kontrollverlust</title> <title>Impressum - Kontrollverlust</title>
<link rel="stylesheet" href="/css/app.css"> <link rel="stylesheet" href="/src/app.css">
</head> </head>
<body> <body>
<header> <header>
@@ -12,7 +12,7 @@
<a href="/static/docs/impressum.html">Impressum</a> - <a href="/static/docs/impressum.html">Impressum</a> -
<a href="/static/docs/datenschutz.html">Datenschutz</a> - <a href="/static/docs/datenschutz.html">Datenschutz</a> -
<a href="/static/docs/beitragsordnung.html">Beitragsordnung</a> - <a href="/static/docs/beitragsordnung.html">Beitragsordnung</a> -
<a href="/stats.html">Statistik</a> <a href="/stats">Statistik</a>
</nav> </nav>
<a href="/"><h1>Kontrollverlust</h1></a> <a href="/"><h1>Kontrollverlust</h1></a>
</header> </header>
+10
View File
@@ -16,9 +16,19 @@ export default defineConfig({
// Das Go-Backend liefert das Build aus web/ aus (siehe frontend.go). // Das Go-Backend liefert das Build aus web/ aus (siehe frontend.go).
// emptyOutDir muss explizit an sein, weil das Ziel außerhalb von frontend/ // emptyOutDir muss explizit an sein, weil das Ziel außerhalb von frontend/
// liegt. // liegt.
// Die Rechtstexte sind eigene statische Seiten ohne JS; als weitere Einstiege
// bekommen sie dasselbe gebaute app.css wie die App.
build: { build: {
outDir: "../web", outDir: "../web",
emptyOutDir: true, emptyOutDir: true,
rollupOptions: {
input: [
"index.html",
"static/docs/impressum.html",
"static/docs/datenschutz.html",
"static/docs/beitragsordnung.html",
],
},
}, },
server: { server: {
proxy: { proxy: {
-9
View File
@@ -9,24 +9,15 @@ require (
github.com/oschwald/maxminddb-golang v1.13.1 github.com/oschwald/maxminddb-golang v1.13.1
golang.org/x/crypto v0.52.0 golang.org/x/crypto v0.52.0
golang.org/x/image v0.41.0 golang.org/x/image v0.41.0
modernc.org/sqlite v1.59.0
) )
require ( 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/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // 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 github.com/zeebo/xxh3 v1.0.2 // indirect
golang.org/x/sync v0.22.0 // indirect golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.37.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
) )
-46
View File
@@ -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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= 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 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g=
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4= 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 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= 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/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 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 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 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE=
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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/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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 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/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 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= 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 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= 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 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 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 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= 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/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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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=
+2 -4
View File
@@ -3,10 +3,8 @@ package main
import "net/http" import "net/http"
// handlerFunc ist die Handler-Signatur dieser App: wie http.HandlerFunc, aber // 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 // mit Fehler-Rückgabe. "return err" beendet den Handler zwingend -- ein
// (writeError(...); return) machte ein vergessenes return zu einem stillen Bug: // vergessenes return nach dem Fehlerschreiben kann es so nicht geben.
// der Handler lief weiter und schrieb eine zweite Antwort in denselben
// ResponseWriter. Mit "return err" kann das strukturell nicht mehr passieren.
type handlerFunc func(http.ResponseWriter, *http.Request) error type handlerFunc func(http.ResponseWriter, *http.Request) error
// api hängt einen fehlerliefernden Handler in chi ein; der Fehler geht als // api hängt einen fehlerliefernden Handler in chi ein; der Fehler geht als
-134
View File
@@ -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()
}
+3 -11
View File
@@ -37,15 +37,6 @@ func main() {
} }
defer db.Close() 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 // Optionale GeoLite2-ASN-Datenbank für die netzwerktopologische Einordnung
// der Impressions. Inaktiv, wenn KVER_GEOIP_ASN nicht gesetzt ist. // der Impressions. Inaktiv, wenn KVER_GEOIP_ASN nicht gesetzt ist.
initGeoASN() initGeoASN()
@@ -69,7 +60,7 @@ func main() {
} }
// Graceful Shutdown: auf SIGINT/SIGTERM laufende Requests zu Ende bedienen, // 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{}) idle := make(chan struct{})
go func() { go func() {
sig := make(chan os.Signal, 1) sig := make(chan os.Signal, 1)
@@ -180,8 +171,9 @@ func routes() http.Handler {
http.Redirect(w, r, "/stats", http.StatusMovedPermanently) http.Redirect(w, r, "/stats", http.StatusMovedPermanently)
}) })
r.Handle("/assets/*", serveAssets()) r.Handle("/assets/*", serveAssets())
r.Get("/static/docs/{page}", serveDoc)
// Hochgeladene Medien + statische Dateien (Schriften, Rechtstexte). // Hochgeladene Medien + statische Dateien (Schriften, Bilder).
// nosniff (global via secHeaders) verhindert, dass der Browser eine // nosniff (global via secHeaders) verhindert, dass der Browser eine
// durchgereichte (nicht re-kodierte) Datei als HTML/JS interpretiert -> // durchgereichte (nicht re-kodierte) Datei als HTML/JS interpretiert ->
// schließt Polyglot/Stored-XSS über Bild-Uploads. // schließt Polyglot/Stored-XSS über Bild-Uploads.
-196
View File
@@ -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" <<SQL
.bail on
ATTACH '$src_sql' AS src;
BEGIN;
-- User übernehmen; nur Spalten des aktuellen Schemas (Quelle kann Extra-Spalten haben)
INSERT INTO user (id, uid, username, password, created_at, last_login, avatar)
SELECT u.id, u.uid, u.username, u.password, u.created_at, u.last_login, $avatar_expr FROM src.user u;
-- Einträge transformieren:
-- filepath NULL und 'none' -> '' (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" <<SQL2
.bail on
ATTACH '$src_sql' AS src;
INSERT OR IGNORE INTO vote (uid, pid, mode)
SELECT uid, pid, mode FROM src.vote
WHERE uid IN (SELECT uid FROM entry)
AND pid IN (SELECT pid FROM entry);
DETACH src;
SQL2
fi
echo
echo "Nachher:"
echo " Einträge gesamt: $(qd 'SELECT COUNT(*) FROM entry;')"
echo " davon Antworten: $(qd 'SELECT COUNT(*) FROM entry WHERE reply_to!=0;')"
echo " sichtbar im Feed: $(qd 'SELECT COUNT(*) FROM entry WHERE reply_to=0;')"
echo " User: $(qd 'SELECT COUNT(*) FROM user;')"
echo " Votes: $(qd 'SELECT COUNT(*) FROM vote;')"
# Medien-Check: referenzierte Bilder, die im Dateisystem fehlen
missing=0
total=0
while IFS= read -r fp; do
[[ -z "$fp" ]] && continue
total=$((total+1))
[[ -f "$fp" ]] || missing=$((missing+1))
done < <(qd "SELECT filepath FROM entry WHERE filepath != '';")
echo
echo "Medien:"
echo " referenziert: $total"
echo " fehlend im Dateisystem: $missing"
if [[ "$missing" -gt 0 ]]; then
echo " -> static/media/ aus der Produktion kopieren, sonst liefern diese Bilder 404."
fi
echo
echo "Fertig."
-17
View File
@@ -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 Tabellen und Indizes legt die App beim Start selbst an (`CREATE ... IF NOT
EXISTS`, als `kver` → gehören `kver`). Nicht als Admin anlegen. 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 ## Bestehende Medien übernehmen
Medien einmalig ins Volume kopieren (Volumes müssen existieren; Medien einmalig ins Volume kopieren (Volumes müssen existieren;
+4
View File
@@ -24,6 +24,10 @@ Vite reicht `/api` und `/static` ans Backend durch (`frontend/vite.config.ts`,
(gitignored). Von dort liefert das Go-Backend aus (`frontend.go`), lokal also (gitignored). Von dort liefert das Go-Backend aus (`frontend.go`), lokal also
auch ohne Vite: `npm run build`, dann `go run .`. auch ohne Vite: `npm run build`, dann `go run .`.
Die Rechtstexte (`frontend/static/docs/*.html`) sind reine HTML-Seiten ohne JS,
aber weitere Einstiege des Builds (`vite.config.ts`). So bekommen sie dasselbe
gebaute `app.css`; ausgeliefert unter `/static/docs/…` (`serveDoc`).
### DEV-Container ### DEV-Container
`./dev-up.sh` baut das Image aus dem Arbeitsverzeichnis und startet den `./dev-up.sh` baut das Image aus dem Arbeitsverzeichnis und startet den
-120
View File
@@ -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
(`<img src="/none">` → 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.
-36
View File
@@ -1,36 +0,0 @@
$(() => {
// $('#auth-visible').hide()
// Startdatum festlegen (Beispiel: 1. Januar 2025, 00:00:00 Uhr)
const startDate = new Date('2025-12-13T12:44:00').getTime();
function updateCounter() {
const now = new Date().getTime();
const diff = now - startDate;
// Berechne Tage, Stunden, Minuten
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
// Zeige den Counter an
$('#uptime-counter').html(`<strong>${days}</strong> Tage, <strong>${hours}</strong> Stunden, <strong>${minutes}</strong> Minuten und <strong>${seconds}</strong> Sekunden ohne Datenverlust!`);
}
// Aktualisiere den Counter sofort und dann jede Minute
updateCounter();
setInterval(updateCounter, 1000);
let agreedToWarning = document.cookie.includes("agreedToWarning=True")
if(agreedToWarning) {
$("#contentnotice").hide()
}
})
function setCookie(cname, cvalue, exdays) {
const d = new Date();
d.setTime(d.getTime() + (exdays*24*60*60*1000));
let expires = "expires="+ d.toUTCString();
document.cookie = cname + "=" + cvalue + ";" + expires + ";path=/";
}
+16
View File
@@ -220,6 +220,22 @@ func recordImpression(path, anonIP, asn string) {
) )
} }
// handleStats liefert die Kennzahlen für die Statuszeile unter dem Titel:
// registrierte Nutzer und nicht-gelöschte Beiträge.
func handleStats(w http.ResponseWriter, r *http.Request) error {
var users, entries int64
if err := db.QueryRow(
`SELECT (SELECT COUNT(*) FROM account), (SELECT COUNT(*) FROM entry WHERE deleted = 0)`).
Scan(&users, &entries); err != nil {
return Internal(err)
}
writeJSON(w, http.StatusOK, map[string]any{
"users": users,
"entries": entries,
})
return nil
}
// handleStatsDetail liefert die Kennzahlen für die Statistik-Seite. Anders als // handleStatsDetail liefert die Kennzahlen für die Statistik-Seite. Anders als
// /stats (Tagline, bei jedem Aufruf) darf das hier ruhig mehrere Aggregate // /stats (Tagline, bei jedem Aufruf) darf das hier ruhig mehrere Aggregate
// rechnen. Öffentlich, kein Auth. // rechnen. Öffentlich, kein Auth.
+6 -34
View File
@@ -54,13 +54,10 @@ func createUser(username, password string) (int64, error) {
return 0, lastErr return 0, lastErr
} }
// idFromUsername liefert die interne id oder 0, wenn der Nutzer nicht existiert. func usernameTaken(username string) bool {
func idFromUsername(username string) int64 { var taken bool
var id int64 db.QueryRow(`SELECT EXISTS (SELECT 1 FROM account WHERE username = $1)`, username).Scan(&taken)
if err := db.QueryRow(`SELECT id FROM account WHERE username = $1`, username).Scan(&id); err != nil { return taken
return 0
}
return id
} }
func handleUserPage(w http.ResponseWriter, r *http.Request) error { func handleUserPage(w http.ResponseWriter, r *http.Request) error {
@@ -81,22 +78,6 @@ func handleUserPage(w http.ResponseWriter, r *http.Request) error {
return nil return nil
} }
// handleStats liefert die Kennzahlen für die Statuszeile unter dem Titel:
// registrierte Nutzer und nicht-gelöschte Beiträge.
func handleStats(w http.ResponseWriter, r *http.Request) error {
var users, entries int64
if err := db.QueryRow(
`SELECT (SELECT COUNT(*) FROM account), (SELECT COUNT(*) FROM entry WHERE deleted = 0)`).
Scan(&users, &entries); err != nil {
return Internal(err)
}
writeJSON(w, http.StatusOK, map[string]any{
"users": users,
"entries": entries,
})
return nil
}
func handleUserInfo(w http.ResponseWriter, r *http.Request) error { func handleUserInfo(w http.ResponseWriter, r *http.Request) error {
uid := uidFromContext(r.Context()) uid := uidFromContext(r.Context())
@@ -135,7 +116,7 @@ func handleUserRename(w http.ResponseWriter, r *http.Request) error {
if name == current { if name == current {
return Invalid("user.name_unchanged", "Das ist bereits dein Name.").At("user") return Invalid("user.name_unchanged", "Das ist bereits dein Name.").At("user")
} }
if idFromUsername(name) != 0 { if usernameTaken(name) {
return errUsernameTaken return errUsernameTaken
} }
@@ -242,16 +223,7 @@ func handleUserDelete(w http.ResponseWriter, r *http.Request) error {
os.Remove(fp) os.Remove(fp)
} }
http.SetCookie(w, &http.Cookie{ clearSessionCookie(w, r)
Name: "session",
Value: "",
Path: "/",
Expires: time.Unix(0, 0),
MaxAge: -1,
HttpOnly: true,
Secure: isHTTPS(r),
SameSite: http.SameSiteLaxMode,
})
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
return nil return nil
} }