diff --git a/Containerfile b/Containerfile index 77411ab..97f9a26 100644 --- a/Containerfile +++ b/Containerfile @@ -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"] diff --git a/auth.go b/auth.go index f27d0c6..43ad3f7 100644 --- a/auth.go +++ b/auth.go @@ -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", diff --git a/db.go b/db.go index 9a4d61e..ad06445 100644 --- a/db.go +++ b/db.go @@ -2,54 +2,58 @@ 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, - avatar TEXT NOT NULL DEFAULT '' +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, - 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 + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + pid BIGINT NOT NULL UNIQUE, + uid BIGINT NOT NULL, + created_at BIGINT NOT NULL, + content TEXT NOT NULL DEFAULT '', + filepath TEXT NOT NULL DEFAULT '', + reply_to BIGINT NOT NULL DEFAULT 0, + reply_count BIGINT NOT NULL DEFAULT 0, + last_activity BIGINT NOT NULL DEFAULT 0, + deleted BIGINT NOT NULL DEFAULT 0, + bump_count BIGINT NOT NULL DEFAULT 0, + last_bump BIGINT NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS vote ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - uid INTEGER, - pid INTEGER, - mode TEXT, - UNIQUE(uid, pid) + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + uid BIGINT NOT NULL, + pid BIGINT NOT NULL, + mode TEXT NOT NULL, + UNIQUE (uid, pid) ); -- Seitenaufrufe, aggregiert pro (Tag, anonymisiertes Netz, Pfad). Es wird nie @@ -59,11 +63,11 @@ CREATE TABLE IF NOT EXISTS vote ( -- deterministisch. Aggregation statt Eventlog -> die Tabelle wächst nur mit -- Tag × Netz × Pfad, nicht pro Request. CREATE TABLE IF NOT EXISTS impression ( - day TEXT NOT NULL, - anon_ip TEXT NOT NULL, - path TEXT NOT NULL DEFAULT '', - asn TEXT NOT NULL DEFAULT '', - hits INTEGER NOT NULL DEFAULT 0, + day TEXT NOT NULL, + anon_ip TEXT NOT NULL, + path TEXT NOT NULL DEFAULT '', + asn TEXT NOT NULL DEFAULT '', + hits BIGINT NOT NULL DEFAULT 0, PRIMARY KEY (day, anon_ip, path) ); @@ -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, - reason TEXT NOT NULL DEFAULT '', - created_at INTEGER NOT NULL, - handled 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 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 { - return err - } - return migrate() + _, err = db.Exec(schema) + return err } -// 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 __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 } diff --git a/endpoints_test.go b/endpoints_test.go index a613b52..4a90c1b 100644 --- a/endpoints_test.go +++ b/endpoints_test.go @@ -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() }) diff --git a/entry.go b/entry.go index 3eed574..805c895 100644 --- a/entry.go +++ b/entry.go @@ -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,14 +590,14 @@ 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 (?, ?, ?) - ON CONFLICT(uid, pid) DO UPDATE SET mode = excluded.mode`, + `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 { return Internal(err) diff --git a/go.mod b/go.mod index b0a7f0d..d445e92 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index ef52ecb..bdb34d1 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/importsqlite.go b/importsqlite.go new file mode 100644 index 0000000..97ae337 --- /dev/null +++ b/importsqlite.go @@ -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() +} diff --git a/kver.env.example b/kver.env.example index b87bc61..15f1910 100644 --- a/kver.env.example +++ b/kver.env.example @@ -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. diff --git a/main.go b/main.go index 726e390..a0883af 100644 --- a/main.go +++ b/main.go @@ -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() diff --git a/moderation.go b/moderation.go index 81a8d1c..f4bbf00 100644 --- a/moderation.go +++ b/moderation.go @@ -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) diff --git a/notes/api.md b/notes/api.md index 4e61b56..c279c2b 100644 --- a/notes/api.md +++ b/notes/api.md @@ -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`. --- diff --git a/notes/deploy.md b/notes/deploy.md index a9e9d82..0105c42 100644 --- a/notes/deploy.md +++ b/notes/deploy.md @@ -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). diff --git a/stats.go b/stats.go index 233f3e3..dfffd3b 100644 --- a/stats.go +++ b/stats.go @@ -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 diff --git a/user.go b/user.go index 4842da9..193a258 100644 --- a/user.go +++ b/user.go @@ -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(¤t); err != nil { + if err := db.QueryRow(`SELECT username FROM account WHERE uid = $1`, uid).Scan(¤t); 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()