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/cache"
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 // Repositories cache
32 cache 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 cache: cache.FromContext(ctx),
61 logger: log.FromContext(ctx).WithPrefix("sqlite"),
62 }
63
64 if err := d.init(); err != nil {
65 return nil, err
66 }
67
68 if err := d.db.Ping(); err != nil {
69 return nil, err
70 }
71
72 return d, d.initRepos()
73}
74
75// WithContext returns a shallow copy of SqliteBackend with the given context.
76func (d SqliteBackend) WithContext(ctx context.Context) backend.Backend {
77 d.ctx = ctx
78 return &d
79}
80
81// AllowKeyless returns whether or not keyless access is allowed.
82//
83// It implements backend.Backend.
84func (d *SqliteBackend) AllowKeyless() bool {
85 var allow bool
86 if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
87 return tx.Get(&allow, "SELECT value FROM settings WHERE key = ?;", "allow_keyless")
88 }); err != nil {
89 return false
90 }
91
92 return allow
93}
94
95// AnonAccess returns the level of anonymous access.
96//
97// It implements backend.Backend.
98func (d *SqliteBackend) AnonAccess() backend.AccessLevel {
99 var level string
100 if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
101 return tx.Get(&level, "SELECT value FROM settings WHERE key = ?;", "anon_access")
102 }); err != nil {
103 return backend.NoAccess
104 }
105
106 return backend.ParseAccessLevel(level)
107}
108
109// SetAllowKeyless sets whether or not keyless access is allowed.
110//
111// It implements backend.Backend.
112func (d *SqliteBackend) SetAllowKeyless(allow bool) error {
113 return wrapDbErr(
114 wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
115 _, err := tx.Exec("UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = ?;", allow, "allow_keyless")
116 return err
117 }),
118 )
119}
120
121// SetAnonAccess sets the level of anonymous access.
122//
123// It implements backend.Backend.
124func (d *SqliteBackend) SetAnonAccess(level backend.AccessLevel) error {
125 return wrapDbErr(
126 wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
127 _, err := tx.Exec("UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = ?;", level.String(), "anon_access")
128 return err
129 }),
130 )
131}
132
133// CreateRepository creates a new repository.
134//
135// It implements backend.Backend.
136func (d *SqliteBackend) CreateRepository(name string, opts backend.RepositoryOptions) (backend.Repository, error) {
137 name = utils.SanitizeRepo(name)
138 if err := utils.ValidateRepo(name); err != nil {
139 return nil, err
140 }
141
142 repo := name + ".git"
143 rp := filepath.Join(d.reposPath(), repo)
144
145 if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
146 if _, err := tx.Exec(`INSERT INTO repo (name, project_name, description, private, mirror, hidden, updated_at)
147 VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP);`,
148 name, opts.ProjectName, opts.Description, opts.Private, opts.Mirror, opts.Hidden); err != nil {
149 return err
150 }
151
152 _, err := git.Init(rp, true)
153 if err != nil {
154 d.logger.Debug("failed to create repository", "err", err)
155 return err
156 }
157
158 return nil
159 }); err != nil {
160 d.logger.Debug("failed to create repository in database", "err", err)
161 return nil, wrapDbErr(err)
162 }
163
164 r := &Repo{
165 name: name,
166 path: rp,
167 db: d.db,
168 }
169
170 // Set cache
171 d.cache.Set(d.ctx, cacheKey(name), r)
172
173 return r, d.initRepo(name)
174}
175
176// ImportRepository imports a repository from remote.
177func (d *SqliteBackend) ImportRepository(name string, remote string, opts backend.RepositoryOptions) (backend.Repository, error) {
178 name = utils.SanitizeRepo(name)
179 if err := utils.ValidateRepo(name); err != nil {
180 return nil, err
181 }
182
183 repo := name + ".git"
184 rp := filepath.Join(d.reposPath(), repo)
185
186 if _, err := os.Stat(rp); err == nil || os.IsExist(err) {
187 return nil, ErrRepoExist
188 }
189
190 copts := git.CloneOptions{
191 Bare: true,
192 Mirror: opts.Mirror,
193 Quiet: true,
194 CommandOptions: git.CommandOptions{
195 Timeout: -1,
196 Context: d.ctx,
197 Envs: []string{
198 fmt.Sprintf(`GIT_SSH_COMMAND=ssh -o UserKnownHostsFile="%s" -o StrictHostKeyChecking=no -i "%s"`,
199 filepath.Join(d.cfg.DataPath, "ssh", "known_hosts"),
200 d.cfg.SSH.ClientKeyPath,
201 ),
202 },
203 },
204 // Timeout: time.Hour,
205 }
206
207 if err := git.Clone(remote, rp, copts); err != nil {
208 d.logger.Error("failed to clone repository", "err", err, "mirror", opts.Mirror, "remote", remote, "path", rp)
209 // Cleanup the mess!
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 // Delete repo from cache
229 defer d.cache.Delete(d.ctx, cacheKey(name))
230
231 if _, err := tx.Exec("DELETE FROM repo WHERE name = ?;", name); err != nil {
232 return err
233 }
234
235 return os.RemoveAll(rp)
236 })
237}
238
239// RenameRepository renames a repository.
240//
241// It implements backend.Backend.
242func (d *SqliteBackend) RenameRepository(oldName string, newName string) error {
243 oldName = utils.SanitizeRepo(oldName)
244 if err := utils.ValidateRepo(oldName); err != nil {
245 return err
246 }
247
248 newName = utils.SanitizeRepo(newName)
249 if err := utils.ValidateRepo(newName); err != nil {
250 return err
251 }
252 oldRepo := oldName + ".git"
253 newRepo := newName + ".git"
254 op := filepath.Join(d.reposPath(), oldRepo)
255 np := filepath.Join(d.reposPath(), newRepo)
256 if _, err := os.Stat(op); err != nil {
257 return ErrRepoNotExist
258 }
259
260 if _, err := os.Stat(np); err == nil {
261 return ErrRepoExist
262 }
263
264 if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
265 // Delete cache
266 defer d.cache.Delete(d.ctx, cacheKey(oldName))
267
268 _, err := tx.Exec("UPDATE repo SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?;", newName, oldName)
269 if err != nil {
270 return err
271 }
272
273 // Make sure the new repository parent directory exists.
274 if err := os.MkdirAll(filepath.Dir(np), os.ModePerm); err != nil {
275 return err
276 }
277
278 if err := os.Rename(op, np); err != nil {
279 return err
280 }
281
282 return nil
283 }); err != nil {
284 return wrapDbErr(err)
285 }
286
287 return nil
288}
289
290// Repositories returns a list of all repositories.
291//
292// It implements backend.Backend.
293func (d *SqliteBackend) Repositories() ([]backend.Repository, error) {
294 repos := make([]backend.Repository, 0)
295
296 if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
297 rows, err := tx.Query("SELECT name FROM repo")
298 if err != nil {
299 return err
300 }
301
302 defer rows.Close() // nolint: errcheck
303 for rows.Next() {
304 var name string
305 if err := rows.Scan(&name); err != nil {
306 return err
307 }
308
309 if r, ok := d.cache.Get(d.ctx, cacheKey(name)); ok && r != nil {
310 if r, ok := r.(*Repo); ok {
311 repos = append(repos, r)
312 }
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(d.ctx, cacheKey(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(d.ctx, cacheKey(repo)); ok && r != nil {
343 if r, ok := r.(*Repo); ok {
344 return r, nil
345 }
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(d.ctx, cacheKey(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(d.ctx, cacheKey(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(d.ctx, cacheKey(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(d.ctx, cacheKey(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(d.ctx, cacheKey(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// cacheKey returns the cache key for a repository.
622func cacheKey(name string) string {
623 return fmt.Sprintf("repo:%s", name)
624}