// Package database provides SQLite database access with logging. package database import ( "context" "crypto/sha256" "database/sql" "encoding/hex" "fmt" "log/slog" "os" "path/filepath" _ "github.com/mattn/go-sqlite3" // SQLite driver "go.uber.org/fx" "git.eeqj.de/sneak/upaas/internal/config" "git.eeqj.de/sneak/upaas/internal/logger" ) // dataDirPermissions is the file permission for the data directory. const dataDirPermissions = 0o750 // Params contains dependencies for Database. type Params struct { fx.In Logger *logger.Logger Config *config.Config } // Database wraps sql.DB with logging and helper methods. type Database struct { database *sql.DB log *slog.Logger params *Params } // New creates a new Database instance. func New(lifecycle fx.Lifecycle, params Params) (*Database, error) { database := &Database{ log: params.Logger.Get(), params: ¶ms, } // For testing, if lifecycle is nil, connect immediately if lifecycle == nil { err := database.connect(context.Background()) if err != nil { return nil, err } return database, nil } lifecycle.Append(fx.Hook{ OnStart: func(ctx context.Context) error { return database.connect(ctx) }, OnStop: func(_ context.Context) error { return database.close() }, }) return database, nil } // DB returns the underlying sql.DB for direct access. func (d *Database) DB() *sql.DB { return d.database } // Exec executes a query with logging. func (d *Database) Exec( ctx context.Context, query string, args ...any, ) (sql.Result, error) { d.log.Debug("database exec", "query", query, "args", args) result, err := d.database.ExecContext(ctx, query, args...) if err != nil { return nil, fmt.Errorf("exec failed: %w", err) } return result, nil } // QueryRow executes a query that returns a single row. func (d *Database) QueryRow(ctx context.Context, query string, args ...any) *sql.Row { d.log.Debug("database query row", "query", query, "args", args) return d.database.QueryRowContext(ctx, query, args...) } // Query executes a query that returns multiple rows. func (d *Database) Query( ctx context.Context, query string, args ...any, ) (*sql.Rows, error) { d.log.Debug("database query", "query", query, "args", args) rows, err := d.database.QueryContext(ctx, query, args...) if err != nil { return nil, fmt.Errorf("query failed: %w", err) } return rows, nil } // BeginTx starts a new transaction. func (d *Database) BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error) { d.log.Debug("database begin transaction") transaction, err := d.database.BeginTx(ctx, opts) if err != nil { return nil, fmt.Errorf("begin transaction failed: %w", err) } return transaction, nil } // Path returns the database file path. func (d *Database) Path() string { return d.params.Config.DatabasePath() } func (d *Database) connect(ctx context.Context) error { dbPath := d.params.Config.DatabasePath() // Ensure data directory exists dir := filepath.Dir(dbPath) err := os.MkdirAll(dir, dataDirPermissions) if err != nil { return fmt.Errorf("failed to create data directory: %w", err) } // Open database with WAL mode and foreign keys dsn := dbPath + "?_journal_mode=WAL&_foreign_keys=on" database, err := sql.Open("sqlite3", dsn) if err != nil { return fmt.Errorf("failed to open database: %w", err) } // Test connection err = database.PingContext(ctx) if err != nil { return fmt.Errorf("failed to ping database: %w", err) } d.database = database d.log.Info("database connected", "path", dbPath) // Run migrations err = d.migrate(ctx) if err != nil { return fmt.Errorf("failed to run migrations: %w", err) } // Backfill webhook_secret_hash for any rows that have a secret but no hash err = d.backfillWebhookSecretHashes(ctx) if err != nil { return fmt.Errorf("failed to backfill webhook secret hashes: %w", err) } return nil } // HashWebhookSecret returns the hex-encoded SHA-256 hash of a webhook secret. func HashWebhookSecret(secret string) string { sum := sha256.Sum256([]byte(secret)) return hex.EncodeToString(sum[:]) } func (d *Database) backfillWebhookSecretHashes(ctx context.Context) error { rows, err := d.database.QueryContext(ctx, "SELECT id, webhook_secret FROM apps WHERE webhook_secret_hash = '' AND webhook_secret != ''") if err != nil { return fmt.Errorf("querying apps for backfill: %w", err) } defer func() { _ = rows.Close() }() type row struct { id, secret string } var toUpdate []row for rows.Next() { var r row scanErr := rows.Scan(&r.id, &r.secret) if scanErr != nil { return fmt.Errorf("scanning app for backfill: %w", scanErr) } toUpdate = append(toUpdate, r) } rowsErr := rows.Err() if rowsErr != nil { return fmt.Errorf("iterating apps for backfill: %w", rowsErr) } for _, r := range toUpdate { hash := HashWebhookSecret(r.secret) _, updateErr := d.database.ExecContext(ctx, "UPDATE apps SET webhook_secret_hash = ? WHERE id = ?", hash, r.id) if updateErr != nil { return fmt.Errorf("updating webhook_secret_hash for app %s: %w", r.id, updateErr) } d.log.Info("backfilled webhook_secret_hash", "app_id", r.id) } return nil } func (d *Database) close() error { if d.database != nil { d.log.Info("closing database connection") err := d.database.Close() if err != nil { return fmt.Errorf("failed to close database: %w", err) } } return nil }