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
*.env
static/media/
migrate.sh
Containerfile
.containerignore
frontend/node_modules/
+2 -4
View File
@@ -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
+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")
}
// 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
+68 -122
View File
@@ -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)
}
+3 -6
View File
@@ -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) {
+10
View File
@@ -2,7 +2,10 @@ package main
import (
"net/http"
"path"
"strings"
"github.com/go-chi/chi/v5"
)
// 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")
}
// 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
// Inhalts-Hash, sie ändern sich also nie -> dauerhaft cachebar. Verzeichnisse
// 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}`;
}
// 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);
+15 -31
View File
@@ -87,9 +87,7 @@ 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).
/* Reihe anarchistischer Sigillen im Kopf der Beitragsordnung (Easter Egg).
aria-hidden im Markup: reine Dekoration, kein Vorlesen. */
#sigils {
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
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 +270,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 +295,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 +304,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 +323,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 +349,6 @@ form input[type="file"] {
outline: none;
}
hr {
border: none;
border-top: 1px solid #ccc;
}
/* --- Statistik-Seite --- */
.statgrid {
display: flex;
@@ -381,8 +365,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 +398,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;
+1 -1
View File
@@ -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".
+2 -3
View File
@@ -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("");
+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
// 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`;
}
-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 }) {
const open = useOpenOnClick(e.pid);
const when = formatTime(e.created_at);
export function EntryCard({ entry }: { entry: Entry }) {
return entry.deleted ? <DeletedCard entry={entry} /> : <LiveCard entry={entry} />;
}
// 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 (
<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"></div>
<div className="body">
@@ -32,10 +33,9 @@ export function EntryCard({ entry: e }: { entry: Entry }) {
</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 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" />
</Link>
<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>
</div>
+2 -2
View File
@@ -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:
// <section> auf der Startseite, <div> innerhalb des Profils.
// API-Pfad der Seite. as ist das Container-Element: <section> auf der
// Startseite, <div> innerhalb des Profils.
export function Feed({ queryKey, urlFor, as: Container = "section" }: {
queryKey: unknown[];
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;
// linkify macht aus URLs im Text Links und aus Zeilenumbrüchen <br>. 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 <br>. Den Text
// escaped React selbst.
export function linkify(text: string): ReactNode[] {
const out: ReactNode[] = [];
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 { 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 }) {
</div>
<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 id="replies">
@@ -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<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 { 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 (
<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
id="profile-avatar"
className="avatar avatar-lg"
src={info ? avatarUrl(info.avatar) + avatarBust : "/static/assets/no_profile_pic.jpg"}
src={avatarUrl(info?.avatar ?? "") + avatarBust}
alt=""
/>
<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) : "" };
}
// 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();
-7
View File
@@ -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;
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Beitragsordnung - Kontrollverlust</title>
<link rel="stylesheet" href="/css/app.css">
<link rel="stylesheet" href="/src/app.css">
</head>
<body>
<header>
@@ -12,7 +12,7 @@
<a href="/static/docs/impressum.html">Impressum</a> -
<a href="/static/docs/datenschutz.html">Datenschutz</a> -
<a href="/static/docs/beitragsordnung.html">Beitragsordnung</a> -
<a href="/stats.html">Statistik</a>
<a href="/stats">Statistik</a>
</nav>
<a href="/"><h1>Kontrollverlust</h1></a>
<div id="sigils" aria-hidden="true">
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Datenschutz - Kontrollverlust</title>
<link rel="stylesheet" href="/css/app.css">
<link rel="stylesheet" href="/src/app.css">
</head>
<body>
<header>
@@ -12,7 +12,7 @@
<a href="/static/docs/impressum.html">Impressum</a> -
<a href="/static/docs/datenschutz.html">Datenschutz</a> -
<a href="/static/docs/beitragsordnung.html">Beitragsordnung</a> -
<a href="/stats.html">Statistik</a>
<a href="/stats">Statistik</a>
</nav>
<a href="/"><h1>Kontrollverlust</h1></a>
</header>
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Impressum - Kontrollverlust</title>
<link rel="stylesheet" href="/css/app.css">
<link rel="stylesheet" href="/src/app.css">
</head>
<body>
<header>
@@ -12,7 +12,7 @@
<a href="/static/docs/impressum.html">Impressum</a> -
<a href="/static/docs/datenschutz.html">Datenschutz</a> -
<a href="/static/docs/beitragsordnung.html">Beitragsordnung</a> -
<a href="/stats.html">Statistik</a>
<a href="/stats">Statistik</a>
</nav>
<a href="/"><h1>Kontrollverlust</h1></a>
</header>
+10
View File
@@ -16,9 +16,19 @@ export default defineConfig({
// Das Go-Backend liefert das Build aus web/ aus (siehe frontend.go).
// emptyOutDir muss explizit an sein, weil das Ziel außerhalb von frontend/
// liegt.
// Die Rechtstexte sind eigene statische Seiten ohne JS; als weitere Einstiege
// bekommen sie dasselbe gebaute app.css wie die App.
build: {
outDir: "../web",
emptyOutDir: true,
rollupOptions: {
input: [
"index.html",
"static/docs/impressum.html",
"static/docs/datenschutz.html",
"static/docs/beitragsordnung.html",
],
},
},
server: {
proxy: {
-9
View File
@@ -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
)
-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.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=
+2 -4
View File
@@ -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
-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()
// 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)
@@ -180,8 +171,9 @@ func routes() http.Handler {
http.Redirect(w, r, "/stats", http.StatusMovedPermanently)
})
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
// durchgereichte (nicht re-kodierte) Datei als HTML/JS interpretiert ->
// 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
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;
+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
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-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
// /stats (Tagline, bei jedem Aufruf) darf das hier ruhig mehrere Aggregate
// rechnen. Öffentlich, kein Auth.
+6 -34
View File
@@ -54,13 +54,10 @@ func createUser(username, password string) (int64, error) {
return 0, lastErr
}
// idFromUsername liefert die interne id oder 0, wenn der Nutzer nicht existiert.
func idFromUsername(username string) int64 {
var id int64
if err := db.QueryRow(`SELECT id FROM account WHERE username = $1`, username).Scan(&id); err != nil {
return 0
}
return id
func usernameTaken(username string) bool {
var taken bool
db.QueryRow(`SELECT EXISTS (SELECT 1 FROM account WHERE username = $1)`, username).Scan(&taken)
return taken
}
func handleUserPage(w http.ResponseWriter, r *http.Request) error {
@@ -81,22 +78,6 @@ func handleUserPage(w http.ResponseWriter, r *http.Request) error {
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 {
uid := uidFromContext(r.Context())
@@ -135,7 +116,7 @@ func handleUserRename(w http.ResponseWriter, r *http.Request) error {
if name == current {
return Invalid("user.name_unchanged", "Das ist bereits dein Name.").At("user")
}
if idFromUsername(name) != 0 {
if usernameTaken(name) {
return errUsernameTaken
}
@@ -242,16 +223,7 @@ func handleUserDelete(w http.ResponseWriter, r *http.Request) error {
os.Remove(fp)
}
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: "",
Path: "/",
Expires: time.Unix(0, 0),
MaxAge: -1,
HttpOnly: true,
Secure: isHTTPS(r),
SameSite: http.SameSiteLaxMode,
})
clearSessionCookie(w, r)
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
return nil
}