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
142 lines
5.2 KiB
Go
142 lines
5.2 KiB
Go
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
|
||
}
|