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 FROM docker.io/library/golang:1.26-alpine AS build
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
@@ -13,10 +14,10 @@ WORKDIR /app
COPY --from=build /kver /usr/local/bin/kver COPY --from=build /kver /usr/local/bin/kver
COPY web/ web/ COPY web/ web/
COPY static/ static/ 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 RUN mkdir -p data static/media && chown kver:kver data static/media
USER kver USER kver
ENV KVER_DB=/app/data/kver.db
ENV KVER_ADDR=:8080 ENV KVER_ADDR=:8080
EXPOSE 8080 EXPOSE 8080
VOLUME ["/app/data", "/app/static/media"] 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} s := Session{Value: c.Value}
err = db.QueryRow( 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, c.Value,
).Scan(&s.UID, &s.CreatedAt, &s.Expires, &s.Description) ).Scan(&s.UID, &s.CreatedAt, &s.Expires, &s.Description)
if err != nil { if err != nil {
@@ -47,7 +47,7 @@ func getSession(r *http.Request) (*Session, bool) {
} }
if s.Expires < time.Now().Unix() { 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 return nil, false
} }
@@ -73,7 +73,7 @@ func handleLogin(w http.ResponseWriter, r *http.Request) error {
// wird der Endpunkt zum Nutzernamen-Orakel. // wird der Endpunkt zum Nutzernamen-Orakel.
var uid int64 var uid int64
var hash []byte 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 { if err == sql.ErrNoRows {
return errBadCredentials return errBadCredentials
} else if err != nil { } else if err != nil {
@@ -112,14 +112,14 @@ func startSession(w http.ResponseWriter, r *http.Request, uid int64) error {
return Internal(err) return Internal(err)
} }
db.Exec(`DELETE FROM session WHERE expires < ?`, now) db.Exec(`DELETE FROM session WHERE expires < $1`, now)
if _, err := db.Exec( 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, uid, now, expires, "", token,
); err != nil { ); err != nil {
return Internal(err) 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{ http.SetCookie(w, &http.Cookie{
Name: "session", 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) { func handleLogout(w http.ResponseWriter, r *http.Request) {
if s, ok := getSession(r); ok { 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{ http.SetCookie(w, &http.Cookie{
Name: "session", Name: "session",
+47 -58
View File
@@ -2,54 +2,58 @@ package main
import ( import (
"database/sql" "database/sql"
"strings" "errors"
_ "modernc.org/sqlite" "github.com/jackc/pgx/v5/pgconn"
_ "github.com/jackc/pgx/v5/stdlib"
) )
var db *sql.DB 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 = ` const schema = `
CREATE TABLE IF NOT EXISTS user ( CREATE TABLE IF NOT EXISTS account (
id INTEGER PRIMARY KEY AUTOINCREMENT, id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
uid INTEGER UNIQUE, uid BIGINT NOT NULL UNIQUE,
username TEXT UNIQUE, username TEXT NOT NULL UNIQUE,
password BLOB, password BYTEA NOT NULL,
created_at INTEGER, created_at BIGINT NOT NULL,
last_login INTEGER, last_login BIGINT,
avatar TEXT NOT NULL DEFAULT '' avatar TEXT NOT NULL DEFAULT ''
); );
CREATE TABLE IF NOT EXISTS session ( CREATE TABLE IF NOT EXISTS session (
id INTEGER PRIMARY KEY AUTOINCREMENT, id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
uid INTEGER, uid BIGINT NOT NULL,
value TEXT UNIQUE, value TEXT NOT NULL UNIQUE,
created_at INTEGER, created_at BIGINT NOT NULL,
expires INTEGER, expires BIGINT NOT NULL,
description TEXT description TEXT NOT NULL DEFAULT ''
); );
CREATE TABLE IF NOT EXISTS entry ( CREATE TABLE IF NOT EXISTS entry (
id INTEGER PRIMARY KEY AUTOINCREMENT, id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
pid INTEGER UNIQUE NOT NULL, pid BIGINT NOT NULL UNIQUE,
uid INTEGER NOT NULL, uid BIGINT NOT NULL,
created_at INTEGER NOT NULL, created_at BIGINT NOT NULL,
content TEXT NOT NULL DEFAULT '', content TEXT NOT NULL DEFAULT '',
filepath TEXT NOT NULL DEFAULT '', filepath TEXT NOT NULL DEFAULT '',
reply_to INTEGER NOT NULL DEFAULT 0, reply_to BIGINT NOT NULL DEFAULT 0,
reply_count INTEGER NOT NULL DEFAULT 0, reply_count BIGINT NOT NULL DEFAULT 0,
last_activity INTEGER NOT NULL DEFAULT 0, last_activity BIGINT NOT NULL DEFAULT 0,
deleted INTEGER NOT NULL DEFAULT 0, deleted BIGINT NOT NULL DEFAULT 0,
bump_count INTEGER NOT NULL DEFAULT 0, bump_count BIGINT NOT NULL DEFAULT 0,
last_bump INTEGER NOT NULL DEFAULT 0 last_bump BIGINT NOT NULL DEFAULT 0
); );
CREATE TABLE IF NOT EXISTS vote ( CREATE TABLE IF NOT EXISTS vote (
id INTEGER PRIMARY KEY AUTOINCREMENT, id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
uid INTEGER, uid BIGINT NOT NULL,
pid INTEGER, pid BIGINT NOT NULL,
mode TEXT, mode TEXT NOT NULL,
UNIQUE(uid, pid) UNIQUE (uid, pid)
); );
-- Seitenaufrufe, aggregiert pro (Tag, anonymisiertes Netz, Pfad). Es wird nie -- Seitenaufrufe, aggregiert pro (Tag, anonymisiertes Netz, Pfad). Es wird nie
@@ -63,7 +67,7 @@ CREATE TABLE IF NOT EXISTS impression (
anon_ip TEXT NOT NULL, anon_ip TEXT NOT NULL,
path TEXT NOT NULL DEFAULT '', path TEXT NOT NULL DEFAULT '',
asn 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) 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, -- Absender nachziehen zu können, ohne das Schema zu ändern. handled: 0 offen,
-- 1 von der Moderation erledigt. reason ist ein optionaler Freitext. -- 1 von der Moderation erledigt. reason ist ein optionaler Freitext.
CREATE TABLE IF NOT EXISTS report ( CREATE TABLE IF NOT EXISTS report (
id INTEGER PRIMARY KEY AUTOINCREMENT, id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
pid INTEGER NOT NULL, pid BIGINT NOT NULL,
uid INTEGER NOT NULL DEFAULT 0, uid BIGINT NOT NULL DEFAULT 0,
reason TEXT NOT NULL DEFAULT '', reason TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL, created_at BIGINT NOT NULL,
handled INTEGER NOT NULL DEFAULT 0 handled BIGINT NOT NULL DEFAULT 0
); );
-- Indizes für die häufigen Zugriffspfade; ohne sie werden Feed (ORDER BY -- 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 { func initDB(dsn string) error {
var err error var err error
// SQLite hat genau einen Schreiber: busy_timeout wartet bei Sperren, statt db, err = sql.Open("pgx", dsn)
// 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)")
if err != nil { if err != nil {
return err return err
} }
if err = db.Ping(); err != nil { if err = db.Ping(); err != nil {
return err return err
} }
if _, err = db.Exec(schema); err != nil { _, err = db.Exec(schema)
return err return err
}
return migrate()
} }
// migrate ergänzt Spalten, die nach dem ersten Deploy dazukamen. CREATE TABLE IF // isUniqueViolation meldet, ob err am UNIQUE-Constraint constraint scheiterte
// NOT EXISTS fasst eine bestehende Tabelle nicht an -> neue Spalten brauchen ein // (Postgres-Namen nach Schema <tabelle>_<spalte>_key, z. B. account_username_key).
// ALTER. SQLite kann ADD COLUMN nicht "IF NOT EXISTS", deshalb schlucken wir den func isUniqueViolation(err error, constraint string) bool {
// "duplicate column"-Fehler: bei einer frischen DB sind die Spalten schon aus var pgErr *pgconn.PgError
// dem Schema da, bei einer alten werden sie hier nachgezogen. return errors.As(err, &pgErr) && pgErr.Code == "23505" && pgErr.ConstraintName == constraint
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
} }
+28 -5
View File
@@ -1,25 +1,48 @@
package main package main
import ( import (
"database/sql"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"net/http/cookiejar" "net/http/cookiejar"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
"path/filepath" "os"
"strings" "strings"
"testing" "testing"
"time"
"unicode/utf8" "unicode/utf8"
) )
// newTestServer richtet eine frische, isolierte DB (eigene Datei je Test) und // newTestServer richtet ein frisches, isoliertes Schema (eigenes je Test) in der
// einen httptest-Server mit dem echten Routing ein. // 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 { func newTestServer(t *testing.T) *httptest.Server {
t.Helper() t.Helper()
dsn := filepath.Join(t.TempDir(), "test.db") base := os.Getenv("KVER_TEST_DSN")
if err := initDB(dsn); err != nil { 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.Fatalf("initDB: %v", err)
} }
t.Cleanup(func() { db.Close() }) t.Cleanup(func() { db.Close() })
+38 -108
View File
@@ -45,7 +45,7 @@ const entrySelect = `
e.reply_to, e.reply_count, e.last_activity, e.deleted, e.reply_to, e.reply_count, e.last_activity, e.deleted,
e.bump_count, e.last_bump, e.bump_count, e.last_bump,
COALESCE(u.username, ''), COALESCE(u.avatar, '') 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. // scanEntries liest Entry-Zeilen aus einem Query mit entrySelect-Spalten.
func scanEntries(rows *sql.Rows) ([]Entry, error) { 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. // entryByPID liefert einen einzelnen Beitrag oder sql.ErrNoRows.
func entryByPID(pid int64) (Entry, error) { func entryByPID(pid int64) (Entry, error) {
var e Entry 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.PID, &e.UID, &e.CreatedAt, &e.Content, &e.Filepath,
&e.ReplyTo, &e.ReplyCount, &e.LastActivity, &e.Deleted, &e.ReplyTo, &e.ReplyCount, &e.LastActivity, &e.Deleted,
&e.BumpCount, &e.LastBump, &e.Username, &e.Avatar) &e.BumpCount, &e.LastBump, &e.Username, &e.Avatar)
@@ -75,8 +75,8 @@ func entryByPID(pid int64) (Entry, error) {
const ( const (
feedPageSize = 20 feedPageSize = 20
// maxFeedPage deckelt den Offset: sehr große Seitenzahlen zwingen SQLite // maxFeedPage deckelt den Offset: sehr große Seitenzahlen zwingen die
// sonst dazu, entsprechend viele Zeilen zu überspringen. // Datenbank sonst dazu, entsprechend viele Zeilen zu überspringen.
maxFeedPage = 100 maxFeedPage = 100
) )
@@ -85,8 +85,8 @@ func feedWhere(uid int64, rootsOnly bool) (string, []any) {
args := []any{} args := []any{}
where := []string{} where := []string{}
if uid != 0 { if uid != 0 {
where = append(where, "e.uid = ?")
args = append(args, uid) args = append(args, uid)
where = append(where, fmt.Sprintf("e.uid = $%d", len(args)))
} }
if rootsOnly { if rootsOnly {
where = append(where, "e.reply_to = 0") where = append(where, "e.reply_to = 0")
@@ -115,7 +115,7 @@ func feedPage(page int, uid int64, rootsOnly bool) ([]Entry, error) {
} else { } else {
query += ` ORDER BY e.created_at DESC` 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) args = append(args, feedPageSize, page*feedPageSize)
rows, err := db.Query(query, args...) 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). // handleUserFeed liefert den Feed eines einzelnen Nutzers (öffentlich, inkl. Antworten).
func handleUserFeed(w http.ResponseWriter, r *http.Request) error { func handleUserFeed(w http.ResponseWriter, r *http.Request) error {
var uid int64 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 return errUserNotFound
} }
@@ -216,11 +216,11 @@ func threadData(pid int64) (thread, error) {
if entry.ReplyTo != 0 { if entry.ReplyTo != 0 {
rows, err := db.Query( rows, err := db.Query(
`WITH RECURSIVE chain(pid, depth) AS ( `WITH RECURSIVE chain(pid, depth) AS (
SELECT ?, 1 SELECT $1::bigint, 1
UNION ALL UNION ALL
SELECT e.reply_to, c.depth + 1 SELECT e.reply_to, c.depth + 1
FROM entry e JOIN chain c ON e.pid = c.pid 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+` )`+entrySelect+`
JOIN chain c ON c.pid = e.pid JOIN chain c ON c.pid = e.pid
ORDER BY c.depth DESC`, entry.ReplyTo) 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 { if err != nil {
return thread{}, Internal(err) return thread{}, Internal(err)
} }
@@ -286,22 +286,28 @@ func handleCreateEntry(w http.ResponseWriter, r *http.Request) error {
return Internal(err) return Internal(err)
} }
// pid würfeln; +1, weil 0 der Sentinel für "kein Elternbeitrag"/"gelöschter // 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 var pid int64
for try := 0; ; try++ { for try := 0; ; try++ {
pid = int64(rand.IntN(999999999999)) + 1 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) `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, pid, uid, now, content, filepath, replyTo, now,
) )
if err == nil { if err != nil {
break
}
if try >= 4 || !strings.Contains(err.Error(), "UNIQUE") {
tx.Rollback() tx.Rollback()
return Internal(err) 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 // Antwort: reply_count und last_activity aller Vorfahren bis zum Root
// anheben -- per rekursiver CTE in einem Statement statt bis zu 50 // 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 replyTo != 0 {
if _, err := tx.Exec( if _, err := tx.Exec(
`WITH RECURSIVE chain(pid, depth) AS ( `WITH RECURSIVE chain(pid, depth) AS (
SELECT ?, 0 SELECT $1::bigint, 0
UNION ALL UNION ALL
SELECT e.reply_to, c.depth + 1 SELECT e.reply_to, c.depth + 1
FROM entry e JOIN chain c ON e.pid = c.pid 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, 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)`, WHERE pid IN (SELECT pid FROM chain)`,
replyTo, now, replyTo, now,
); err != nil { ); 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") 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) return Internal(err)
} }
@@ -395,7 +401,7 @@ func handleDeleteEntry(w http.ResponseWriter, r *http.Request) error {
} }
if _, err := db.Exec( 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 { ); err != nil {
return Internal(err) return Internal(err)
} }
@@ -430,16 +436,16 @@ func handleBump(w http.ResponseWriter, r *http.Request) error {
// gleichzeitige Bumps den Cooldown nicht umgehen können. // gleichzeitige Bumps den Cooldown nicht umgehen können.
now := time.Now().Unix() now := time.Now().Unix()
res, err := db.Exec( res, err := db.Exec(
`UPDATE entry SET last_activity = ?, bump_count = bump_count + 1, last_bump = ? `UPDATE entry SET last_activity = $1, bump_count = bump_count + 1, last_bump = $1
WHERE pid = ? AND deleted = 0 AND last_bump + bump_count * ? <= ?`, WHERE pid = $2 AND deleted = 0 AND last_bump + bump_count * $3 <= $1`,
now, now, pid, bumpStep, now, now, pid, bumpStep,
) )
if err != nil { if err != nil {
return Internal(err) return Internal(err)
} }
var bumpCount, lastBump, deleted int64 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 { Scan(&bumpCount, &lastBump, &deleted); err != nil || deleted != 0 {
return errEntryNotFound return errEntryNotFound
} }
@@ -480,12 +486,12 @@ func voteTally(pid, uid int64) (left, right int, selected string) {
selected = "none" selected = "none"
if uid != 0 { if uid != 0 {
var cur string 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 selected = cur
} }
} }
// Beide Zähler in einem Query statt zwei COUNTs. // 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 { if err != nil {
log.Printf("voteTally pid=%d: %v", pid, err) log.Printf("voteTally pid=%d: %v", pid, err)
return return
@@ -508,82 +514,6 @@ func voteTally(pid, uid int64) (left, right int, selected string) {
return 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) { func writeTally(w http.ResponseWriter, pid, uid int64) {
left, right, selected := voteTally(pid, uid) left, right, selected := voteTally(pid, uid)
writeJSON(w, http.StatusOK, map[string]any{ 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) reason := truncateRunes(strings.TrimSpace(r.FormValue("reason")), 500)
if _, err := db.Exec( 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(), pid, reason, time.Now().Unix(),
); err != nil { ); err != nil {
return Internal(err) return Internal(err)
@@ -660,14 +590,14 @@ func handleVote(w http.ResponseWriter, r *http.Request) error {
// (parallele Requests konnten den Toggle sonst doppelt anwenden): // (parallele Requests konnten den Toggle sonst doppelt anwenden):
// 1. Gleiche Stimme löschen -> hat das getroffen, war es ein Toggle-off. // 1. Gleiche Stimme löschen -> hat das getroffen, war es ein Toggle-off.
// 2. Sonst Upsert: neue Stimme anlegen bzw. bestehende ummodeln. // 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 { if err != nil {
return Internal(err) return Internal(err)
} }
if n, _ := res.RowsAffected(); n == 0 { if n, _ := res.RowsAffected(); n == 0 {
if _, err := db.Exec( 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`, ON CONFLICT (uid, pid) DO UPDATE SET mode = excluded.mode`,
uid, pid, mode, uid, pid, mode,
); err != nil { ); err != nil {
return Internal(err) return Internal(err)
+11 -5
View File
@@ -5,22 +5,28 @@ go 1.25.0
require ( require (
github.com/go-chi/chi/v5 v5.3.0 github.com/go-chi/chi/v5 v5.3.0
github.com/go-chi/httprate v0.15.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 github.com/oschwald/maxminddb-golang v1.13.1
golang.org/x/crypto v0.52.0 golang.org/x/crypto v0.52.0
golang.org/x/image v0.41.0 golang.org/x/image v0.41.0
modernc.org/sqlite v1.51.0 modernc.org/sqlite v1.59.0
) )
require ( require (
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // 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/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/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect
golang.org/x/sys v0.45.0 // indirect golang.org/x/sync v0.22.0 // indirect
modernc.org/libc v1.72.3 // 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/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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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= 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/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 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g=
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4= 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-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= 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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 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 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 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.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 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= 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/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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 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/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 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 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= 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/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 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= 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.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= 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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ= modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w=
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A= 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 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= 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 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= 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.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= 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 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= modernc.org/libc v1.75.7 h1:o3DTP9/0p9pKmY2WCKQaySW6wIiZhNM7wc2lUoyhfew=
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= 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 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= 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.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= 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 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= 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 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= 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.59.0 h1:X1es1GpqBlS/5T+vbM4HLUdaa8OtQx468DF2vrx+38A=
modernc.org/sqlite v1.51.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= 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 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= 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), # KEINE Anführungszeichen um den Wert, keine Shell-Expansion ($VAR ist literal),
# `#` für Kommentare. # `#` 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 # Basic-Auth für die Moderationsseite /moderation. PFLICHT: ohne diese beiden
# Werte bleibt /moderation gesperrt (HTTP 503, fail-closed). Langes Zufalls- # Werte bleibt /moderation gesperrt (HTTP 503, fail-closed). Langes Zufalls-
# passwort verwenden. # passwort verwenden.
@@ -20,7 +23,6 @@ KVER_MOD_PASS=hier-ein-langes-zufallspasswort
# Selten nötig -- die Container-Defaults passen normalerweise: # Selten nötig -- die Container-Defaults passen normalerweise:
#KVER_ADDR=:8080 #KVER_ADDR=:8080
#KVER_DB=/app/data/kver.db
# Optional: Verzeichnis mit web/ und static/ (die App macht dort ein chdir). # Optional: Verzeichnis mit web/ und static/ (die App macht dort ein chdir).
# Erlaubt den Start aus beliebigem Arbeitsverzeichnis. # Erlaubt den Start aus beliebigem Arbeitsverzeichnis.
+16 -3
View File
@@ -18,19 +18,32 @@ import (
func main() { func main() {
// KVER_DIR: Verzeichnis mit web/ und static/. Per Chdir werden alle // KVER_DIR: Verzeichnis mit web/ und static/. Per Chdir werden alle
// relativen Pfade (auch Uploads und ein relativer KVER_DB-Pfad) auf einmal // relativen Pfade (auch Uploads) auf einmal verankert -- die App ist damit
// verankert -- die App ist damit nicht mehr an das Start-CWD gebunden. // nicht mehr an das Start-CWD gebunden.
if dir := os.Getenv("KVER_DIR"); dir != "" { if dir := os.Getenv("KVER_DIR"); dir != "" {
if err := os.Chdir(dir); err != nil { if err := os.Chdir(dir); err != nil {
log.Fatalf("KVER_DIR: %v", err) 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) log.Fatalf("db init: %v", err)
} }
defer db.Close() 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 // Optionale GeoLite2-ASN-Datenbank für die netzwerktopologische Einordnung
// der Impressions. Inaktiv, wenn KVER_GEOIP_ASN nicht gesetzt ist. // der Impressions. Inaktiv, wenn KVER_GEOIP_ASN nicht gesetzt ist.
initGeoASN() initGeoASN()
+1 -1
View File
@@ -109,7 +109,7 @@ func handleModeration(w http.ResponseWriter, r *http.Request) error {
COALESCE(u.username, ''), COALESCE(e.content, '') COALESCE(u.username, ''), COALESCE(e.content, '')
FROM report r FROM report r
LEFT JOIN entry e ON e.pid = r.pid 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`) ORDER BY r.handled ASC, r.created_at DESC`)
if err != nil { if err != nil {
return Internal(err) return Internal(err)
+2 -36
View File
@@ -215,43 +215,9 @@ mitgelöscht, zugehörige Mediendateien best effort entfernt.
--- ---
## Datenbankschema (SQLite) ## Datenbankschema
```sql Postgres; maßgeblich ist die Konstante `schema` in `db.go`.
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'
);
```
--- ---
+41 -12
View File
@@ -27,8 +27,8 @@ Beim ersten Deployment: DB und Medien einmalig in die Volumes kopieren
podman build -t kver . podman build -t kver .
``` ```
Multi-Stage-Build (`Containerfile`): statisches Go-Binary (CGO aus, modernc- Multi-Stage-Build (`Containerfile`): statisches Go-Binary (CGO aus), Laufzeit-
SQLite ist pures Go), Laufzeit-Image ist Alpine mit unprivilegiertem Nutzer. Image ist Alpine mit unprivilegiertem Nutzer.
Die Warnung `HEALTHCHECK is not supported for OCI image format` ist erwartbar: Die Warnung `HEALTHCHECK is not supported for OCI image format` ist erwartbar:
Podman baut OCI-Images, dort gibt es kein eingebettetes HEALTHCHECK. Der Podman baut OCI-Images, dort gibt es kein eingebettetes HEALTHCHECK. Der
@@ -46,8 +46,9 @@ podman run -d --name kver \
kver kver
``` ```
- `kver-data` hält die SQLite-DB (`/app/data/kver.db`), `kver-media` die - Die Daten liegen in Postgres (`KVER_DSN`). `kver-data` hält nur noch die
hochgeladenen Bilder. Beides Named Volumes → überleben Image-Updates. 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. - 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) - Der Container lauscht intern immer auf `:8080`; der externe Port (hier 7777)
ist nur das Host-seitige Mapping. ist nur das Host-seitige Mapping.
@@ -57,7 +58,7 @@ Konfiguration über Umgebungsvariablen:
| Variable | Default | Bedeutung | | Variable | Default | Bedeutung |
|-------------|---------------------|----------------------| |-------------|---------------------|----------------------|
| `KVER_ADDR` | `:8080` | Listen-Adresse im Container | | `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 | | `KVER_GEOIP_ASN` | _(leer)_ | Pfad zur GeoLite2-ASN-`.mmdb`; leer = ASN-Auflösung aus |
## GeoLite2-ASN-Datenbank (optional) ## 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 einmalig gemappt; für aktualisierte Daten die Datei ersetzen und den Dienst neu
starten. 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): Container kann laufen oder gestoppt sein):
```sh ```sh
@@ -88,14 +120,11 @@ podman volume create kver-media
# Rootless: podman unshare sorgt dafür, dass die UID ins User-Namespace- # Rootless: podman unshare sorgt dafür, dass die UID ins User-Namespace-
# Mapping passt — ohne das bekommt der Container die Dateien nicht zu lesen. # 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 unshare cp -r static/media/. \
"$(podman volume inspect kver-media --format '{{.Mountpoint}}')/" "$(podman volume inspect kver-media --format '{{.Mountpoint}}')/"
``` ```
Danach Container (neu) starten. Die App legt beim ersten Start alle fehlenden Danach Container (neu) starten.
Tabellen und Indizes automatisch per `CREATE ... IF NOT EXISTS` an.
## Reverse-Proxy (TLS) — Caddy ## 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 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. // Netz. Gezielt nur auf den HTML-Seiten-Routen, nicht auf API oder Assets.
// //
// Die Erfassung läuft asynchron über den Impression-Worker: der Seitenaufruf // 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 // nach Rückkehr des Handlers nicht mehr sicher nutzbar -> wir kopieren die zwei
// Werte vorher heraus. // Werte vorher heraus.
func trackImpression(next http.Handler) http.Handler { 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") day := time.Now().UTC().Format("2006-01-02")
db.Exec( db.Exec(
`INSERT INTO impression (day, anon_ip, path, asn, hits) VALUES (?, ?, ?, ?, 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 = hits + 1`, ON CONFLICT (day, anon_ip, path) DO UPDATE SET hits = impression.hits + 1`,
day, anonIP, path, asn, day, anonIP, path, asn,
) )
} }
@@ -225,21 +225,21 @@ func recordImpression(path, anonIP, asn string) {
// rechnen. Öffentlich, kein Auth. // rechnen. Öffentlich, kein Auth.
func handleStatsDetail(w http.ResponseWriter, r *http.Request) { func handleStatsDetail(w http.ResponseWriter, r *http.Request) {
var users, entries, toplevel, votesLeft, votesRight int64 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`).Scan(&entries)
db.QueryRow(`SELECT COUNT(*) FROM entry WHERE deleted = 0 AND reply_to = 0`).Scan(&toplevel) 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 = 'left'`).Scan(&votesLeft)
db.QueryRow(`SELECT COUNT(*) FROM vote WHERE mode = 'right'`).Scan(&votesRight) db.QueryRow(`SELECT COUNT(*) FROM vote WHERE mode = 'right'`).Scan(&votesRight)
var imprTotal, visitorsTotal int64 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) Scan(&imprTotal, &visitorsTotal)
// Tägliche Impressions + grobe Unique-Netze, jüngste 30 Tage zuerst. Das // Tägliche Impressions + grobe Unique-Netze, jüngste 30 Tage zuerst. Das
// Frontend dreht für die Achse auf aufsteigend. // Frontend dreht für die Achse auf aufsteigend.
daily := []map[string]any{} daily := []map[string]any{}
if rows, err := db.Query( if rows, err := db.Query(
`SELECT day, SUM(hits), COUNT(DISTINCT anon_ip) `SELECT day, SUM(hits)::bigint, COUNT(DISTINCT anon_ip)
FROM impression FROM impression
GROUP BY day GROUP BY day
ORDER BY day DESC ORDER BY day DESC
@@ -257,7 +257,7 @@ func handleStatsDetail(w http.ResponseWriter, r *http.Request) {
topASNs := []map[string]any{} topASNs := []map[string]any{}
if rows, err := db.Query( if rows, err := db.Query(
`SELECT CASE WHEN asn = '' THEN '(unbekannt)' ELSE asn END AS a, `SELECT CASE WHEN asn = '' THEN '(unbekannt)' ELSE asn END AS a,
SUM(hits) AS h, SUM(hits)::bigint AS h,
COUNT(DISTINCT anon_ip) COUNT(DISTINCT anon_ip)
FROM impression FROM impression
GROUP BY a GROUP BY a
+21 -19
View File
@@ -5,7 +5,6 @@ import (
"math/rand/v2" "math/rand/v2"
"net/http" "net/http"
"os" "os"
"strings"
"time" "time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@@ -37,7 +36,7 @@ func createUser(username, password string) (int64, error) {
for i := 0; i < 5; i++ { for i := 0; i < 5; i++ {
uid := int64(rand.IntN(99999999)) + 1 uid := int64(rand.IntN(99999999)) + 1
_, lastErr = db.Exec( _, 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, uid, now, username, hash,
) )
if lastErr == nil { if lastErr == nil {
@@ -45,9 +44,12 @@ func createUser(username, password string) (int64, error) {
} }
// username ist ebenfalls UNIQUE: hat DER kollidiert (Rennen mit der // username ist ebenfalls UNIQUE: hat DER kollidiert (Rennen mit der
// Vorab-Prüfung des Aufrufers), hilft kein Retry -> gezielt melden. // 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 return 0, errUsernameTaken
} }
if !isUniqueViolation(lastErr, "account_uid_key") {
return 0, lastErr
}
} }
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. // idFromUsername liefert die interne id oder 0, wenn der Nutzer nicht existiert.
func idFromUsername(username string) int64 { func idFromUsername(username string) int64 {
var id 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 0
} }
return id return id
@@ -72,7 +74,7 @@ func handleUserPage(w http.ResponseWriter, r *http.Request) error {
var uid, createdAt int64 var uid, createdAt int64
var avatar string 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 { if err != nil {
return errUserNotFound return errUserNotFound
} }
@@ -89,7 +91,7 @@ func handleUserPage(w http.ResponseWriter, r *http.Request) error {
// registrierte Nutzer und nicht-gelöschte Beiträge. // registrierte Nutzer und nicht-gelöschte Beiträge.
func handleStats(w http.ResponseWriter, r *http.Request) { func handleStats(w http.ResponseWriter, r *http.Request) {
var users, entries int64 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) db.QueryRow(`SELECT COUNT(*) FROM entry WHERE deleted = 0`).Scan(&entries)
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"users": users, "users": users,
@@ -103,7 +105,7 @@ func handleUserInfo(w http.ResponseWriter, r *http.Request) error {
var createdAt, lastLogin sql.NullInt64 var createdAt, lastLogin sql.NullInt64
var username, avatar string var username, avatar string
err := db.QueryRow( 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) ).Scan(&createdAt, &lastLogin, &username, &avatar)
if err != nil { if err != nil {
return errUserNotFound return errUserNotFound
@@ -129,7 +131,7 @@ func handleUserRename(w http.ResponseWriter, r *http.Request) error {
} }
var current string 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 return errUserNotFound
} }
if name == current { if name == current {
@@ -139,9 +141,9 @@ func handleUserRename(w http.ResponseWriter, r *http.Request) error {
return errUsernameTaken 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. // 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 errUsernameTaken
} }
return Internal(err) return Internal(err)
@@ -171,9 +173,9 @@ func handleSetAvatar(w http.ResponseWriter, r *http.Request) error {
} }
var old string 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) os.Remove(stored)
return Internal(err) return Internal(err)
} }
@@ -193,7 +195,7 @@ func handleUserDelete(w http.ResponseWriter, r *http.Request) error {
uid := uidFromContext(r.Context()) uid := uidFromContext(r.Context())
var hash []byte 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 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 // Mediendateien der Beiträge (und das Profilbild) einsammeln, bevor die
// Pfade geleert bzw. die user-Zeile gelöscht wird. // Pfade geleert bzw. die user-Zeile gelöscht wird.
var media []string 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() { for rows.Next() {
var fp string var fp string
if rows.Scan(&fp) == nil && fp != "" { if rows.Scan(&fp) == nil && fp != "" {
@@ -214,7 +216,7 @@ func handleUserDelete(w http.ResponseWriter, r *http.Request) error {
rows.Close() rows.Close()
} }
var avatar string 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) media = append(media, avatar)
} }
@@ -223,10 +225,10 @@ func handleUserDelete(w http.ResponseWriter, r *http.Request) error {
return Internal(err) return Internal(err)
} }
for _, q := range []string{ for _, q := range []string{
`DELETE FROM session WHERE uid = ?`, `DELETE FROM session WHERE uid = $1`,
`DELETE FROM vote WHERE uid = ?`, `DELETE FROM vote WHERE uid = $1`,
`UPDATE entry SET deleted = 1, content = '', filepath = '', uid = 0 WHERE uid = ?`, `UPDATE entry SET deleted = 1, content = '', filepath = '', uid = 0 WHERE uid = $1`,
`DELETE FROM user WHERE uid = ?`, `DELETE FROM account WHERE uid = $1`,
} { } {
if _, err := tx.Exec(q, uid); err != nil { if _, err := tx.Exec(q, uid); err != nil {
tx.Rollback() tx.Rollback()