package db import ( "context" "database/sql" "embed" "errors" "fmt" "io/fs" "os" "path/filepath" "sort" "strconv" "strings" "time" _ "modernc.org/sqlite" ) //go:embed migrations/*.sql var migrationsFS embed.FS type DB struct { *sql.DB } func Open(ctx context.Context, dbPath string, defaultRegEnabled bool) (*DB, error) { if dbPath != ":memory:" && !strings.HasPrefix(dbPath, "file::memory:") { if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil { return nil, fmt.Errorf("create database directory: %w", err) } } dsn := dbPath if !strings.Contains(dsn, "?") { dsn += "?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)&_pragma=synchronous(NORMAL)" } sqlDB, err := sql.Open("sqlite", dsn) if err != nil { return nil, fmt.Errorf("open sqlite %s: %w", dbPath, err) } sqlDB.SetMaxOpenConns(1) sqlDB.SetMaxIdleConns(1) sqlDB.SetConnMaxLifetime(0) pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() if err := sqlDB.PingContext(pingCtx); err != nil { _ = sqlDB.Close() return nil, fmt.Errorf("ping sqlite: %w", err) } pragmas := []string{ "PRAGMA journal_mode = WAL;", "PRAGMA foreign_keys = ON;", "PRAGMA busy_timeout = 5000;", "PRAGMA synchronous = NORMAL;", } for _, p := range pragmas { if _, err := sqlDB.ExecContext(pingCtx, p); err != nil { _ = sqlDB.Close() return nil, fmt.Errorf("exec pragma %q: %w", p, err) } } d := &DB{DB: sqlDB} if err := d.Migrate(pingCtx); err != nil { _ = sqlDB.Close() return nil, fmt.Errorf("run migrations: %w", err) } if _, err := d.GetSetting(pingCtx, "registration_enabled"); errors.Is(err, sql.ErrNoRows) { if err := d.SetSetting(pingCtx, "registration_enabled", strconv.FormatBool(defaultRegEnabled)); err != nil { _ = sqlDB.Close() return nil, fmt.Errorf("seed registration_enabled setting: %w", err) } } if _, err := d.GetSetting(pingCtx, "registration_mode"); errors.Is(err, sql.ErrNoRows) { mode := RegistrationModeClosed if d.IsRegistrationEnabled(pingCtx, defaultRegEnabled) { mode = RegistrationModePublic } if err := d.SetSetting(pingCtx, "registration_mode", mode); err != nil { _ = sqlDB.Close() return nil, fmt.Errorf("seed registration_mode setting: %w", err) } } return d, nil } func (d *DB) Migrate(ctx context.Context) error { _, err := d.ExecContext(ctx, ` CREATE TABLE IF NOT EXISTS schema_migrations ( version TEXT PRIMARY KEY, applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); `) if err != nil { return fmt.Errorf("create schema_migrations table: %w", err) } entries, err := fs.ReadDir(migrationsFS, "migrations") if err != nil { return fmt.Errorf("read embedded migrations: %w", err) } var files []string for _, entry := range entries { if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") { files = append(files, entry.Name()) } } sort.Strings(files) for _, name := range files { var exists int err := d.QueryRowContext(ctx, `SELECT 1 FROM schema_migrations WHERE version = ?`, name).Scan(&exists) if err == nil { continue } if !errors.Is(err, sql.ErrNoRows) { return fmt.Errorf("check migration %s: %w", name, err) } content, err := migrationsFS.ReadFile("migrations/" + name) if err != nil { return fmt.Errorf("read migration %s: %w", name, err) } tx, err := d.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("begin migration tx %s: %w", name, err) } if _, err := tx.ExecContext(ctx, string(content)); err != nil { _ = tx.Rollback() return fmt.Errorf("execute migration %s: %w", name, err) } if _, err := tx.ExecContext(ctx, `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) } } return nil } func (d *DB) GetSetting(ctx context.Context, key string) (string, error) { cctx, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() var val string err := d.QueryRowContext(cctx, `SELECT value FROM settings WHERE key = ?`, key).Scan(&val) if err != nil { return "", fmt.Errorf("get setting %s: %w", key, err) } return val, nil } func (d *DB) SetSetting(ctx context.Context, key, value string) error { cctx, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() _, err := d.ExecContext(cctx, ` INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value `, key, value) if err != nil { return fmt.Errorf("set setting %s: %w", key, err) } return nil } func (d *DB) GetBoolSetting(ctx context.Context, key string, fallback bool) bool { val, err := d.GetSetting(ctx, key) if err != nil { return fallback } b, err := strconv.ParseBool(val) if err != nil { return fallback } return b } func (d *DB) SetBoolSetting(ctx context.Context, key string, enabled bool) error { return d.SetSetting(ctx, key, strconv.FormatBool(enabled)) } const ( RegistrationModePublic = "public" RegistrationModeConfirm = "confirm" RegistrationModeClosed = "closed" ) func ValidRegistrationMode(mode string) bool { switch mode { case RegistrationModePublic, RegistrationModeConfirm, RegistrationModeClosed: return true default: return false } } // GetRegistrationMode returns the current registration policy. Older databases // only have the boolean registration_enabled setting, so fall back to it. func (d *DB) GetRegistrationMode(ctx context.Context, fallback bool) string { mode, err := d.GetSetting(ctx, "registration_mode") if err == nil && ValidRegistrationMode(mode) { return mode } if d.IsRegistrationEnabled(ctx, fallback) { return RegistrationModePublic } return RegistrationModeClosed } func (d *DB) SetRegistrationMode(ctx context.Context, mode string) error { if !ValidRegistrationMode(mode) { return fmt.Errorf("invalid registration mode %q", mode) } if err := d.SetSetting(ctx, "registration_mode", mode); err != nil { return err } return d.SetBoolSetting(ctx, "registration_enabled", mode != RegistrationModeClosed) } func (d *DB) IsRegistrationEnabled(ctx context.Context, fallback bool) bool { return d.GetBoolSetting(ctx, "registration_enabled", fallback) } func (d *DB) SetRegistrationEnabled(ctx context.Context, enabled bool) error { mode := RegistrationModeClosed if enabled { mode = RegistrationModePublic } return d.SetRegistrationMode(ctx, mode) } func (d *DB) IsExploreEnabled(ctx context.Context) bool { return d.GetBoolSetting(ctx, "explore_enabled", true) } func (d *DB) IsLandingEnabled(ctx context.Context, fallback bool) bool { return d.GetBoolSetting(ctx, "landing_enabled", fallback) } func (d *DB) IsAnonymousReadEnabled(ctx context.Context) bool { return d.GetBoolSetting(ctx, "anonymous_read_enabled", true) } func (d *DB) IsIssuesEnabled(ctx context.Context) bool { return d.GetBoolSetting(ctx, "issues_enabled", true) }