package store import ( "context" "database/sql" "errors" "fmt" "os" "path/filepath" "strings" "time" _ "modernc.org/sqlite" ) const schemaVersion = 6 var ErrNotFound = errors.New("store: not found") // Store owns the SQLite connection used by the process. type Store struct { db *sql.DB } type Admin struct { ID int64 Username string PasswordHash []byte CreatedAt time.Time UpdatedAt time.Time } type Session struct { TokenHash []byte CSRFHash []byte ExpiresAt time.Time CreatedAt time.Time Admin Admin } // Open creates the parent directory, opens SQLite, applies safety pragmas and // runs the built-in schema migration. func Open(ctx context.Context, path string) (*Store, error) { if err := prepareDatabasePath(path); err != nil { return nil, err } db, err := sql.Open("sqlite", path) if err != nil { return nil, fmt.Errorf("open sqlite: %w", err) } db.SetMaxOpenConns(1) db.SetMaxIdleConns(1) closeOnError := func(err error) (*Store, error) { _ = db.Close() return nil, err } if err := db.PingContext(ctx); err != nil { return closeOnError(fmt.Errorf("ping sqlite: %w", err)) } for _, pragma := range []string{ "PRAGMA foreign_keys = ON", "PRAGMA busy_timeout = 5000", "PRAGMA journal_mode = WAL", } { if _, err := db.ExecContext(ctx, pragma); err != nil { return closeOnError(fmt.Errorf("%s: %w", pragma, err)) } } if err := migrate(ctx, db); err != nil { return closeOnError(err) } if isFilesystemPath(path) { if err := os.Chmod(path, 0o600); err != nil { return closeOnError(fmt.Errorf("secure sqlite file: %w", err)) } } return &Store{db: db}, nil } func prepareDatabasePath(path string) error { if !isFilesystemPath(path) { return nil } parent := filepath.Dir(path) if parent == "." { return nil } if err := os.MkdirAll(parent, 0o750); err != nil { return fmt.Errorf("create sqlite directory: %w", err) } return nil } func isFilesystemPath(path string) bool { return path != ":memory:" && !strings.HasPrefix(path, "file:") } func migrate(ctx context.Context, db *sql.DB) error { var version int if err := db.QueryRowContext(ctx, "PRAGMA user_version").Scan(&version); err != nil { return fmt.Errorf("read sqlite schema version: %w", err) } if version > schemaVersion { return fmt.Errorf("sqlite schema version %d is newer than supported version %d", version, schemaVersion) } if version == schemaVersion { return nil } for nextVersion := version + 1; nextVersion <= schemaVersion; nextVersion++ { tx, err := db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("begin sqlite migration %d: %w", nextVersion, err) } for _, statement := range migrationStatements(nextVersion) { if _, err := tx.ExecContext(ctx, statement); err != nil { _ = tx.Rollback() return fmt.Errorf("apply sqlite migration %d: %w", nextVersion, err) } } if _, err := tx.ExecContext( ctx, fmt.Sprintf("PRAGMA user_version = %d", nextVersion), ); err != nil { _ = tx.Rollback() return fmt.Errorf("record sqlite migration %d: %w", nextVersion, err) } if err := tx.Commit(); err != nil { return fmt.Errorf("commit sqlite migration %d: %w", nextVersion, err) } } return nil } func (s *Store) Close() error { return s.db.Close() } func (s *Store) Ready(ctx context.Context) error { if err := s.db.PingContext(ctx); err != nil { return err } var one int if err := s.db.QueryRowContext(ctx, "SELECT 1").Scan(&one); err != nil { return err } if one != 1 { return errors.New("sqlite readiness query returned an unexpected value") } return nil } func (s *Store) CurrentAdmin(ctx context.Context) (Admin, error) { return scanAdmin(s.db.QueryRowContext(ctx, ` SELECT id, username, password_hash, created_at, updated_at FROM admins WHERE id = 1 `)) } func (s *Store) AdminByUsername(ctx context.Context, username string) (Admin, error) { return scanAdmin(s.db.QueryRowContext(ctx, ` SELECT id, username, password_hash, created_at, updated_at FROM admins WHERE username = ? `, username)) } type rowScanner interface { Scan(dest ...any) error } func scanAdmin(row rowScanner) (Admin, error) { var admin Admin var createdAt int64 var updatedAt int64 err := row.Scan( &admin.ID, &admin.Username, &admin.PasswordHash, &createdAt, &updatedAt, ) if errors.Is(err, sql.ErrNoRows) { return Admin{}, ErrNotFound } if err != nil { return Admin{}, err } admin.CreatedAt = time.Unix(createdAt, 0).UTC() admin.UpdatedAt = time.Unix(updatedAt, 0).UTC() return admin, nil } // SetAdmin inserts or replaces the single configured administrator and // atomically revokes all existing sessions. func (s *Store) SetAdmin(ctx context.Context, username string, passwordHash []byte) error { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("begin admin update: %w", err) } defer tx.Rollback() now := time.Now().UTC().Unix() _, err = tx.ExecContext(ctx, ` INSERT INTO admins (id, username, password_hash, created_at, updated_at) VALUES (1, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET username = excluded.username, password_hash = excluded.password_hash, updated_at = excluded.updated_at `, username, passwordHash, now, now) if err != nil { return fmt.Errorf("set admin: %w", err) } if _, err := tx.ExecContext(ctx, "DELETE FROM sessions"); err != nil { return fmt.Errorf("revoke sessions after admin update: %w", err) } if err := tx.Commit(); err != nil { return fmt.Errorf("commit admin update: %w", err) } return nil } func (s *Store) DeleteAllSessions(ctx context.Context) error { if _, err := s.db.ExecContext(ctx, "DELETE FROM sessions"); err != nil { return fmt.Errorf("delete all sessions: %w", err) } return nil } func (s *Store) CreateSession( ctx context.Context, adminID int64, tokenHash []byte, csrfHash []byte, expiresAt time.Time, ) error { now := time.Now().UTC().Unix() _, err := s.db.ExecContext(ctx, ` INSERT INTO sessions (token_hash, admin_id, csrf_hash, expires_at, created_at) VALUES (?, ?, ?, ?, ?) `, tokenHash, adminID, csrfHash, expiresAt.UTC().Unix(), now) if err != nil { return fmt.Errorf("create session: %w", err) } return nil } func (s *Store) SessionByTokenHash(ctx context.Context, tokenHash []byte) (Session, error) { var session Session var expiresAt int64 var createdAt int64 var adminCreatedAt int64 var adminUpdatedAt int64 err := s.db.QueryRowContext(ctx, ` SELECT s.token_hash, s.csrf_hash, s.expires_at, s.created_at, a.id, a.username, a.created_at, a.updated_at FROM sessions s JOIN admins a ON a.id = s.admin_id WHERE s.token_hash = ? `, tokenHash).Scan( &session.TokenHash, &session.CSRFHash, &expiresAt, &createdAt, &session.Admin.ID, &session.Admin.Username, &adminCreatedAt, &adminUpdatedAt, ) if errors.Is(err, sql.ErrNoRows) { return Session{}, ErrNotFound } if err != nil { return Session{}, err } session.ExpiresAt = time.Unix(expiresAt, 0).UTC() session.CreatedAt = time.Unix(createdAt, 0).UTC() session.Admin.CreatedAt = time.Unix(adminCreatedAt, 0).UTC() session.Admin.UpdatedAt = time.Unix(adminUpdatedAt, 0).UTC() return session, nil } func (s *Store) UpdateSessionCSRF(ctx context.Context, tokenHash []byte, csrfHash []byte) error { result, err := s.db.ExecContext(ctx, ` UPDATE sessions SET csrf_hash = ? WHERE token_hash = ? `, csrfHash, tokenHash) if err != nil { return fmt.Errorf("update session csrf: %w", err) } affected, err := result.RowsAffected() if err != nil { return fmt.Errorf("read session update result: %w", err) } if affected == 0 { return ErrNotFound } return nil } func (s *Store) DeleteSession(ctx context.Context, tokenHash []byte) error { if _, err := s.db.ExecContext(ctx, "DELETE FROM sessions WHERE token_hash = ?", tokenHash); err != nil { return fmt.Errorf("delete session: %w", err) } return nil } func (s *Store) DeleteExpiredSessions(ctx context.Context, now time.Time) error { if _, err := s.db.ExecContext(ctx, "DELETE FROM sessions WHERE expires_at <= ?", now.UTC().Unix()); err != nil { return fmt.Errorf("delete expired sessions: %w", err) } return nil }