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