| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326 |
- 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
- }
|