db.go
·
259 строк
·
6.9 KB
| 1 | package db |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "database/sql" |
| 6 | "embed" |
| 7 | "errors" |
| 8 | "fmt" |
| 9 | "io/fs" |
| 10 | "os" |
| 11 | "path/filepath" |
| 12 | "sort" |
| 13 | "strconv" |
| 14 | "strings" |
| 15 | "time" |
| 16 | |
| 17 | _ "modernc.org/sqlite" |
| 18 | ) |
| 19 | |
| 20 | //go:embed migrations/*.sql |
| 21 | var migrationsFS embed.FS |
| 22 | |
| 23 | type DB struct { |
| 24 | *sql.DB |
| 25 | } |
| 26 | |
| 27 | func Open(ctx context.Context, dbPath string, defaultRegEnabled bool) (*DB, error) { |
| 28 | if dbPath != ":memory:" && !strings.HasPrefix(dbPath, "file::memory:") { |
| 29 | if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil { |
| 30 | return nil, fmt.Errorf("create database directory: %w", err) |
| 31 | } |
| 32 | } |
| 33 | |
| 34 | dsn := dbPath |
| 35 | if !strings.Contains(dsn, "?") { |
| 36 | dsn += "?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)&_pragma=synchronous(NORMAL)" |
| 37 | } |
| 38 | |
| 39 | sqlDB, err := sql.Open("sqlite", dsn) |
| 40 | if err != nil { |
| 41 | return nil, fmt.Errorf("open sqlite %s: %w", dbPath, err) |
| 42 | } |
| 43 | |
| 44 | sqlDB.SetMaxOpenConns(1) |
| 45 | sqlDB.SetMaxIdleConns(1) |
| 46 | sqlDB.SetConnMaxLifetime(0) |
| 47 | |
| 48 | pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second) |
| 49 | defer cancel() |
| 50 | if err := sqlDB.PingContext(pingCtx); err != nil { |
| 51 | _ = sqlDB.Close() |
| 52 | return nil, fmt.Errorf("ping sqlite: %w", err) |
| 53 | } |
| 54 | |
| 55 | pragmas := []string{ |
| 56 | "PRAGMA journal_mode = WAL;", |
| 57 | "PRAGMA foreign_keys = ON;", |
| 58 | "PRAGMA busy_timeout = 5000;", |
| 59 | "PRAGMA synchronous = NORMAL;", |
| 60 | } |
| 61 | for _, p := range pragmas { |
| 62 | if _, err := sqlDB.ExecContext(pingCtx, p); err != nil { |
| 63 | _ = sqlDB.Close() |
| 64 | return nil, fmt.Errorf("exec pragma %q: %w", p, err) |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | d := &DB{DB: sqlDB} |
| 69 | if err := d.Migrate(pingCtx); err != nil { |
| 70 | _ = sqlDB.Close() |
| 71 | return nil, fmt.Errorf("run migrations: %w", err) |
| 72 | } |
| 73 | |
| 74 | if _, err := d.GetSetting(pingCtx, "registration_enabled"); errors.Is(err, sql.ErrNoRows) { |
| 75 | if err := d.SetSetting(pingCtx, "registration_enabled", strconv.FormatBool(defaultRegEnabled)); err != nil { |
| 76 | _ = sqlDB.Close() |
| 77 | return nil, fmt.Errorf("seed registration_enabled setting: %w", err) |
| 78 | } |
| 79 | } |
| 80 | |
| 81 | if _, err := d.GetSetting(pingCtx, "registration_mode"); errors.Is(err, sql.ErrNoRows) { |
| 82 | mode := RegistrationModeClosed |
| 83 | if d.IsRegistrationEnabled(pingCtx, defaultRegEnabled) { |
| 84 | mode = RegistrationModePublic |
| 85 | } |
| 86 | if err := d.SetSetting(pingCtx, "registration_mode", mode); err != nil { |
| 87 | _ = sqlDB.Close() |
| 88 | return nil, fmt.Errorf("seed registration_mode setting: %w", err) |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | return d, nil |
| 93 | } |
| 94 | |
| 95 | func (d *DB) Migrate(ctx context.Context) error { |
| 96 | _, err := d.ExecContext(ctx, ` |
| 97 | CREATE TABLE IF NOT EXISTS schema_migrations ( |
| 98 | version TEXT PRIMARY KEY, |
| 99 | applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP |
| 100 | ); |
| 101 | `) |
| 102 | if err != nil { |
| 103 | return fmt.Errorf("create schema_migrations table: %w", err) |
| 104 | } |
| 105 | |
| 106 | entries, err := fs.ReadDir(migrationsFS, "migrations") |
| 107 | if err != nil { |
| 108 | return fmt.Errorf("read embedded migrations: %w", err) |
| 109 | } |
| 110 | |
| 111 | var files []string |
| 112 | for _, entry := range entries { |
| 113 | if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") { |
| 114 | files = append(files, entry.Name()) |
| 115 | } |
| 116 | } |
| 117 | sort.Strings(files) |
| 118 | |
| 119 | for _, name := range files { |
| 120 | var exists int |
| 121 | err := d.QueryRowContext(ctx, `SELECT 1 FROM schema_migrations WHERE version = ?`, name).Scan(&exists) |
| 122 | if err == nil { |
| 123 | continue |
| 124 | } |
| 125 | if !errors.Is(err, sql.ErrNoRows) { |
| 126 | return fmt.Errorf("check migration %s: %w", name, err) |
| 127 | } |
| 128 | |
| 129 | content, err := migrationsFS.ReadFile("migrations/" + name) |
| 130 | if err != nil { |
| 131 | return fmt.Errorf("read migration %s: %w", name, err) |
| 132 | } |
| 133 | |
| 134 | tx, err := d.BeginTx(ctx, nil) |
| 135 | if err != nil { |
| 136 | return fmt.Errorf("begin migration tx %s: %w", name, err) |
| 137 | } |
| 138 | |
| 139 | if _, err := tx.ExecContext(ctx, string(content)); err != nil { |
| 140 | _ = tx.Rollback() |
| 141 | return fmt.Errorf("execute migration %s: %w", name, err) |
| 142 | } |
| 143 | if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations (version) VALUES (?)`, name); err != nil { |
| 144 | _ = tx.Rollback() |
| 145 | return fmt.Errorf("record migration %s: %w", name, err) |
| 146 | } |
| 147 | if err := tx.Commit(); err != nil { |
| 148 | return fmt.Errorf("commit migration %s: %w", name, err) |
| 149 | } |
| 150 | } |
| 151 | |
| 152 | return nil |
| 153 | } |
| 154 | |
| 155 | func (d *DB) GetSetting(ctx context.Context, key string) (string, error) { |
| 156 | cctx, cancel := context.WithTimeout(ctx, 3*time.Second) |
| 157 | defer cancel() |
| 158 | var val string |
| 159 | err := d.QueryRowContext(cctx, `SELECT value FROM settings WHERE key = ?`, key).Scan(&val) |
| 160 | if err != nil { |
| 161 | return "", fmt.Errorf("get setting %s: %w", key, err) |
| 162 | } |
| 163 | return val, nil |
| 164 | } |
| 165 | |
| 166 | func (d *DB) SetSetting(ctx context.Context, key, value string) error { |
| 167 | cctx, cancel := context.WithTimeout(ctx, 3*time.Second) |
| 168 | defer cancel() |
| 169 | _, err := d.ExecContext(cctx, ` |
| 170 | INSERT INTO settings (key, value) VALUES (?, ?) |
| 171 | ON CONFLICT(key) DO UPDATE SET value = excluded.value |
| 172 | `, key, value) |
| 173 | if err != nil { |
| 174 | return fmt.Errorf("set setting %s: %w", key, err) |
| 175 | } |
| 176 | return nil |
| 177 | } |
| 178 | |
| 179 | func (d *DB) GetBoolSetting(ctx context.Context, key string, fallback bool) bool { |
| 180 | val, err := d.GetSetting(ctx, key) |
| 181 | if err != nil { |
| 182 | return fallback |
| 183 | } |
| 184 | b, err := strconv.ParseBool(val) |
| 185 | if err != nil { |
| 186 | return fallback |
| 187 | } |
| 188 | return b |
| 189 | } |
| 190 | |
| 191 | func (d *DB) SetBoolSetting(ctx context.Context, key string, enabled bool) error { |
| 192 | return d.SetSetting(ctx, key, strconv.FormatBool(enabled)) |
| 193 | } |
| 194 | |
| 195 | const ( |
| 196 | RegistrationModePublic = "public" |
| 197 | RegistrationModeConfirm = "confirm" |
| 198 | RegistrationModeClosed = "closed" |
| 199 | ) |
| 200 | |
| 201 | func ValidRegistrationMode(mode string) bool { |
| 202 | switch mode { |
| 203 | case RegistrationModePublic, RegistrationModeConfirm, RegistrationModeClosed: |
| 204 | return true |
| 205 | default: |
| 206 | return false |
| 207 | } |
| 208 | } |
| 209 | |
| 210 | // GetRegistrationMode returns the current registration policy. Older databases |
| 211 | // only have the boolean registration_enabled setting, so fall back to it. |
| 212 | func (d *DB) GetRegistrationMode(ctx context.Context, fallback bool) string { |
| 213 | mode, err := d.GetSetting(ctx, "registration_mode") |
| 214 | if err == nil && ValidRegistrationMode(mode) { |
| 215 | return mode |
| 216 | } |
| 217 | if d.IsRegistrationEnabled(ctx, fallback) { |
| 218 | return RegistrationModePublic |
| 219 | } |
| 220 | return RegistrationModeClosed |
| 221 | } |
| 222 | |
| 223 | func (d *DB) SetRegistrationMode(ctx context.Context, mode string) error { |
| 224 | if !ValidRegistrationMode(mode) { |
| 225 | return fmt.Errorf("invalid registration mode %q", mode) |
| 226 | } |
| 227 | if err := d.SetSetting(ctx, "registration_mode", mode); err != nil { |
| 228 | return err |
| 229 | } |
| 230 | return d.SetBoolSetting(ctx, "registration_enabled", mode != RegistrationModeClosed) |
| 231 | } |
| 232 | |
| 233 | func (d *DB) IsRegistrationEnabled(ctx context.Context, fallback bool) bool { |
| 234 | return d.GetBoolSetting(ctx, "registration_enabled", fallback) |
| 235 | } |
| 236 | |
| 237 | func (d *DB) SetRegistrationEnabled(ctx context.Context, enabled bool) error { |
| 238 | mode := RegistrationModeClosed |
| 239 | if enabled { |
| 240 | mode = RegistrationModePublic |
| 241 | } |
| 242 | return d.SetRegistrationMode(ctx, mode) |
| 243 | } |
| 244 | |
| 245 | func (d *DB) IsExploreEnabled(ctx context.Context) bool { |
| 246 | return d.GetBoolSetting(ctx, "explore_enabled", true) |
| 247 | } |
| 248 | |
| 249 | func (d *DB) IsLandingEnabled(ctx context.Context, fallback bool) bool { |
| 250 | return d.GetBoolSetting(ctx, "landing_enabled", fallback) |
| 251 | } |
| 252 | |
| 253 | func (d *DB) IsAnonymousReadEnabled(ctx context.Context) bool { |
| 254 | return d.GetBoolSetting(ctx, "anonymous_read_enabled", true) |
| 255 | } |
| 256 | |
| 257 | func (d *DB) IsIssuesEnabled(ctx context.Context) bool { |
| 258 | return d.GetBoolSetting(ctx, "issues_enabled", true) |
| 259 | } |
| 260 |