Files
Kontrollverlust/db.go
T
irrlichtandClaude Opus 5.5 26b0182391 Linkvorschau für den ersten Link eines Beitrags
Der Server holt zum ersten Link Titel, Seiten- bzw. Kanalname und ein
kleines Vorschaubild (320 px) und speichert das Bild lokal; der Browser
lädt nur von uns, Besucher-IPs gehen nie an die verlinkte Seite.
YouTube über oEmbed (alle Linkformen kanonisch als watch?v=<id>), alle
übrigen Seiten über OpenGraph bzw. <title>. Abruf asynchron in einem
Worker, eine Zeile je Link in link_preview, Auffrischen nach 30 Tagen.

Der Abruf-Client lässt nur öffentliche IPs auf Port 80/443 zu, geprüft
nach DNS-Auflösung und bei jeder Weiterleitung (SSRF). Bestehende
Beiträge: kver link-previews. Neue Abhängigkeit golang.org/x/net, damit
go 1.26. DEV bekommt ein beschreibbares Volume für die Vorschaubilder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
2026-10-06 18:47:21 +02:00

142 lines
5.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <tabelle>_<spalte>_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
}