1package sqlite
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "os"
8 "path/filepath"
9 "strings"
10 "time"
11
12 "github.com/charmbracelet/log"
13 "github.com/charmbracelet/soft-serve/git"
14 "github.com/charmbracelet/soft-serve/server/backend"
15 "github.com/charmbracelet/soft-serve/server/config"
16 "github.com/charmbracelet/soft-serve/server/hooks"
17 "github.com/charmbracelet/soft-serve/server/utils"
18 "github.com/jmoiron/sqlx"
19 _ "modernc.org/sqlite" // sqlite driver
20)
21
22// SqliteBackend is a backend that uses a SQLite database as a Soft Serve
23// backend.
24type SqliteBackend struct { //nolint: revive
25 cfg *config.Config
26 ctx context.Context
27 dp string
28 db *sqlx.DB
29 logger *log.Logger
30}
31
32var _ backend.Backend = (*SqliteBackend)(nil)
33
34func (d *SqliteBackend) reposPath() string {
35 return filepath.Join(d.dp, "repos")
36}
37
38// NewSqliteBackend creates a new SqliteBackend.
39func NewSqliteBackend(ctx context.Context) (*SqliteBackend, error) {
40 cfg := config.FromContext(ctx)
41 dataPath := cfg.DataPath
42 if err := os.MkdirAll(dataPath, os.ModePerm); err != nil {
43 return nil, err
44 }
45
46 db, err := sqlx.Connect("sqlite", filepath.Join(dataPath, "soft-serve.db"+
47 "?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)"))
48 if err != nil {
49 return nil, err
50 }
51
52 d := &SqliteBackend{
53 cfg: cfg,
54 ctx: ctx,
55 dp: dataPath,
56 db: db,
57 logger: log.FromContext(ctx).WithPrefix("sqlite"),
58 }
59
60 if err := d.init(); err != nil {
61 return nil, err
62 }
63
64 if err := d.db.Ping(); err != nil {
65 return nil, err
66 }
67
68 return d, d.initRepos()
69}
70
71// WithContext returns a copy of SqliteBackend with the given context.
72func (d SqliteBackend) WithContext(ctx context.Context) backend.Backend {
73 d.ctx = ctx
74 return &d
75}
76
77// AllowKeyless returns whether or not keyless access is allowed.
78//
79// It implements backend.Backend.
80func (d *SqliteBackend) AllowKeyless() bool {
81 var allow bool
82 if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
83 return tx.Get(&allow, "SELECT value FROM settings WHERE key = ?;", "allow_keyless")
84 }); err != nil {
85 return false
86 }
87
88 return allow
89}
90
91// AnonAccess returns the level of anonymous access.
92//
93// It implements backend.Backend.
94func (d *SqliteBackend) AnonAccess() backend.AccessLevel {
95 var level string
96 if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
97 return tx.Get(&level, "SELECT value FROM settings WHERE key = ?;", "anon_access")
98 }); err != nil {
99 return backend.NoAccess
100 }
101
102 return backend.ParseAccessLevel(level)
103}
104
105// SetAllowKeyless sets whether or not keyless access is allowed.
106//
107// It implements backend.Backend.
108func (d *SqliteBackend) SetAllowKeyless(allow bool) error {
109 return wrapDbErr(
110 wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
111 _, err := tx.Exec("UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = ?;", allow, "allow_keyless")
112 return err
113 }),
114 )
115}
116
117// SetAnonAccess sets the level of anonymous access.
118//
119// It implements backend.Backend.
120func (d *SqliteBackend) SetAnonAccess(level backend.AccessLevel) error {
121 return wrapDbErr(
122 wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
123 _, err := tx.Exec("UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = ?;", level.String(), "anon_access")
124 return err
125 }),
126 )
127}
128
129// CreateRepository creates a new repository.
130//
131// It implements backend.Backend.
132func (d *SqliteBackend) CreateRepository(name string, opts backend.RepositoryOptions) (backend.Repository, error) {
133 name = utils.SanitizeRepo(name)
134 if err := utils.ValidateRepo(name); err != nil {
135 return nil, err
136 }
137
138 repo := name + ".git"
139 rp := filepath.Join(d.reposPath(), repo)
140
141 cleanup := func() error {
142 return os.RemoveAll(rp)
143 }
144
145 rr, err := git.Init(rp, true)
146 if err != nil {
147 d.logger.Debug("failed to create repository", "err", err)
148 cleanup() // nolint: errcheck
149 return nil, err
150 }
151
152 if err := rr.UpdateServerInfo(); err != nil {
153 d.logger.Debug("failed to update server info", "err", err)
154 cleanup() // nolint: errcheck
155 return nil, err
156 }
157
158 if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
159 _, err := tx.Exec(`INSERT INTO repo (name, project_name, description, private, mirror, hidden, updated_at)
160 VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP);`,
161 name, opts.ProjectName, opts.Description, opts.Private, opts.Mirror, opts.Hidden)
162 return err
163 }); err != nil {
164 d.logger.Debug("failed to create repository in database", "err", err)
165 return nil, wrapDbErr(err)
166 }
167
168 r := &Repo{
169 name: name,
170 path: rp,
171 db: d.db,
172 }
173
174 return r, d.initRepo(name)
175}
176
177// ImportRepository imports a repository from remote.
178func (d *SqliteBackend) ImportRepository(name string, remote string, opts backend.RepositoryOptions) (backend.Repository, error) {
179 name = utils.SanitizeRepo(name)
180 if err := utils.ValidateRepo(name); err != nil {
181 return nil, err
182 }
183
184 repo := name + ".git"
185 rp := filepath.Join(d.reposPath(), repo)
186
187 if _, err := os.Stat(rp); err == nil || os.IsExist(err) {
188 return nil, ErrRepoExist
189 }
190
191 copts := git.CloneOptions{
192 Bare: true,
193 Mirror: opts.Mirror,
194 Quiet: true,
195 Timeout: 15 * time.Minute,
196 CommandOptions: git.CommandOptions{
197 Timeout: -1,
198 Context: d.ctx,
199 Envs: []string{
200 fmt.Sprintf(`GIT_SSH_COMMAND=ssh -o UserKnownHostsFile="%s" -o StrictHostKeyChecking=no -i "%s"`,
201 filepath.Join(d.cfg.DataPath, "ssh", "known_hosts"),
202 d.cfg.SSH.ClientKeyPath,
203 ),
204 },
205 },
206 }
207
208 if err := git.Clone(remote, rp, copts); err != nil {
209 d.logger.Error("failed to clone repository", "err", err, "mirror", opts.Mirror, "remote", remote, "path", rp)
210 if rerr := os.RemoveAll(rp); rerr != nil {
211 err = errors.Join(err, rerr)
212 }
213 return nil, err
214 }
215
216 return d.CreateRepository(name, opts)
217}
218
219// DeleteRepository deletes a repository.
220//
221// It implements backend.Backend.
222func (d *SqliteBackend) DeleteRepository(name string) error {
223 name = utils.SanitizeRepo(name)
224 repo := name + ".git"
225 rp := filepath.Join(d.reposPath(), repo)
226
227 return wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
228 if err := os.RemoveAll(rp); err != nil {
229 return err
230 }
231 _, err := tx.Exec("DELETE FROM repo WHERE name = ?;", name)
232 return err
233 })
234}
235
236// RenameRepository renames a repository.
237//
238// It implements backend.Backend.
239func (d *SqliteBackend) RenameRepository(oldName string, newName string) error {
240 oldName = utils.SanitizeRepo(oldName)
241 if err := utils.ValidateRepo(oldName); err != nil {
242 return err
243 }
244
245 newName = utils.SanitizeRepo(newName)
246 if err := utils.ValidateRepo(newName); err != nil {
247 return err
248 }
249 oldRepo := oldName + ".git"
250 newRepo := newName + ".git"
251 op := filepath.Join(d.reposPath(), oldRepo)
252 np := filepath.Join(d.reposPath(), newRepo)
253 if _, err := os.Stat(op); err != nil {
254 return fmt.Errorf("repository %s does not exist", oldName)
255 }
256
257 if _, err := os.Stat(np); err == nil {
258 return fmt.Errorf("repository %s already exists", newName)
259 }
260
261 if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
262 _, err := tx.Exec("UPDATE repo SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?;", newName, oldName)
263 return err
264 }); err != nil {
265 return wrapDbErr(err)
266 }
267
268 // Make sure the new repository parent directory exists.
269 if err := os.MkdirAll(filepath.Dir(np), os.ModePerm); err != nil {
270 return err
271 }
272
273 return os.Rename(op, np)
274}
275
276// Repositories returns a list of all repositories.
277//
278// It implements backend.Backend.
279func (d *SqliteBackend) Repositories() ([]backend.Repository, error) {
280 repos := make([]backend.Repository, 0)
281 if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
282 rows, err := tx.Query("SELECT name FROM repo")
283 if err != nil {
284 return err
285 }
286
287 defer rows.Close() // nolint: errcheck
288 for rows.Next() {
289 var name string
290 if err := rows.Scan(&name); err != nil {
291 return err
292 }
293
294 repos = append(repos, &Repo{
295 name: name,
296 path: filepath.Join(d.reposPath(), name+".git"),
297 db: d.db,
298 })
299 }
300
301 return nil
302 }); err != nil {
303 return nil, wrapDbErr(err)
304 }
305
306 return repos, nil
307}
308
309// Repository returns a repository by name.
310//
311// It implements backend.Backend.
312func (d *SqliteBackend) Repository(repo string) (backend.Repository, error) {
313 repo = utils.SanitizeRepo(repo)
314 rp := filepath.Join(d.reposPath(), repo+".git")
315 if _, err := os.Stat(rp); err != nil {
316 return nil, os.ErrNotExist
317 }
318
319 var count int
320 if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
321 return tx.Get(&count, "SELECT COUNT(*) FROM repo WHERE name = ?", repo)
322 }); err != nil {
323 return nil, wrapDbErr(err)
324 }
325
326 if count == 0 {
327 d.logger.Warn("repository exists but not found in database", "repo", repo)
328 return nil, ErrRepoNotExist
329 }
330
331 return &Repo{
332 name: repo,
333 path: rp,
334 db: d.db,
335 }, nil
336}
337
338// Description returns the description of a repository.
339//
340// It implements backend.Backend.
341func (d *SqliteBackend) Description(repo string) (string, error) {
342 repo = utils.SanitizeRepo(repo)
343 var desc string
344 if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
345 row := tx.QueryRow("SELECT description FROM repo WHERE name = ?", repo)
346 return row.Scan(&desc)
347 }); err != nil {
348 return "", wrapDbErr(err)
349 }
350
351 return desc, nil
352}
353
354// IsMirror returns true if the repository is a mirror.
355//
356// It implements backend.Backend.
357func (d *SqliteBackend) IsMirror(repo string) (bool, error) {
358 repo = utils.SanitizeRepo(repo)
359 var mirror bool
360 if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
361 return tx.Get(&mirror, "SELECT mirror FROM repo WHERE name = ?", repo)
362 }); err != nil {
363 return false, wrapDbErr(err)
364 }
365
366 return mirror, nil
367}
368
369// IsPrivate returns true if the repository is private.
370//
371// It implements backend.Backend.
372func (d *SqliteBackend) IsPrivate(repo string) (bool, error) {
373 repo = utils.SanitizeRepo(repo)
374 var private bool
375 if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
376 row := tx.QueryRow("SELECT private FROM repo WHERE name = ?", repo)
377 return row.Scan(&private)
378 }); err != nil {
379 return false, wrapDbErr(err)
380 }
381
382 return private, nil
383}
384
385// IsHidden returns true if the repository is hidden.
386//
387// It implements backend.Backend.
388func (d *SqliteBackend) IsHidden(repo string) (bool, error) {
389 repo = utils.SanitizeRepo(repo)
390 var hidden bool
391 if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
392 row := tx.QueryRow("SELECT hidden FROM repo WHERE name = ?", repo)
393 return row.Scan(&hidden)
394 }); err != nil {
395 return false, wrapDbErr(err)
396 }
397
398 return hidden, nil
399}
400
401// SetHidden sets the hidden flag of a repository.
402//
403// It implements backend.Backend.
404func (d *SqliteBackend) SetHidden(repo string, hidden bool) error {
405 repo = utils.SanitizeRepo(repo)
406 return wrapDbErr(wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
407 var count int
408 if err := tx.Get(&count, "SELECT COUNT(*) FROM repo WHERE name = ?", repo); err != nil {
409 return err
410 }
411 if count == 0 {
412 return ErrRepoNotExist
413 }
414 _, err := tx.Exec("UPDATE repo SET hidden = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?;", hidden, repo)
415 return err
416 }))
417}
418
419// ProjectName returns the project name of a repository.
420//
421// It implements backend.Backend.
422func (d *SqliteBackend) ProjectName(repo string) (string, error) {
423 repo = utils.SanitizeRepo(repo)
424 var name string
425 if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
426 row := tx.QueryRow("SELECT project_name FROM repo WHERE name = ?", repo)
427 return row.Scan(&name)
428 }); err != nil {
429 return "", wrapDbErr(err)
430 }
431
432 return name, nil
433}
434
435// SetDescription sets the description of a repository.
436//
437// It implements backend.Backend.
438func (d *SqliteBackend) SetDescription(repo string, desc string) error {
439 repo = utils.SanitizeRepo(repo)
440 return wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
441 var count int
442 if err := tx.Get(&count, "SELECT COUNT(*) FROM repo WHERE name = ?", repo); err != nil {
443 return err
444 }
445 if count == 0 {
446 return ErrRepoNotExist
447 }
448 _, err := tx.Exec("UPDATE repo SET description = ? WHERE name = ?", desc, repo)
449 return err
450 })
451}
452
453// SetPrivate sets the private flag of a repository.
454//
455// It implements backend.Backend.
456func (d *SqliteBackend) SetPrivate(repo string, private bool) error {
457 repo = utils.SanitizeRepo(repo)
458 return wrapDbErr(
459 wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
460 var count int
461 if err := tx.Get(&count, "SELECT COUNT(*) FROM repo WHERE name = ?", repo); err != nil {
462 return err
463 }
464 if count == 0 {
465 return ErrRepoNotExist
466 }
467 _, err := tx.Exec("UPDATE repo SET private = ? WHERE name = ?", private, repo)
468 return err
469 }),
470 )
471}
472
473// SetProjectName sets the project name of a repository.
474//
475// It implements backend.Backend.
476func (d *SqliteBackend) SetProjectName(repo string, name string) error {
477 repo = utils.SanitizeRepo(repo)
478 return wrapDbErr(
479 wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
480 var count int
481 if err := tx.Get(&count, "SELECT COUNT(*) FROM repo WHERE name = ?", repo); err != nil {
482 return err
483 }
484 if count == 0 {
485 return ErrRepoNotExist
486 }
487 _, err := tx.Exec("UPDATE repo SET project_name = ? WHERE name = ?", name, repo)
488 return err
489 }),
490 )
491}
492
493// AddCollaborator adds a collaborator to a repository.
494//
495// It implements backend.Backend.
496func (d *SqliteBackend) AddCollaborator(repo string, username string) error {
497 username = strings.ToLower(username)
498 if err := utils.ValidateUsername(username); err != nil {
499 return err
500 }
501
502 repo = utils.SanitizeRepo(repo)
503 return wrapDbErr(wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
504 _, err := tx.Exec(`INSERT INTO collab (user_id, repo_id, updated_at)
505 VALUES (
506 (SELECT id FROM user WHERE username = ?),
507 (SELECT id FROM repo WHERE name = ?),
508 CURRENT_TIMESTAMP
509 );`, username, repo)
510 return err
511 }),
512 )
513}
514
515// Collaborators returns a list of collaborators for a repository.
516//
517// It implements backend.Backend.
518func (d *SqliteBackend) Collaborators(repo string) ([]string, error) {
519 repo = utils.SanitizeRepo(repo)
520 var users []string
521 if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
522 return tx.Select(&users, `SELECT user.username FROM user
523 INNER JOIN collab ON user.id = collab.user_id
524 INNER JOIN repo ON repo.id = collab.repo_id
525 WHERE repo.name = ?`, repo)
526 }); err != nil {
527 return nil, wrapDbErr(err)
528 }
529
530 return users, nil
531}
532
533// IsCollaborator returns true if the user is a collaborator of the repository.
534//
535// It implements backend.Backend.
536func (d *SqliteBackend) IsCollaborator(repo string, username string) (bool, error) {
537 repo = utils.SanitizeRepo(repo)
538 var count int
539 if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
540 return tx.Get(&count, `SELECT COUNT(*) FROM user
541 INNER JOIN collab ON user.id = collab.user_id
542 INNER JOIN repo ON repo.id = collab.repo_id
543 WHERE repo.name = ? AND user.username = ?`, repo, username)
544 }); err != nil {
545 return false, wrapDbErr(err)
546 }
547
548 return count > 0, nil
549}
550
551// RemoveCollaborator removes a collaborator from a repository.
552//
553// It implements backend.Backend.
554func (d *SqliteBackend) RemoveCollaborator(repo string, username string) error {
555 repo = utils.SanitizeRepo(repo)
556 return wrapDbErr(
557 wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
558 _, err := tx.Exec(`DELETE FROM collab
559 WHERE user_id = (SELECT id FROM user WHERE username = ?)
560 AND repo_id = (SELECT id FROM repo WHERE name = ?)`, username, repo)
561 return err
562 }),
563 )
564}
565
566func (d *SqliteBackend) initRepo(repo string) error {
567 return hooks.GenerateHooks(d.ctx, d.cfg, repo)
568}
569
570func (d *SqliteBackend) initRepos() error {
571 repos, err := d.Repositories()
572 if err != nil {
573 return err
574 }
575
576 for _, repo := range repos {
577 if err := d.initRepo(repo.Name()); err != nil {
578 return err
579 }
580 }
581
582 return nil
583}