package main import ( "database/sql" "errors" "github.com/jackc/pgx/v5/pgconn" _ "github.com/jackc/pgx/v5/stdlib" ) var db *sql.DB // Zeitstempel sind Unix-Sekunden (BIGINT) statt timestamptz: das JSON-Format // der API liefert sie so aus, und die Bump-Rechnung arbeitet direkt damit. // "user" ist in Postgres ein reserviertes Wort, daher heißt die Tabelle account. const schema = ` CREATE TABLE IF NOT EXISTS account ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, uid BIGINT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE, password BYTEA NOT NULL, created_at BIGINT NOT NULL, last_login BIGINT, avatar TEXT NOT NULL DEFAULT '' ); CREATE TABLE IF NOT EXISTS session ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, uid BIGINT NOT NULL, value TEXT NOT NULL UNIQUE, created_at BIGINT NOT NULL, expires BIGINT NOT NULL, description TEXT NOT NULL DEFAULT '' ); CREATE TABLE IF NOT EXISTS entry ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, pid BIGINT NOT NULL UNIQUE, uid BIGINT NOT NULL, created_at BIGINT NOT NULL, content TEXT NOT NULL DEFAULT '', filepath TEXT NOT NULL DEFAULT '', reply_to BIGINT NOT NULL DEFAULT 0, reply_count BIGINT NOT NULL DEFAULT 0, last_activity BIGINT NOT NULL DEFAULT 0, deleted BIGINT NOT NULL DEFAULT 0, bump_count BIGINT NOT NULL DEFAULT 0, last_bump BIGINT NOT NULL DEFAULT 0 ); -- Kanonische URL des ersten Links im Inhalt (previewKey), '' ohne Link. ALTER TABLE entry ADD COLUMN IF NOT EXISTS link_url TEXT NOT NULL DEFAULT ''; CREATE TABLE IF NOT EXISTS vote ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, uid BIGINT NOT NULL, pid BIGINT NOT NULL, mode TEXT NOT NULL, UNIQUE (uid, pid) ); -- Seitenaufrufe, aggregiert pro (Tag, anonymisiertes Netz, Pfad). Es wird nie -- eine volle IP gespeichert: anon_ip ist auf /16 (IPv4) bzw. /32 (IPv6) gekürzt -- (siehe maskIP). asn ist das autonome System (Netzbetreiber), aufgelöst aus dem -- bereits anonymisierten Netz (siehe lookupASN) -- nicht im PK, da pro Netz -- deterministisch. Aggregation statt Eventlog -> die Tabelle wächst nur mit -- Tag × Netz × Pfad, nicht pro Request. CREATE TABLE IF NOT EXISTS impression ( day TEXT NOT NULL, anon_ip TEXT NOT NULL, path TEXT NOT NULL DEFAULT '', asn TEXT NOT NULL DEFAULT '', hits BIGINT NOT NULL DEFAULT 0, PRIMARY KEY (day, anon_ip, path) ); -- Meldungen von Beiträgen. handled bleibt vorerst 0; die Abarbeitung folgt -- mit der Moderation (notes/todo.md). CREATE TABLE IF NOT EXISTS report ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, pid BIGINT NOT NULL, uid BIGINT NOT NULL DEFAULT 0, reason TEXT NOT NULL DEFAULT '', created_at BIGINT NOT NULL, handled BIGINT NOT NULL DEFAULT 0 ); -- Einmal-Links zum Passwort-Reset, per CLI erzeugt (kver reset-link, siehe -- reset.go). Gespeichert wird nur der SHA-256 des Tokens: wer die DB lesen -- kann, kann damit trotzdem kein Passwort setzen. CREATE TABLE IF NOT EXISTS password_reset ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, uid BIGINT NOT NULL, token_hash TEXT NOT NULL UNIQUE, created_at BIGINT NOT NULL, expires BIGINT NOT NULL ); -- Linkvorschauen, eine Zeile je kanonischer URL (siehe preview.go), geteilt -- von allen Beiträgen mit diesem Link. status: pending (wird geholt), ok, -- none (keine Vorschau möglich). thumb ist das lokal gespeicherte Bild. CREATE TABLE IF NOT EXISTS link_preview ( url TEXT PRIMARY KEY, kind TEXT NOT NULL DEFAULT '', title TEXT NOT NULL DEFAULT '', site TEXT NOT NULL DEFAULT '', thumb TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'pending', fetched_at BIGINT NOT NULL DEFAULT 0 ); -- Indizes für die häufigen Zugriffspfade; ohne sie werden Feed (ORDER BY -- last_activity), Thread (WHERE reply_to) und Vote-Zähler (WHERE pid) mit -- wachsender Tabelle zu Full-Table-Scans. username/session.value/vote(uid,pid) -- sind über UNIQUE bereits indiziert. CREATE INDEX IF NOT EXISTS idx_entry_last_activity ON entry(last_activity); CREATE INDEX IF NOT EXISTS idx_entry_reply_to ON entry(reply_to); CREATE INDEX IF NOT EXISTS idx_entry_uid ON entry(uid); CREATE INDEX IF NOT EXISTS idx_vote_pid ON vote(pid); CREATE INDEX IF NOT EXISTS idx_session_uid ON session(uid); CREATE INDEX IF NOT EXISTS idx_report_open ON report(handled, created_at); ` func initDB(dsn string) error { var err error db, err = sql.Open("pgx", dsn) if err != nil { return err } if err = db.Ping(); err != nil { return err } _, err = db.Exec(schema) return err } // isUniqueViolation meldet, ob err am UNIQUE-Constraint constraint scheiterte // (Postgres-Namen nach Schema __key, z. B. account_username_key). func isUniqueViolation(err error, constraint string) bool { var pgErr *pgconn.PgError return errors.As(err, &pgErr) && pgErr.Code == "23505" && pgErr.ConstraintName == constraint }