Datenbank von SQLite auf Postgres umgestellt

Verbindung über KVER_DSN; Tabelle user heißt jetzt account (reserviertes
Wort in Postgres). kver import-sqlite übernimmt die alte Datei einmalig in
einer Transaktion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
This commit is contained in:
irrlicht
2026-09-26 22:22:07 +02:00
co-authored by Claude Opus 5.5
parent c6ab082f8c
commit 2714fdb1a5
15 changed files with 412 additions and 301 deletions
+4 -3
View File
@@ -1,4 +1,5 @@
# Build-Stage: statisches Binary, CGO aus (modernc.org/sqlite ist pures Go).
# Build-Stage: statisches Binary, CGO aus (pgx und modernc.org/sqlite für den
# einmaligen Import sind pures Go).
FROM docker.io/library/golang:1.26-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
@@ -13,10 +14,10 @@ WORKDIR /app
COPY --from=build /kver /usr/local/bin/kver
COPY web/ web/
COPY static/ static/
# data: SQLite-DB; static/media: Uploads. Beide als Volume persistieren.
# data: GeoLite2-ASN-DB (und die alte SQLite-DB für den Import);
# static/media: Uploads. Beide als Volume persistieren.
RUN mkdir -p data static/media && chown kver:kver data static/media
USER kver
ENV KVER_DB=/app/data/kver.db
ENV KVER_ADDR=:8080
EXPOSE 8080
VOLUME ["/app/data", "/app/static/media"]
+7 -7
View File
@@ -39,7 +39,7 @@ func getSession(r *http.Request) (*Session, bool) {
s := Session{Value: c.Value}
err = db.QueryRow(
`SELECT uid, created_at, expires, description FROM session WHERE value = ?`,
`SELECT uid, created_at, expires, description FROM session WHERE value = $1`,
c.Value,
).Scan(&s.UID, &s.CreatedAt, &s.Expires, &s.Description)
if err != nil {
@@ -47,7 +47,7 @@ func getSession(r *http.Request) (*Session, bool) {
}
if s.Expires < time.Now().Unix() {
db.Exec(`DELETE FROM session WHERE value = ?`, c.Value)
db.Exec(`DELETE FROM session WHERE value = $1`, c.Value)
return nil, false
}
@@ -73,7 +73,7 @@ func handleLogin(w http.ResponseWriter, r *http.Request) error {
// wird der Endpunkt zum Nutzernamen-Orakel.
var uid int64
var hash []byte
err := db.QueryRow(`SELECT uid, password FROM user WHERE username = ?`, username).Scan(&uid, &hash)
err := db.QueryRow(`SELECT uid, password FROM account WHERE username = $1`, username).Scan(&uid, &hash)
if err == sql.ErrNoRows {
return errBadCredentials
} else if err != nil {
@@ -112,14 +112,14 @@ func startSession(w http.ResponseWriter, r *http.Request, uid int64) error {
return Internal(err)
}
db.Exec(`DELETE FROM session WHERE expires < ?`, now)
db.Exec(`DELETE FROM session WHERE expires < $1`, now)
if _, err := db.Exec(
`INSERT INTO session (uid, created_at, expires, description, value) VALUES (?, ?, ?, ?, ?)`,
`INSERT INTO session (uid, created_at, expires, description, value) VALUES ($1, $2, $3, $4, $5)`,
uid, now, expires, "", token,
); err != nil {
return Internal(err)
}
db.Exec(`UPDATE user SET last_login = ? WHERE uid = ?`, now, uid)
db.Exec(`UPDATE account SET last_login = $1 WHERE uid = $2`, now, uid)
http.SetCookie(w, &http.Cookie{
Name: "session",
@@ -135,7 +135,7 @@ func startSession(w http.ResponseWriter, r *http.Request, uid int64) error {
func handleLogout(w http.ResponseWriter, r *http.Request) {
if s, ok := getSession(r); ok {
db.Exec(`DELETE FROM session WHERE value = ?`, s.Value)
db.Exec(`DELETE FROM session WHERE value = $1`, s.Value)
}
http.SetCookie(w, &http.Cookie{
Name: "session",
+46 -57
View File
@@ -2,53 +2,57 @@ package main
import (
"database/sql"
"strings"
"errors"
_ "modernc.org/sqlite"
"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 user (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uid INTEGER UNIQUE,
username TEXT UNIQUE,
password BLOB,
created_at INTEGER,
last_login INTEGER,
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 INTEGER PRIMARY KEY AUTOINCREMENT,
uid INTEGER,
value TEXT UNIQUE,
created_at INTEGER,
expires INTEGER,
description TEXT
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 INTEGER PRIMARY KEY AUTOINCREMENT,
pid INTEGER UNIQUE NOT NULL,
uid INTEGER NOT NULL,
created_at INTEGER NOT NULL,
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 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,
bump_count INTEGER NOT NULL DEFAULT 0,
last_bump INTEGER NOT NULL DEFAULT 0
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 INTEGER PRIMARY KEY AUTOINCREMENT,
uid INTEGER,
pid INTEGER,
mode TEXT,
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
uid BIGINT NOT NULL,
pid BIGINT NOT NULL,
mode TEXT NOT NULL,
UNIQUE (uid, pid)
);
@@ -63,7 +67,7 @@ CREATE TABLE IF NOT EXISTS impression (
anon_ip TEXT NOT NULL,
path TEXT NOT NULL DEFAULT '',
asn TEXT NOT NULL DEFAULT '',
hits INTEGER NOT NULL DEFAULT 0,
hits BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (day, anon_ip, path)
);
@@ -73,12 +77,12 @@ CREATE TABLE IF NOT EXISTS impression (
-- Absender nachziehen zu können, ohne das Schema zu ändern. handled: 0 offen,
-- 1 von der Moderation erledigt. reason ist ein optionaler Freitext.
CREATE TABLE IF NOT EXISTS report (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pid INTEGER NOT NULL,
uid INTEGER NOT NULL DEFAULT 0,
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 INTEGER NOT NULL,
handled INTEGER NOT NULL DEFAULT 0
created_at BIGINT NOT NULL,
handled BIGINT NOT NULL DEFAULT 0
);
-- Indizes für die häufigen Zugriffspfade; ohne sie werden Feed (ORDER BY
@@ -95,35 +99,20 @@ CREATE INDEX IF NOT EXISTS idx_report_open ON report(handled, created_at
func initDB(dsn string) error {
var err error
// SQLite hat genau einen Schreiber: busy_timeout wartet bei Sperren, statt
// sofort mit SQLITE_BUSY zu scheitern; WAL erlaubt parallele Leser.
db, err = sql.Open("sqlite", dsn+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(on)")
db, err = sql.Open("pgx", dsn)
if err != nil {
return err
}
if err = db.Ping(); err != nil {
return err
}
if _, err = db.Exec(schema); err != nil {
_, err = db.Exec(schema)
return err
}
return migrate()
}
// migrate ergänzt Spalten, die nach dem ersten Deploy dazukamen. CREATE TABLE IF
// NOT EXISTS fasst eine bestehende Tabelle nicht an -> neue Spalten brauchen ein
// ALTER. SQLite kann ADD COLUMN nicht "IF NOT EXISTS", deshalb schlucken wir den
// "duplicate column"-Fehler: bei einer frischen DB sind die Spalten schon aus
// dem Schema da, bei einer alten werden sie hier nachgezogen.
func migrate() error {
alters := []string{
`ALTER TABLE entry ADD COLUMN bump_count INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE entry ADD COLUMN last_bump INTEGER NOT NULL DEFAULT 0`,
}
for _, stmt := range alters {
if _, err := db.Exec(stmt); err != nil && !strings.Contains(err.Error(), "duplicate column") {
return err
}
}
return nil
// 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
}
+28 -5
View File
@@ -1,25 +1,48 @@
package main
import (
"database/sql"
"encoding/json"
"fmt"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"path/filepath"
"os"
"strings"
"testing"
"time"
"unicode/utf8"
)
// newTestServer richtet eine frische, isolierte DB (eigene Datei je Test) und
// einen httptest-Server mit dem echten Routing ein.
// newTestServer richtet ein frisches, isoliertes Schema (eigenes je Test) in der
// Datenbank aus KVER_TEST_DSN und einen httptest-Server mit dem echten Routing
// ein. Ohne KVER_TEST_DSN werden die Tests übersprungen.
func newTestServer(t *testing.T) *httptest.Server {
t.Helper()
dsn := filepath.Join(t.TempDir(), "test.db")
if err := initDB(dsn); err != nil {
base := os.Getenv("KVER_TEST_DSN")
if base == "" {
t.Skip("KVER_TEST_DSN nicht gesetzt")
}
admin, err := sql.Open("pgx", base)
if err != nil {
t.Fatal(err)
}
schema := fmt.Sprintf("test_%d", time.Now().UnixNano())
if _, err := admin.Exec(`CREATE SCHEMA ` + schema); err != nil {
t.Fatalf("schema anlegen: %v", err)
}
t.Cleanup(func() {
admin.Exec(`DROP SCHEMA ` + schema + ` CASCADE`)
admin.Close()
})
sep := "?"
if strings.Contains(base, "?") {
sep = "&"
}
if err := initDB(base + sep + "search_path=" + schema); err != nil {
t.Fatalf("initDB: %v", err)
}
t.Cleanup(func() { db.Close() })
+37 -107
View File
@@ -45,7 +45,7 @@ const entrySelect = `
e.reply_to, e.reply_count, e.last_activity, e.deleted,
e.bump_count, e.last_bump,
COALESCE(u.username, ''), COALESCE(u.avatar, '')
FROM entry e LEFT JOIN user u ON u.uid = e.uid`
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) {
@@ -66,7 +66,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 = ?`, pid).Scan(
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)
@@ -75,8 +75,8 @@ func entryByPID(pid int64) (Entry, error) {
const (
feedPageSize = 20
// maxFeedPage deckelt den Offset: sehr große Seitenzahlen zwingen SQLite
// sonst dazu, entsprechend viele Zeilen zu überspringen.
// maxFeedPage deckelt den Offset: sehr große Seitenzahlen zwingen die
// Datenbank sonst dazu, entsprechend viele Zeilen zu überspringen.
maxFeedPage = 100
)
@@ -85,8 +85,8 @@ func feedWhere(uid int64, rootsOnly bool) (string, []any) {
args := []any{}
where := []string{}
if uid != 0 {
where = append(where, "e.uid = ?")
args = append(args, uid)
where = append(where, fmt.Sprintf("e.uid = $%d", len(args)))
}
if rootsOnly {
where = append(where, "e.reply_to = 0")
@@ -115,7 +115,7 @@ func feedPage(page int, uid int64, rootsOnly bool) ([]Entry, error) {
} else {
query += ` ORDER BY e.created_at DESC`
}
query += ` LIMIT ? OFFSET ?`
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...)
@@ -138,7 +138,7 @@ func handleFeed(w http.ResponseWriter, r *http.Request) error {
// handleUserFeed liefert den Feed eines einzelnen Nutzers (öffentlich, inkl. Antworten).
func handleUserFeed(w http.ResponseWriter, r *http.Request) error {
var uid int64
if err := db.QueryRow(`SELECT uid FROM user WHERE username = ?`, chi.URLParam(r, "username")).Scan(&uid); err != nil {
if err := db.QueryRow(`SELECT uid FROM account WHERE username = $1`, chi.URLParam(r, "username")).Scan(&uid); err != nil {
return errUserNotFound
}
@@ -216,11 +216,11 @@ func threadData(pid int64) (thread, error) {
if entry.ReplyTo != 0 {
rows, err := db.Query(
`WITH RECURSIVE chain(pid, depth) AS (
SELECT ?, 1
SELECT $1::bigint, 1
UNION ALL
SELECT e.reply_to, c.depth + 1
FROM entry e JOIN chain c ON e.pid = c.pid
WHERE e.reply_to != 0 AND c.depth < 50
WHERE e.reply_to <> 0 AND c.depth < 50
)`+entrySelect+`
JOIN chain c ON c.pid = e.pid
ORDER BY c.depth DESC`, entry.ReplyTo)
@@ -232,7 +232,7 @@ func threadData(pid int64) (thread, error) {
}
}
rows, err := db.Query(entrySelect+` WHERE e.reply_to = ? ORDER BY e.last_activity DESC LIMIT 100`, pid)
rows, err := db.Query(entrySelect+` WHERE e.reply_to = $1 ORDER BY e.last_activity DESC LIMIT 100`, pid)
if err != nil {
return thread{}, Internal(err)
}
@@ -286,22 +286,28 @@ func handleCreateEntry(w http.ResponseWriter, r *http.Request) error {
return Internal(err)
}
// pid würfeln; +1, weil 0 der Sentinel für "kein Elternbeitrag"/"gelöschter
// Autor" ist. Bei der seltenen UNIQUE-Kollision neu würfeln statt 500.
// Autor" ist. Kollisionen über ON CONFLICT statt über den Fehler: ein
// fehlgeschlagenes Statement bricht in Postgres die ganze Transaktion ab.
var pid int64
for try := 0; ; try++ {
pid = int64(rand.IntN(999999999999)) + 1
_, err := tx.Exec(
res, err := tx.Exec(
`INSERT INTO entry (pid, uid, created_at, content, filepath, reply_to, reply_count, last_activity)
VALUES (?, ?, ?, ?, ?, ?, 0, ?)`,
VALUES ($1, $2, $3, $4, $5, $6, 0, $7)
ON CONFLICT (pid) DO NOTHING`,
pid, uid, now, content, filepath, replyTo, now,
)
if err == nil {
break
}
if try >= 4 || !strings.Contains(err.Error(), "UNIQUE") {
if err != nil {
tx.Rollback()
return Internal(err)
}
if n, _ := res.RowsAffected(); n == 1 {
break
}
if try >= 4 {
tx.Rollback()
return Internal(errors.New("pid-Kollision nach 5 Versuchen"))
}
}
// Antwort: reply_count und last_activity aller Vorfahren bis zum Root
// anheben -- per rekursiver CTE in einem Statement statt bis zu 50
@@ -310,14 +316,14 @@ func handleCreateEntry(w http.ResponseWriter, r *http.Request) error {
if replyTo != 0 {
if _, err := tx.Exec(
`WITH RECURSIVE chain(pid, depth) AS (
SELECT ?, 0
SELECT $1::bigint, 0
UNION ALL
SELECT e.reply_to, c.depth + 1
FROM entry e JOIN chain c ON e.pid = c.pid
WHERE e.reply_to != 0 AND c.depth < 50
WHERE e.reply_to <> 0 AND c.depth < 50
)
UPDATE entry SET reply_count = reply_count + 1,
last_activity = MAX(last_activity, ?)
last_activity = GREATEST(last_activity, $2)
WHERE pid IN (SELECT pid FROM chain)`,
replyTo, now,
); err != nil {
@@ -355,7 +361,7 @@ func handleEditEntry(w http.ResponseWriter, r *http.Request) error {
return Invalid("entry.empty", "Inhalt darf nicht leer sein.").At("content")
}
if _, err := db.Exec(`UPDATE entry SET content = ? WHERE pid = ?`, content, pid); err != nil {
if _, err := db.Exec(`UPDATE entry SET content = $1 WHERE pid = $2`, content, pid); err != nil {
return Internal(err)
}
@@ -395,7 +401,7 @@ func handleDeleteEntry(w http.ResponseWriter, r *http.Request) error {
}
if _, err := db.Exec(
`UPDATE entry SET deleted = 1, content = '', filepath = '', uid = 0 WHERE pid = ?`, pid,
`UPDATE entry SET deleted = 1, content = '', filepath = '', uid = 0 WHERE pid = $1`, pid,
); err != nil {
return Internal(err)
}
@@ -430,16 +436,16 @@ func handleBump(w http.ResponseWriter, r *http.Request) error {
// gleichzeitige Bumps den Cooldown nicht umgehen können.
now := time.Now().Unix()
res, err := db.Exec(
`UPDATE entry SET last_activity = ?, bump_count = bump_count + 1, last_bump = ?
WHERE pid = ? AND deleted = 0 AND last_bump + bump_count * ? <= ?`,
now, now, pid, bumpStep, now,
`UPDATE entry SET last_activity = $1, bump_count = bump_count + 1, last_bump = $1
WHERE pid = $2 AND deleted = 0 AND last_bump + bump_count * $3 <= $1`,
now, pid, bumpStep,
)
if err != nil {
return Internal(err)
}
var bumpCount, lastBump, deleted int64
if err := db.QueryRow(`SELECT bump_count, last_bump, deleted FROM entry WHERE pid = ?`, pid).
if err := db.QueryRow(`SELECT bump_count, last_bump, deleted FROM entry WHERE pid = $1`, pid).
Scan(&bumpCount, &lastBump, &deleted); err != nil || deleted != 0 {
return errEntryNotFound
}
@@ -480,12 +486,12 @@ func voteTally(pid, uid int64) (left, right int, selected string) {
selected = "none"
if uid != 0 {
var cur string
if err := db.QueryRow(`SELECT mode FROM vote WHERE uid = ? AND pid = ?`, uid, pid).Scan(&cur); err == nil {
if err := db.QueryRow(`SELECT mode FROM vote WHERE uid = $1 AND pid = $2`, uid, pid).Scan(&cur); err == nil {
selected = cur
}
}
// Beide Zähler in einem Query statt zwei COUNTs.
rows, err := db.Query(`SELECT mode, COUNT(*) FROM vote WHERE pid = ? GROUP BY mode`, pid)
rows, err := db.Query(`SELECT mode, COUNT(*) FROM vote WHERE pid = $1 GROUP BY mode`, pid)
if err != nil {
log.Printf("voteTally pid=%d: %v", pid, err)
return
@@ -508,82 +514,6 @@ func voteTally(pid, uid int64) (left, right int, selected string) {
return
}
// tally ist der Abstimmungsstand eines Beitrags aus Sicht eines Betrachters.
type tallyState struct {
Left int
Right int
Selected string // "left", "right" oder "none"
}
// voteTallies holt die Stände für eine ganze Feed-Seite in zwei Abfragen statt
// in zwei pro Beitrag. Bei 20 Beiträgen wären das sonst 40 Roundtrips für eine
// einzige Seitenansicht.
func voteTallies(pids []int64, uid int64) (map[int64]tallyState, error) {
out := make(map[int64]tallyState, len(pids))
for _, pid := range pids {
out[pid] = tallyState{Selected: "none"}
}
if len(pids) == 0 {
return out, nil
}
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(pids)), ",")
args := make([]any, 0, len(pids)+1)
for _, pid := range pids {
args = append(args, pid)
}
rows, err := db.Query(
`SELECT pid, mode, COUNT(*) FROM vote WHERE pid IN (`+placeholders+`) GROUP BY pid, mode`, args...)
if err != nil {
return nil, err
}
for rows.Next() {
var pid int64
var mode string
var n int
if err := rows.Scan(&pid, &mode, &n); err != nil {
rows.Close()
return nil, err
}
t := out[pid]
switch mode {
case "left":
t.Left = n
case "right":
t.Right = n
}
out[pid] = t
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
// Eigene Stimmen nur, wenn überhaupt jemand angemeldet ist.
if uid == 0 {
return out, nil
}
rows, err = db.Query(
`SELECT pid, mode FROM vote WHERE uid = ? AND pid IN (`+placeholders+`)`,
append([]any{uid}, args...)...)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var pid int64
var mode string
if err := rows.Scan(&pid, &mode); err != nil {
return nil, err
}
t := out[pid]
t.Selected = mode
out[pid] = t
}
return out, rows.Err()
}
func writeTally(w http.ResponseWriter, pid, uid int64) {
left, right, selected := voteTally(pid, uid)
writeJSON(w, http.StatusOK, map[string]any{
@@ -625,7 +555,7 @@ func handleReport(w http.ResponseWriter, r *http.Request) error {
}
reason := truncateRunes(strings.TrimSpace(r.FormValue("reason")), 500)
if _, err := db.Exec(
`INSERT INTO report (pid, uid, reason, created_at, handled) VALUES (?, 0, ?, ?, 0)`,
`INSERT INTO report (pid, uid, reason, created_at, handled) VALUES ($1, 0, $2, $3, 0)`,
pid, reason, time.Now().Unix(),
); err != nil {
return Internal(err)
@@ -660,13 +590,13 @@ func handleVote(w http.ResponseWriter, r *http.Request) error {
// (parallele Requests konnten den Toggle sonst doppelt anwenden):
// 1. Gleiche Stimme löschen -> hat das getroffen, war es ein Toggle-off.
// 2. Sonst Upsert: neue Stimme anlegen bzw. bestehende ummodeln.
res, err := db.Exec(`DELETE FROM vote WHERE uid = ? AND pid = ? AND mode = ?`, uid, pid, mode)
res, err := db.Exec(`DELETE FROM vote WHERE uid = $1 AND pid = $2 AND mode = $3`, uid, pid, mode)
if err != nil {
return Internal(err)
}
if n, _ := res.RowsAffected(); n == 0 {
if _, err := db.Exec(
`INSERT INTO vote (uid, pid, mode) VALUES (?, ?, ?)
`INSERT INTO vote (uid, pid, mode) VALUES ($1, $2, $3)
ON CONFLICT (uid, pid) DO UPDATE SET mode = excluded.mode`,
uid, pid, mode,
); err != nil {
+11 -5
View File
@@ -5,22 +5,28 @@ go 1.25.0
require (
github.com/go-chi/chi/v5 v5.3.0
github.com/go-chi/httprate v0.15.0
github.com/jackc/pgx/v5 v5.11.0
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.51.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.20 // 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/sys v0.45.0 // indirect
modernc.org/libc v1.72.3 // 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.11.0 // indirect
modernc.org/memory v1.12.1 // indirect
)
+42 -27
View File
@@ -1,3 +1,4 @@
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=
@@ -6,16 +7,24 @@ 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-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
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=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
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.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
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=
@@ -24,8 +33,11 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
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/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
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=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
@@ -34,41 +46,44 @@ 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.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
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.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
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.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
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.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
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.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
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.51.0 h1:aH/MMSoayAIhozZ7uJbVTT9QO/VhzBf0J9tymmmuC/U=
modernc.org/sqlite v1.51.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
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=
+135
View File
@@ -0,0 +1,135 @@
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},
{"report", "report", []string{"id", "pid", "uid", "reason", "created_at", "handled"}, true},
}
// 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 -1
View File
@@ -9,6 +9,9 @@
# KEINE Anführungszeichen um den Wert, keine Shell-Expansion ($VAR ist literal),
# `#` für Kommentare.
# Postgres-Verbindung. PFLICHT. Sonderzeichen im Passwort URL-kodieren.
KVER_DSN=postgres://kver:passwort@host:5432/kontrollverlust?sslmode=disable
# Basic-Auth für die Moderationsseite /moderation. PFLICHT: ohne diese beiden
# Werte bleibt /moderation gesperrt (HTTP 503, fail-closed). Langes Zufalls-
# passwort verwenden.
@@ -20,7 +23,6 @@ KVER_MOD_PASS=hier-ein-langes-zufallspasswort
# Selten nötig -- die Container-Defaults passen normalerweise:
#KVER_ADDR=:8080
#KVER_DB=/app/data/kver.db
# Optional: Verzeichnis mit web/ und static/ (die App macht dort ein chdir).
# Erlaubt den Start aus beliebigem Arbeitsverzeichnis.
+16 -3
View File
@@ -18,19 +18,32 @@ import (
func main() {
// KVER_DIR: Verzeichnis mit web/ und static/. Per Chdir werden alle
// relativen Pfade (auch Uploads und ein relativer KVER_DB-Pfad) auf einmal
// verankert -- die App ist damit nicht mehr an das Start-CWD gebunden.
// relativen Pfade (auch Uploads) auf einmal verankert -- die App ist damit
// nicht mehr an das Start-CWD gebunden.
if dir := os.Getenv("KVER_DIR"); dir != "" {
if err := os.Chdir(dir); err != nil {
log.Fatalf("KVER_DIR: %v", err)
}
}
if err := initDB(envOr("KVER_DB", "kver.db")); err != nil {
dsn := os.Getenv("KVER_DSN")
if dsn == "" {
log.Fatal("KVER_DSN fehlt (z. B. postgres://kver:passwort@host:5432/kontrollverlust)")
}
if err := initDB(dsn); err != nil {
log.Fatalf("db init: %v", err)
}
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()
+1 -1
View File
@@ -109,7 +109,7 @@ func handleModeration(w http.ResponseWriter, r *http.Request) error {
COALESCE(u.username, ''), COALESCE(e.content, '')
FROM report r
LEFT JOIN entry e ON e.pid = r.pid
LEFT JOIN user u ON u.uid = e.uid
LEFT JOIN account u ON u.uid = e.uid
ORDER BY r.handled ASC, r.created_at DESC`)
if err != nil {
return Internal(err)
+2 -36
View File
@@ -215,43 +215,9 @@ mitgelöscht, zugehörige Mediendateien best effort entfernt.
---
## Datenbankschema (SQLite)
## Datenbankschema
```sql
CREATE TABLE user (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uid INTEGER UNIQUE,
username TEXT UNIQUE,
password BLOB,
created_at INTEGER,
last_login INTEGER
);
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,
uid INTEGER,
created_at INTEGER,
content TEXT,
filepath TEXT
);
CREATE TABLE vote (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uid INTEGER,
pid INTEGER,
mode TEXT -- 'left' | 'right'
);
```
Postgres; maßgeblich ist die Konstante `schema` in `db.go`.
---
+41 -12
View File
@@ -27,8 +27,8 @@ Beim ersten Deployment: DB und Medien einmalig in die Volumes kopieren
podman build -t kver .
```
Multi-Stage-Build (`Containerfile`): statisches Go-Binary (CGO aus, modernc-
SQLite ist pures Go), Laufzeit-Image ist Alpine mit unprivilegiertem Nutzer.
Multi-Stage-Build (`Containerfile`): statisches Go-Binary (CGO aus), Laufzeit-
Image ist Alpine mit unprivilegiertem Nutzer.
Die Warnung `HEALTHCHECK is not supported for OCI image format` ist erwartbar:
Podman baut OCI-Images, dort gibt es kein eingebettetes HEALTHCHECK. Der
@@ -46,8 +46,9 @@ podman run -d --name kver \
kver
```
- `kver-data` hält die SQLite-DB (`/app/data/kver.db`), `kver-media` die
hochgeladenen Bilder. Beides Named Volumes → überleben Image-Updates.
- Die Daten liegen in Postgres (`KVER_DSN`). `kver-data` hält nur noch die
GeoLite2-ASN-DB (und die alte SQLite-Datei), `kver-media` die hochgeladenen
Bilder. Beides Named Volumes → überleben Image-Updates.
- Port nur an localhost binden; nach außen geht es über den Reverse-Proxy.
- Der Container lauscht intern immer auf `:8080`; der externe Port (hier 7777)
ist nur das Host-seitige Mapping.
@@ -57,7 +58,7 @@ Konfiguration über Umgebungsvariablen:
| Variable | Default | Bedeutung |
|-------------|---------------------|----------------------|
| `KVER_ADDR` | `:8080` | Listen-Adresse im Container |
| `KVER_DB` | `/app/data/kver.db` | Pfad zur SQLite-Datei |
| `KVER_DSN` | _(Pflicht)_ | Postgres-Verbindung, z. B. `postgres://kver:pw@host:5432/kontrollverlust?sslmode=disable` |
| `KVER_GEOIP_ASN` | _(leer)_ | Pfad zur GeoLite2-ASN-`.mmdb`; leer = ASN-Auflösung aus |
## GeoLite2-ASN-Datenbank (optional)
@@ -77,9 +78,40 @@ Dann `KVER_GEOIP_ASN=/app/data/GeoLite2-ASN.mmdb` setzen. Die Datei wird beim St
einmalig gemappt; für aktualisierte Daten die Datei ersetzen und den Dienst neu
starten.
## Bestehende Daten übernehmen
## Postgres
DB und Medien einmalig in die Volumes kopieren (Volumes müssen existieren;
Die App erwartet eine Datenbank, die ihrem Login-User gehört. Einmalig als
Admin:
```sql
CREATE ROLE kver LOGIN PASSWORD '...';
CREATE DATABASE kontrollverlust OWNER kver;
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;
Container kann laufen oder gestoppt sein):
```sh
@@ -88,14 +120,11 @@ podman volume create kver-media
# Rootless: podman unshare sorgt dafür, dass die UID ins User-Namespace-
# Mapping passt — ohne das bekommt der Container die Dateien nicht zu lesen.
podman unshare cp kver.db \
"$(podman volume inspect kver-data --format '{{.Mountpoint}}')/"
podman unshare cp -r static/media/. \
"$(podman volume inspect kver-media --format '{{.Mountpoint}}')/"
```
Danach Container (neu) starten. Die App legt beim ersten Start alle fehlenden
Tabellen und Indizes automatisch per `CREATE ... IF NOT EXISTS` an.
Danach Container (neu) starten.
## Reverse-Proxy (TLS) — Caddy
@@ -201,4 +230,4 @@ systemctl --user restart kver
```
Die App fährt bei SIGTERM sauber herunter (laufende Requests werden zu Ende
bedient, WAL-Checkpoint beim DB-Close).
bedient, dann wird die DB-Verbindung geschlossen).
+7 -7
View File
@@ -192,7 +192,7 @@ func stopImpressionWorker() {
// Netz. Gezielt nur auf den HTML-Seiten-Routen, nicht auf API oder Assets.
//
// Die Erfassung läuft asynchron über den Impression-Worker: der Seitenaufruf
// darf nicht auf den DB-Schreib warten (SQLite serialisiert Schreiber). r ist
// darf nicht auf den DB-Schreibvorgang warten. r ist
// nach Rückkehr des Handlers nicht mehr sicher nutzbar -> wir kopieren die zwei
// Werte vorher heraus.
func trackImpression(next http.Handler) http.Handler {
@@ -214,8 +214,8 @@ func recordImpression(path, anonIP, asn string) {
}
day := time.Now().UTC().Format("2006-01-02")
db.Exec(
`INSERT INTO impression (day, anon_ip, path, asn, hits) VALUES (?, ?, ?, ?, 1)
ON CONFLICT(day, anon_ip, path) DO UPDATE SET hits = hits + 1`,
`INSERT INTO impression (day, anon_ip, path, asn, hits) VALUES ($1, $2, $3, $4, 1)
ON CONFLICT (day, anon_ip, path) DO UPDATE SET hits = impression.hits + 1`,
day, anonIP, path, asn,
)
}
@@ -225,21 +225,21 @@ func recordImpression(path, anonIP, asn string) {
// rechnen. Öffentlich, kein Auth.
func handleStatsDetail(w http.ResponseWriter, r *http.Request) {
var users, entries, toplevel, votesLeft, votesRight int64
db.QueryRow(`SELECT COUNT(*) FROM user`).Scan(&users)
db.QueryRow(`SELECT COUNT(*) FROM account`).Scan(&users)
db.QueryRow(`SELECT COUNT(*) FROM entry WHERE deleted = 0`).Scan(&entries)
db.QueryRow(`SELECT COUNT(*) FROM entry WHERE deleted = 0 AND reply_to = 0`).Scan(&toplevel)
db.QueryRow(`SELECT COUNT(*) FROM vote WHERE mode = 'left'`).Scan(&votesLeft)
db.QueryRow(`SELECT COUNT(*) FROM vote WHERE mode = 'right'`).Scan(&votesRight)
var imprTotal, visitorsTotal int64
db.QueryRow(`SELECT COALESCE(SUM(hits), 0), COUNT(DISTINCT anon_ip) FROM impression`).
db.QueryRow(`SELECT COALESCE(SUM(hits), 0)::bigint, COUNT(DISTINCT anon_ip) FROM impression`).
Scan(&imprTotal, &visitorsTotal)
// Tägliche Impressions + grobe Unique-Netze, jüngste 30 Tage zuerst. Das
// Frontend dreht für die Achse auf aufsteigend.
daily := []map[string]any{}
if rows, err := db.Query(
`SELECT day, SUM(hits), COUNT(DISTINCT anon_ip)
`SELECT day, SUM(hits)::bigint, COUNT(DISTINCT anon_ip)
FROM impression
GROUP BY day
ORDER BY day DESC
@@ -257,7 +257,7 @@ func handleStatsDetail(w http.ResponseWriter, r *http.Request) {
topASNs := []map[string]any{}
if rows, err := db.Query(
`SELECT CASE WHEN asn = '' THEN '(unbekannt)' ELSE asn END AS a,
SUM(hits) AS h,
SUM(hits)::bigint AS h,
COUNT(DISTINCT anon_ip)
FROM impression
GROUP BY a
+21 -19
View File
@@ -5,7 +5,6 @@ import (
"math/rand/v2"
"net/http"
"os"
"strings"
"time"
"github.com/go-chi/chi/v5"
@@ -37,7 +36,7 @@ func createUser(username, password string) (int64, error) {
for i := 0; i < 5; i++ {
uid := int64(rand.IntN(99999999)) + 1
_, lastErr = db.Exec(
`INSERT INTO user (uid, created_at, username, password) VALUES (?, ?, ?, ?)`,
`INSERT INTO account (uid, created_at, username, password) VALUES ($1, $2, $3, $4)`,
uid, now, username, hash,
)
if lastErr == nil {
@@ -45,9 +44,12 @@ func createUser(username, password string) (int64, error) {
}
// username ist ebenfalls UNIQUE: hat DER kollidiert (Rennen mit der
// Vorab-Prüfung des Aufrufers), hilft kein Retry -> gezielt melden.
if strings.Contains(lastErr.Error(), "user.username") {
if isUniqueViolation(lastErr, "account_username_key") {
return 0, errUsernameTaken
}
if !isUniqueViolation(lastErr, "account_uid_key") {
return 0, lastErr
}
}
return 0, lastErr
}
@@ -55,7 +57,7 @@ func createUser(username, password string) (int64, error) {
// 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 user WHERE username = ?`, username).Scan(&id); err != nil {
if err := db.QueryRow(`SELECT id FROM account WHERE username = $1`, username).Scan(&id); err != nil {
return 0
}
return id
@@ -72,7 +74,7 @@ func handleUserPage(w http.ResponseWriter, r *http.Request) error {
var uid, createdAt int64
var avatar string
err := db.QueryRow(`SELECT uid, created_at, avatar FROM user WHERE username = ?`, username).Scan(&uid, &createdAt, &avatar)
err := db.QueryRow(`SELECT uid, created_at, avatar FROM account WHERE username = $1`, username).Scan(&uid, &createdAt, &avatar)
if err != nil {
return errUserNotFound
}
@@ -89,7 +91,7 @@ func handleUserPage(w http.ResponseWriter, r *http.Request) error {
// registrierte Nutzer und nicht-gelöschte Beiträge.
func handleStats(w http.ResponseWriter, r *http.Request) {
var users, entries int64
db.QueryRow(`SELECT COUNT(*) FROM user`).Scan(&users)
db.QueryRow(`SELECT COUNT(*) FROM account`).Scan(&users)
db.QueryRow(`SELECT COUNT(*) FROM entry WHERE deleted = 0`).Scan(&entries)
writeJSON(w, http.StatusOK, map[string]any{
"users": users,
@@ -103,7 +105,7 @@ func handleUserInfo(w http.ResponseWriter, r *http.Request) error {
var createdAt, lastLogin sql.NullInt64
var username, avatar string
err := db.QueryRow(
`SELECT created_at, last_login, username, avatar FROM user WHERE uid = ?`, uid,
`SELECT created_at, last_login, username, avatar FROM account WHERE uid = $1`, uid,
).Scan(&createdAt, &lastLogin, &username, &avatar)
if err != nil {
return errUserNotFound
@@ -129,7 +131,7 @@ func handleUserRename(w http.ResponseWriter, r *http.Request) error {
}
var current string
if err := db.QueryRow(`SELECT username FROM user WHERE uid = ?`, uid).Scan(&current); err != nil {
if err := db.QueryRow(`SELECT username FROM account WHERE uid = $1`, uid).Scan(&current); err != nil {
return errUserNotFound
}
if name == current {
@@ -139,9 +141,9 @@ func handleUserRename(w http.ResponseWriter, r *http.Request) error {
return errUsernameTaken
}
if _, err := db.Exec(`UPDATE user SET username = ? WHERE uid = ?`, name, uid); err != nil {
if _, err := db.Exec(`UPDATE account SET username = $1 WHERE uid = $2`, name, uid); err != nil {
// Rennen zwischen Vorab-Prüfung und UPDATE: UNIQUE-Constraint -> 409.
if strings.Contains(err.Error(), "user.username") {
if isUniqueViolation(err, "account_username_key") {
return errUsernameTaken
}
return Internal(err)
@@ -171,9 +173,9 @@ func handleSetAvatar(w http.ResponseWriter, r *http.Request) error {
}
var old string
db.QueryRow(`SELECT avatar FROM user WHERE uid = ?`, uid).Scan(&old)
db.QueryRow(`SELECT avatar FROM account WHERE uid = $1`, uid).Scan(&old)
if _, err := db.Exec(`UPDATE user SET avatar = ? WHERE uid = ?`, stored, uid); err != nil {
if _, err := db.Exec(`UPDATE account SET avatar = $1 WHERE uid = $2`, stored, uid); err != nil {
os.Remove(stored)
return Internal(err)
}
@@ -193,7 +195,7 @@ func handleUserDelete(w http.ResponseWriter, r *http.Request) error {
uid := uidFromContext(r.Context())
var hash []byte
if err := db.QueryRow(`SELECT password FROM user WHERE uid = ?`, uid).Scan(&hash); err != nil {
if err := db.QueryRow(`SELECT password FROM account WHERE uid = $1`, uid).Scan(&hash); err != nil {
return errUserNotFound
}
@@ -204,7 +206,7 @@ func handleUserDelete(w http.ResponseWriter, r *http.Request) error {
// Mediendateien der Beiträge (und das Profilbild) einsammeln, bevor die
// Pfade geleert bzw. die user-Zeile gelöscht wird.
var media []string
if rows, err := db.Query(`SELECT filepath FROM entry WHERE uid = ?`, uid); err == nil {
if rows, err := db.Query(`SELECT filepath FROM entry WHERE uid = $1`, uid); err == nil {
for rows.Next() {
var fp string
if rows.Scan(&fp) == nil && fp != "" {
@@ -214,7 +216,7 @@ func handleUserDelete(w http.ResponseWriter, r *http.Request) error {
rows.Close()
}
var avatar string
if db.QueryRow(`SELECT avatar FROM user WHERE uid = ?`, uid).Scan(&avatar) == nil && avatar != "" {
if db.QueryRow(`SELECT avatar FROM account WHERE uid = $1`, uid).Scan(&avatar) == nil && avatar != "" {
media = append(media, avatar)
}
@@ -223,10 +225,10 @@ func handleUserDelete(w http.ResponseWriter, r *http.Request) error {
return Internal(err)
}
for _, q := range []string{
`DELETE FROM session WHERE uid = ?`,
`DELETE FROM vote WHERE uid = ?`,
`UPDATE entry SET deleted = 1, content = '', filepath = '', uid = 0 WHERE uid = ?`,
`DELETE FROM user WHERE uid = ?`,
`DELETE FROM session WHERE uid = $1`,
`DELETE FROM vote WHERE uid = $1`,
`UPDATE entry SET deleted = 1, content = '', filepath = '', uid = 0 WHERE uid = $1`,
`DELETE FROM account WHERE uid = $1`,
} {
if _, err := tx.Exec(q, uid); err != nil {
tx.Rollback()