sqlite.go

  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}