rin
Public Open-source lightweight git hosting.
db.go · 206 lines · 5.3 KB
Raw
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 return d, nil
82 }
83
84 func (d *DB) Migrate(ctx context.Context) error {
85 _, err := d.ExecContext(ctx, `
86 CREATE TABLE IF NOT EXISTS schema_migrations (
87 version TEXT PRIMARY KEY,
88 applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
89 );
90 `)
91 if err != nil {
92 return fmt.Errorf("create schema_migrations table: %w", err)
93 }
94
95 entries, err := fs.ReadDir(migrationsFS, "migrations")
96 if err != nil {
97 return fmt.Errorf("read embedded migrations: %w", err)
98 }
99
100 var files []string
101 for _, entry := range entries {
102 if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") {
103 files = append(files, entry.Name())
104 }
105 }
106 sort.Strings(files)
107
108 for _, name := range files {
109 var exists int
110 err := d.QueryRowContext(ctx, `SELECT 1 FROM schema_migrations WHERE version = ?`, name).Scan(&exists)
111 if err == nil {
112 continue
113 }
114 if !errors.Is(err, sql.ErrNoRows) {
115 return fmt.Errorf("check migration %s: %w", name, err)
116 }
117
118 content, err := migrationsFS.ReadFile("migrations/" + name)
119 if err != nil {
120 return fmt.Errorf("read migration %s: %w", name, err)
121 }
122
123 tx, err := d.BeginTx(ctx, nil)
124 if err != nil {
125 return fmt.Errorf("begin migration tx %s: %w", name, err)
126 }
127
128 if _, err := tx.ExecContext(ctx, string(content)); err != nil {
129 _ = tx.Rollback()
130 return fmt.Errorf("execute migration %s: %w", name, err)
131 }
132 if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations (version) VALUES (?)`, name); err != nil {
133 _ = tx.Rollback()
134 return fmt.Errorf("record migration %s: %w", name, err)
135 }
136 if err := tx.Commit(); err != nil {
137 return fmt.Errorf("commit migration %s: %w", name, err)
138 }
139 }
140
141 return nil
142 }
143
144 func (d *DB) GetSetting(ctx context.Context, key string) (string, error) {
145 cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
146 defer cancel()
147 var val string
148 err := d.QueryRowContext(cctx, `SELECT value FROM settings WHERE key = ?`, key).Scan(&val)
149 if err != nil {
150 return "", fmt.Errorf("get setting %s: %w", key, err)
151 }
152 return val, nil
153 }
154
155 func (d *DB) SetSetting(ctx context.Context, key, value string) error {
156 cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
157 defer cancel()
158 _, err := d.ExecContext(cctx, `
159 INSERT INTO settings (key, value) VALUES (?, ?)
160 ON CONFLICT(key) DO UPDATE SET value = excluded.value
161 `, key, value)
162 if err != nil {
163 return fmt.Errorf("set setting %s: %w", key, err)
164 }
165 return nil
166 }
167
168 func (d *DB) GetBoolSetting(ctx context.Context, key string, fallback bool) bool {
169 val, err := d.GetSetting(ctx, key)
170 if err != nil {
171 return fallback
172 }
173 b, err := strconv.ParseBool(val)
174 if err != nil {
175 return fallback
176 }
177 return b
178 }
179
180 func (d *DB) SetBoolSetting(ctx context.Context, key string, enabled bool) error {
181 return d.SetSetting(ctx, key, strconv.FormatBool(enabled))
182 }
183
184 func (d *DB) IsRegistrationEnabled(ctx context.Context, fallback bool) bool {
185 return d.GetBoolSetting(ctx, "registration_enabled", fallback)
186 }
187
188 func (d *DB) SetRegistrationEnabled(ctx context.Context, enabled bool) error {
189 return d.SetBoolSetting(ctx, "registration_enabled", enabled)
190 }
191
192 func (d *DB) IsExploreEnabled(ctx context.Context) bool {
193 return d.GetBoolSetting(ctx, "explore_enabled", true)
194 }
195
196 func (d *DB) IsLandingEnabled(ctx context.Context, fallback bool) bool {
197 return d.GetBoolSetting(ctx, "landing_enabled", fallback)
198 }
199
200 func (d *DB) IsAnonymousReadEnabled(ctx context.Context) bool {
201 return d.GetBoolSetting(ctx, "anonymous_read_enabled", true)
202 }
203
204 func (d *DB) IsIssuesEnabled(ctx context.Context) bool {
205 return d.GetBoolSetting(ctx, "issues_enabled", true)
206 }
207