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 ); 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 ); -- 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 }