favoritter/internal/database/database.go
Ole-Morten Duesund fc1f7259c5 feat: implement Phase 1 (auth) and Phase 2 (faves CRUD) foundation
Go backend with server-rendered HTML/HTMX frontend, SQLite database,
and filesystem image storage. Self-hostable single-binary architecture.

Phase 1 — Authentication & project foundation:
- Argon2id password hashing with timing-attack prevention
- Session management with cookie-based auth and periodic cleanup
- Login, signup (open/requests/closed modes), logout, forced password reset
- CSRF double-submit cookie pattern with HTMX auto-inclusion
- Proxy-aware real IP extraction (WireGuard/Tailscale support)
- Configurable base path for subdomain and subpath deployment
- Rate limiting on auth endpoints with background cleanup
- Security headers (CSP, X-Frame-Options, Referrer-Policy)
- Structured logging with slog, graceful shutdown
- Pico CSS + HTMX vendored and embedded via go:embed

Phase 2 — Faves CRUD with tags and images:
- Full CRUD for favorites with ownership checks
- Image upload with EXIF stripping, resize to 1920px, UUID filenames
- Tag system with HTMX autocomplete (prefix search, popularity-sorted)
- Privacy controls (public/private per fave, user-configurable default)
- Tag browsing, pagination, batch tag loading (avoids N+1)
- OpenGraph meta tags on public fave detail pages

Includes code quality pass: extracted shared helpers, fixed signup
request persistence bug, plugged rate limiter memory leak, removed
dead code, and logged previously-swallowed errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 15:55:22 +02:00

129 lines
3.5 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-or-later
// Package database manages the SQLite connection, PRAGMAs, and schema migrations.
package database
import (
"database/sql"
"embed"
"fmt"
"log/slog"
"os"
"path/filepath"
"sort"
"strings"
_ "modernc.org/sqlite"
)
//go:embed migrations/*.sql
var migrationsFS embed.FS
// Open creates a new SQLite database connection with recommended PRAGMAs
// for WAL mode, foreign keys, and performance tuning.
func Open(dbPath string) (*sql.DB, error) {
// Ensure the directory for the database file exists.
dir := filepath.Dir(dbPath)
if err := os.MkdirAll(dir, 0750); err != nil {
return nil, fmt.Errorf("create db directory: %w", err)
}
db, err := sql.Open("sqlite", dbPath)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
// Apply recommended PRAGMAs. These must be set per-connection, and since
// database/sql may open multiple connections, we use ConnInitHook via DSN
// parameters where possible. However, journal_mode persists at the db level.
pragmas := []string{
"PRAGMA journal_mode = WAL",
"PRAGMA busy_timeout = 5000",
"PRAGMA synchronous = NORMAL",
"PRAGMA foreign_keys = ON",
"PRAGMA cache_size = -20000",
}
for _, p := range pragmas {
if _, err := db.Exec(p); err != nil {
db.Close()
return nil, fmt.Errorf("set pragma %q: %w", p, err)
}
}
// SQLite works best with a single writer connection.
db.SetMaxOpenConns(1)
slog.Info("database opened", "path", dbPath)
return db, nil
}
// Migrate runs all pending SQL migrations in order. Migrations are embedded
// SQL files named with a numeric prefix (e.g. 001_initial.sql). Each migration
// runs within a transaction.
func Migrate(db *sql.DB) error {
// Create the migrations tracking table if it doesn't exist.
_, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
)`)
if err != nil {
return fmt.Errorf("create schema_migrations table: %w", err)
}
// Read all migration files.
entries, err := migrationsFS.ReadDir("migrations")
if err != nil {
return fmt.Errorf("read migrations dir: %w", err)
}
// Sort by filename to ensure correct order.
sort.Slice(entries, func(i, j int) bool {
return entries[i].Name() < entries[j].Name()
})
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") {
continue
}
name := entry.Name()
// Check if this migration has already been applied.
var count int
if err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations WHERE version = ?", name).Scan(&count); err != nil {
return fmt.Errorf("check migration %s: %w", name, err)
}
if count > 0 {
continue
}
// Read and execute the migration in a transaction.
content, err := migrationsFS.ReadFile("migrations/" + name)
if err != nil {
return fmt.Errorf("read migration %s: %w", name, err)
}
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("begin tx for %s: %w", name, err)
}
if _, err := tx.Exec(string(content)); err != nil {
tx.Rollback()
return fmt.Errorf("execute migration %s: %w", name, err)
}
if _, err := tx.Exec("INSERT INTO schema_migrations (version) VALUES (?)", name); err != nil {
tx.Rollback()
return fmt.Errorf("record migration %s: %w", name, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit migration %s: %w", name, err)
}
slog.Info("applied migration", "version", name)
}
return nil
}