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