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	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}