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 if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
148 if _, err := tx.Exec(`INSERT INTO repo (name, project_name, description, private, mirror, hidden, updated_at)
149 VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP);`,
150 name, opts.ProjectName, opts.Description, opts.Private, opts.Mirror, opts.Hidden); err != nil {
151 return err
152 }
153
154 _, err := git.Init(rp, true)
155 if err != nil {
156 d.logger.Debug("failed to create repository", "err", err)
157 return err
158 }
159
160 return nil
161 }); err != nil {
162 d.logger.Debug("failed to create repository in database", "err", err)
163 return nil, wrapDbErr(err)
164 }
165
166 r := &Repo{
167 name: name,
168 path: rp,
169 db: d.db,
170 }
171
172 // Set cache
173 d.cache.Set(name, r)
174
175 return r, d.initRepo(name)
176}
177
178// ImportRepository imports a repository from remote.
179func (d *SqliteBackend) ImportRepository(name string, remote string, opts backend.RepositoryOptions) (backend.Repository, error) {
180 name = utils.SanitizeRepo(name)
181 if err := utils.ValidateRepo(name); err != nil {
182 return nil, err
183 }
184
185 repo := name + ".git"
186 rp := filepath.Join(d.reposPath(), repo)
187
188 if _, err := os.Stat(rp); err == nil || os.IsExist(err) {
189 return nil, ErrRepoExist
190 }
191
192 copts := git.CloneOptions{
193 Bare: true,
194 Mirror: opts.Mirror,
195 Quiet: true,
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 // Timeout: time.Hour,
207 }
208
209 if err := git.Clone(remote, rp, copts); err != nil {
210 d.logger.Error("failed to clone repository", "err", err, "mirror", opts.Mirror, "remote", remote, "path", rp)
211 // Cleanup the mess!
212 if rerr := os.RemoveAll(rp); rerr != nil {
213 err = errors.Join(err, rerr)
214 }
215 return nil, err
216 }
217
218 return d.CreateRepository(name, opts)
219}
220
221// DeleteRepository deletes a repository.
222//
223// It implements backend.Backend.
224func (d *SqliteBackend) DeleteRepository(name string) error {
225 name = utils.SanitizeRepo(name)
226 repo := name + ".git"
227 rp := filepath.Join(d.reposPath(), repo)
228
229 return wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
230 // Delete repo from cache
231 defer d.cache.Delete(name)
232
233 if _, err := tx.Exec("DELETE FROM repo WHERE name = ?;", name); err != nil {
234 return err
235 }
236
237 return os.RemoveAll(rp)
238 })
239}
240
241// RenameRepository renames a repository.
242//
243// It implements backend.Backend.
244func (d *SqliteBackend) RenameRepository(oldName string, newName string) error {
245 oldName = utils.SanitizeRepo(oldName)
246 if err := utils.ValidateRepo(oldName); err != nil {
247 return err
248 }
249
250 newName = utils.SanitizeRepo(newName)
251 if err := utils.ValidateRepo(newName); err != nil {
252 return err
253 }
254 oldRepo := oldName + ".git"
255 newRepo := newName + ".git"
256 op := filepath.Join(d.reposPath(), oldRepo)
257 np := filepath.Join(d.reposPath(), newRepo)
258 if _, err := os.Stat(op); err != nil {
259 return ErrRepoNotExist
260 }
261
262 if _, err := os.Stat(np); err == nil {
263 return ErrRepoExist
264 }
265
266 if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
267 // Delete cache
268 defer d.cache.Delete(oldName)
269
270 _, err := tx.Exec("UPDATE repo SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?;", newName, oldName)
271 if err != nil {
272 return err
273 }
274
275 // Make sure the new repository parent directory exists.
276 if err := os.MkdirAll(filepath.Dir(np), os.ModePerm); err != nil {
277 return err
278 }
279
280 if err := os.Rename(op, np); err != nil {
281 return err
282 }
283
284 return nil
285 }); err != nil {
286 return wrapDbErr(err)
287 }
288
289 return nil
290}
291
292// Repositories returns a list of all repositories.
293//
294// It implements backend.Backend.
295func (d *SqliteBackend) Repositories() ([]backend.Repository, error) {
296 repos := make([]backend.Repository, 0)
297
298 if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
299 rows, err := tx.Query("SELECT name FROM repo")
300 if err != nil {
301 return err
302 }
303
304 defer rows.Close() // nolint: errcheck
305 for rows.Next() {
306 var name string
307 if err := rows.Scan(&name); err != nil {
308 return err
309 }
310
311 if r, ok := d.cache.Get(name); ok && r != nil {
312 repos = append(repos, r)
313 continue
314 }
315
316 r := &Repo{
317 name: name,
318 path: filepath.Join(d.reposPath(), name+".git"),
319 db: d.db,
320 }
321
322 // Cache repositories
323 d.cache.Set(name, r)
324
325 repos = append(repos, r)
326 }
327
328 return nil
329 }); err != nil {
330 return nil, wrapDbErr(err)
331 }
332
333 return repos, nil
334}
335
336// Repository returns a repository by name.
337//
338// It implements backend.Backend.
339func (d *SqliteBackend) Repository(repo string) (backend.Repository, error) {
340 repo = utils.SanitizeRepo(repo)
341
342 if r, ok := d.cache.Get(repo); ok && r != nil {
343 return r, nil
344 }
345
346 rp := filepath.Join(d.reposPath(), repo+".git")
347 if _, err := os.Stat(rp); err != nil {
348 return nil, os.ErrNotExist
349 }
350
351 var count int
352 if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
353 return tx.Get(&count, "SELECT COUNT(*) FROM repo WHERE name = ?", repo)
354 }); err != nil {
355 return nil, wrapDbErr(err)
356 }
357
358 if count == 0 {
359 d.logger.Warn("repository exists but not found in database", "repo", repo)
360 return nil, ErrRepoNotExist
361 }
362
363 r := &Repo{
364 name: repo,
365 path: rp,
366 db: d.db,
367 }
368
369 // Add to cache
370 d.cache.Set(repo, r)
371
372 return r, nil
373}
374
375// Description returns the description of a repository.
376//
377// It implements backend.Backend.
378func (d *SqliteBackend) Description(repo string) (string, error) {
379 r, err := d.Repository(repo)
380 if err != nil {
381 return "", err
382 }
383
384 return r.Description(), nil
385}
386
387// IsMirror returns true if the repository is a mirror.
388//
389// It implements backend.Backend.
390func (d *SqliteBackend) IsMirror(repo string) (bool, error) {
391 r, err := d.Repository(repo)
392 if err != nil {
393 return false, err
394 }
395
396 return r.IsMirror(), nil
397}
398
399// IsPrivate returns true if the repository is private.
400//
401// It implements backend.Backend.
402func (d *SqliteBackend) IsPrivate(repo string) (bool, error) {
403 r, err := d.Repository(repo)
404 if err != nil {
405 return false, err
406 }
407
408 return r.IsPrivate(), nil
409}
410
411// IsHidden returns true if the repository is hidden.
412//
413// It implements backend.Backend.
414func (d *SqliteBackend) IsHidden(repo string) (bool, error) {
415 r, err := d.Repository(repo)
416 if err != nil {
417 return false, err
418 }
419
420 return r.IsHidden(), nil
421}
422
423// SetHidden sets the hidden flag of a repository.
424//
425// It implements backend.Backend.
426func (d *SqliteBackend) SetHidden(repo string, hidden bool) error {
427 repo = utils.SanitizeRepo(repo)
428
429 // Delete cache
430 d.cache.Delete(repo)
431
432 return wrapDbErr(wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
433 var count int
434 if err := tx.Get(&count, "SELECT COUNT(*) FROM repo WHERE name = ?", repo); err != nil {
435 return err
436 }
437 if count == 0 {
438 return ErrRepoNotExist
439 }
440 _, err := tx.Exec("UPDATE repo SET hidden = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?;", hidden, repo)
441 return err
442 }))
443}
444
445// ProjectName returns the project name of a repository.
446//
447// It implements backend.Backend.
448func (d *SqliteBackend) ProjectName(repo string) (string, error) {
449 r, err := d.Repository(repo)
450 if err != nil {
451 return "", err
452 }
453
454 return r.ProjectName(), nil
455}
456
457// SetDescription sets the description of a repository.
458//
459// It implements backend.Backend.
460func (d *SqliteBackend) SetDescription(repo string, desc string) error {
461 repo = utils.SanitizeRepo(repo)
462
463 // Delete cache
464 d.cache.Delete(repo)
465
466 return wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
467 var count int
468 if err := tx.Get(&count, "SELECT COUNT(*) FROM repo WHERE name = ?", repo); err != nil {
469 return err
470 }
471 if count == 0 {
472 return ErrRepoNotExist
473 }
474 _, err := tx.Exec("UPDATE repo SET description = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?", desc, repo)
475 return err
476 })
477}
478
479// SetPrivate sets the private flag of a repository.
480//
481// It implements backend.Backend.
482func (d *SqliteBackend) SetPrivate(repo string, private bool) error {
483 repo = utils.SanitizeRepo(repo)
484
485 // Delete cache
486 d.cache.Delete(repo)
487
488 return wrapDbErr(
489 wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
490 var count int
491 if err := tx.Get(&count, "SELECT COUNT(*) FROM repo WHERE name = ?", repo); err != nil {
492 return err
493 }
494 if count == 0 {
495 return ErrRepoNotExist
496 }
497 _, err := tx.Exec("UPDATE repo SET private = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?", private, repo)
498 return err
499 }),
500 )
501}
502
503// SetProjectName sets the project name of a repository.
504//
505// It implements backend.Backend.
506func (d *SqliteBackend) SetProjectName(repo string, name string) error {
507 repo = utils.SanitizeRepo(repo)
508
509 // Delete cache
510 d.cache.Delete(repo)
511
512 return wrapDbErr(
513 wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
514 var count int
515 if err := tx.Get(&count, "SELECT COUNT(*) FROM repo WHERE name = ?", repo); err != nil {
516 return err
517 }
518 if count == 0 {
519 return ErrRepoNotExist
520 }
521 _, err := tx.Exec("UPDATE repo SET project_name = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?", name, repo)
522 return err
523 }),
524 )
525}
526
527// AddCollaborator adds a collaborator to a repository.
528//
529// It implements backend.Backend.
530func (d *SqliteBackend) AddCollaborator(repo string, username string) error {
531 username = strings.ToLower(username)
532 if err := utils.ValidateUsername(username); err != nil {
533 return err
534 }
535
536 repo = utils.SanitizeRepo(repo)
537 return wrapDbErr(wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
538 _, err := tx.Exec(`INSERT INTO collab (user_id, repo_id, updated_at)
539 VALUES (
540 (SELECT id FROM user WHERE username = ?),
541 (SELECT id FROM repo WHERE name = ?),
542 CURRENT_TIMESTAMP
543 );`, username, repo)
544 return err
545 }),
546 )
547}
548
549// Collaborators returns a list of collaborators for a repository.
550//
551// It implements backend.Backend.
552func (d *SqliteBackend) Collaborators(repo string) ([]string, error) {
553 repo = utils.SanitizeRepo(repo)
554 var users []string
555 if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
556 return tx.Select(&users, `SELECT user.username FROM user
557 INNER JOIN collab ON user.id = collab.user_id
558 INNER JOIN repo ON repo.id = collab.repo_id
559 WHERE repo.name = ?`, repo)
560 }); err != nil {
561 return nil, wrapDbErr(err)
562 }
563
564 return users, nil
565}
566
567// IsCollaborator returns true if the user is a collaborator of the repository.
568//
569// It implements backend.Backend.
570func (d *SqliteBackend) IsCollaborator(repo string, username string) (bool, error) {
571 repo = utils.SanitizeRepo(repo)
572 var count int
573 if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
574 return tx.Get(&count, `SELECT COUNT(*) FROM user
575 INNER JOIN collab ON user.id = collab.user_id
576 INNER JOIN repo ON repo.id = collab.repo_id
577 WHERE repo.name = ? AND user.username = ?`, repo, username)
578 }); err != nil {
579 return false, wrapDbErr(err)
580 }
581
582 return count > 0, nil
583}
584
585// RemoveCollaborator removes a collaborator from a repository.
586//
587// It implements backend.Backend.
588func (d *SqliteBackend) RemoveCollaborator(repo string, username string) error {
589 repo = utils.SanitizeRepo(repo)
590 return wrapDbErr(
591 wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
592 _, err := tx.Exec(`DELETE FROM collab
593 WHERE user_id = (SELECT id FROM user WHERE username = ?)
594 AND repo_id = (SELECT id FROM repo WHERE name = ?)`, username, repo)
595 return err
596 }),
597 )
598}
599
600func (d *SqliteBackend) initRepo(repo string) error {
601 return hooks.GenerateHooks(d.ctx, d.cfg, repo)
602}
603
604func (d *SqliteBackend) initRepos() error {
605 repos, err := d.Repositories()
606 if err != nil {
607 return err
608 }
609
610 for _, repo := range repos {
611 if err := d.initRepo(repo.Name()); err != nil {
612 return err
613 }
614 }
615
616 return nil
617}
618
619// TODO: implement a caching interface.
620type cache struct {
621 b *SqliteBackend
622 repos *lru.Cache[string, *Repo]
623}
624
625func newCache(b *SqliteBackend, size int) *cache {
626 if size <= 0 {
627 size = 1
628 }
629 c := &cache{b: b}
630 cache, _ := lru.New[string, *Repo](size)
631 c.repos = cache
632 return c
633}
634
635func (c *cache) Get(repo string) (*Repo, bool) {
636 return c.repos.Get(repo)
637}
638
639func (c *cache) Set(repo string, r *Repo) {
640 c.repos.Add(repo, r)
641}
642
643func (c *cache) Delete(repo string) {
644 c.repos.Remove(repo)
645}
646
647func (c *cache) Len() int {
648 return c.repos.Len()
649}