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}