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