Files
2026-08-16 21:18:45 -05:00

206 lines
5.7 KiB
Go

// Package database owns the SQLite connection, the migration runner and every
// SQL statement in the application. Higher layers talk to *DB and never build
// SQL themselves, which keeps parameterisation and transaction handling in one
// auditable place.
package database
import (
"context"
"database/sql"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"time"
_ "modernc.org/sqlite" // pure-Go driver: no cgo, single static binary
)
// DB wraps the SQLite handle with the helpers the rest of the app needs.
type DB struct {
*sql.DB
path string
}
// Common storage errors surfaced to the HTTP layer as 404/409 responses.
var (
ErrNotFound = errors.New("not found")
ErrConflict = errors.New("already exists")
)
// Open opens (creating if necessary) the SQLite database at path and applies
// the connection pragmas the application relies on.
//
// The file is created with 0600 and its parent directory with 0750: the
// database holds the administrator password hash and API token hashes, so it
// must not be world readable.
func Open(path string) (*DB, error) {
if path == "" {
return nil, errors.New("database path is empty")
}
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o750); err != nil {
return nil, fmt.Errorf("create database directory %s: %w", dir, err)
}
// _txlock=immediate makes database/sql issue BEGIN IMMEDIATE, so SQLite's
// busy handler can actually resolve writer contention instead of failing
// with SQLITE_BUSY when a deferred transaction tries to upgrade.
dsn := "file:" + url.PathEscape(path) + "?" +
"_pragma=journal_mode(WAL)" +
"&_pragma=foreign_keys(1)" +
"&_pragma=busy_timeout(15000)" +
"&_pragma=synchronous(NORMAL)" +
"&_txlock=immediate"
sqlDB, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
// SQLite serialises writes; a small pool avoids piling up blocked writers
// while still allowing concurrent WAL readers.
sqlDB.SetMaxOpenConns(8)
sqlDB.SetMaxIdleConns(8)
sqlDB.SetConnMaxLifetime(time.Hour)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := sqlDB.PingContext(ctx); err != nil {
sqlDB.Close()
return nil, fmt.Errorf("connect to database: %w", err)
}
db := &DB{DB: sqlDB, path: path}
if err := db.hardenPermissions(); err != nil {
sqlDB.Close()
return nil, err
}
return db, nil
}
// Path returns the on-disk location of the database.
func (db *DB) Path() string { return db.path }
// hardenPermissions restricts the database and its WAL sidecars to the owner.
func (db *DB) hardenPermissions() error {
for _, suffix := range []string{"", "-wal", "-shm"} {
p := db.path + suffix
if _, err := os.Stat(p); err != nil {
continue // sidecars may not exist yet
}
if err := os.Chmod(p, 0o600); err != nil {
return fmt.Errorf("secure %s: %w", p, err)
}
}
return nil
}
// Checkpoint flushes the write-ahead log into the main database file. It runs
// before backups so the copied file is complete.
func (db *DB) Checkpoint(ctx context.Context) error {
_, err := db.ExecContext(ctx, `PRAGMA wal_checkpoint(TRUNCATE)`)
return err
}
// Vacuum rebuilds the database, reclaiming space after large deletions.
func (db *DB) Vacuum(ctx context.Context) error {
_, err := db.ExecContext(ctx, `VACUUM`)
return err
}
// Stats describes database size and row counts for the settings UI.
type Stats struct {
Path string `json:"path"`
SizeBytes int64 `json:"size_bytes"`
WALBytes int64 `json:"wal_bytes"`
PageSize int64 `json:"page_size"`
PageCount int64 `json:"page_count"`
FreePages int64 `json:"free_pages"`
Zones int64 `json:"zones"`
Records int64 `json:"records"`
Domains int64 `json:"domains"`
QueryLogs int64 `json:"query_logs"`
AuditLogs int64 `json:"audit_logs"`
APITokens int64 `json:"api_tokens"`
SchemaVer int `json:"schema_version"`
}
// Stats collects database size and row-count information.
func (db *DB) Stats(ctx context.Context) (Stats, error) {
s := Stats{Path: db.path}
if fi, err := os.Stat(db.path); err == nil {
s.SizeBytes = fi.Size()
}
if fi, err := os.Stat(db.path + "-wal"); err == nil {
s.WALBytes = fi.Size()
}
_ = db.QueryRowContext(ctx, `PRAGMA page_size`).Scan(&s.PageSize)
_ = db.QueryRowContext(ctx, `PRAGMA page_count`).Scan(&s.PageCount)
_ = db.QueryRowContext(ctx, `PRAGMA freelist_count`).Scan(&s.FreePages)
counts := []struct {
table string
dst *int64
}{
{"zones", &s.Zones},
{"records", &s.Records},
{"domain_entries", &s.Domains},
{"query_logs", &s.QueryLogs},
{"audit_logs", &s.AuditLogs},
{"api_tokens", &s.APITokens},
}
for _, c := range counts {
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM `+c.table).Scan(c.dst); err != nil {
return s, fmt.Errorf("count %s: %w", c.table, err)
}
}
v, err := db.SchemaVersion(ctx)
if err != nil {
return s, err
}
s.SchemaVer = v
return s, nil
}
// InTx runs fn inside a transaction, committing on success and rolling back on
// error or panic.
func (db *DB) InTx(ctx context.Context, fn func(*sql.Tx) error) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin transaction: %w", err)
}
defer func() {
if p := recover(); p != nil {
_ = tx.Rollback()
panic(p)
}
}()
if err := fn(tx); err != nil {
_ = tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit transaction: %w", err)
}
return nil
}
// unixPtr converts a nullable epoch-seconds column into a *time.Time.
func unixPtr(n sql.NullInt64) *time.Time {
if !n.Valid {
return nil
}
t := time.Unix(n.Int64, 0)
return &t
}
// nullInt64 converts a *int64 into a driver-friendly nullable value.
func nullInt64(v *int64) any {
if v == nil {
return nil
}
return *v
}