sqlite.go

  1package sqlite
  2
  3import (
  4	"context"
  5	"errors"
  6	"fmt"
  7	"os"
  8	"path/filepath"
  9	"strings"
 10	"time"
 11
 12	"github.com/charmbracelet/log"
 13	"github.com/charmbracelet/soft-serve/git"
 14	"github.com/charmbracelet/soft-serve/server/backend"
 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
 32var _ backend.Backend = (*SqliteBackend)(nil)
 33
 34func (d *SqliteBackend) reposPath() string {
 35	return filepath.Join(d.dp, "repos")
 36}
 37
 38// NewSqliteBackend creates a new SqliteBackend.
 39func NewSqliteBackend(ctx context.Context) (*SqliteBackend, error) {
 40	cfg := config.FromContext(ctx)
 41	dataPath := cfg.DataPath
 42	if err := os.MkdirAll(dataPath, os.ModePerm); err != nil {
 43		return nil, err
 44	}
 45
 46	db, err := sqlx.Connect("sqlite", filepath.Join(dataPath, "soft-serve.db"+
 47		"?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)"))
 48	if err != nil {
 49		return nil, err
 50	}
 51
 52	d := &SqliteBackend{
 53		cfg:    cfg,
 54		ctx:    ctx,
 55		dp:     dataPath,
 56		db:     db,
 57		logger: log.FromContext(ctx).WithPrefix("sqlite"),
 58	}
 59
 60	if err := d.init(); err != nil {
 61		return nil, err
 62	}
 63
 64	if err := d.db.Ping(); err != nil {
 65		return nil, err
 66	}
 67
 68	return d, d.initRepos()
 69}
 70
 71// WithContext returns a copy of SqliteBackend with the given context.
 72func (d SqliteBackend) WithContext(ctx context.Context) backend.Backend {
 73	d.ctx = ctx
 74	return &d
 75}
 76
 77// AllowKeyless returns whether or not keyless access is allowed.
 78//
 79// It implements backend.Backend.
 80func (d *SqliteBackend) AllowKeyless() bool {
 81	var allow bool
 82	if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
 83		return tx.Get(&allow, "SELECT value FROM settings WHERE key = ?;", "allow_keyless")
 84	}); err != nil {
 85		return false
 86	}
 87
 88	return allow
 89}
 90
 91// AnonAccess returns the level of anonymous access.
 92//
 93// It implements backend.Backend.
 94func (d *SqliteBackend) AnonAccess() backend.AccessLevel {
 95	var level string
 96	if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
 97		return tx.Get(&level, "SELECT value FROM settings WHERE key = ?;", "anon_access")
 98	}); err != nil {
 99		return backend.NoAccess
100	}
101
102	return backend.ParseAccessLevel(level)
103}
104
105// SetAllowKeyless sets whether or not keyless access is allowed.
106//
107// It implements backend.Backend.
108func (d *SqliteBackend) SetAllowKeyless(allow bool) error {
109	return wrapDbErr(
110		wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
111			_, err := tx.Exec("UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = ?;", allow, "allow_keyless")
112			return err
113		}),
114	)
115}
116
117// SetAnonAccess sets the level of anonymous access.
118//
119// It implements backend.Backend.
120func (d *SqliteBackend) SetAnonAccess(level backend.AccessLevel) error {
121	return wrapDbErr(
122		wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
123			_, err := tx.Exec("UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = ?;", level.String(), "anon_access")
124			return err
125		}),
126	)
127}
128
129// CreateRepository creates a new repository.
130//
131// It implements backend.Backend.
132func (d *SqliteBackend) CreateRepository(name string, opts backend.RepositoryOptions) (backend.Repository, error) {
133	name = utils.SanitizeRepo(name)
134	if err := utils.ValidateRepo(name); err != nil {
135		return nil, err
136	}
137
138	repo := name + ".git"
139	rp := filepath.Join(d.reposPath(), repo)
140
141	cleanup := func() error {
142		return os.RemoveAll(rp)
143	}
144
145	rr, err := git.Init(rp, true)
146	if err != nil {
147		d.logger.Debug("failed to create repository", "err", err)
148		cleanup() // nolint: errcheck
149		return nil, err
150	}
151
152	if err := rr.UpdateServerInfo(); err != nil {
153		d.logger.Debug("failed to update server info", "err", err)
154		cleanup() // nolint: errcheck
155		return nil, err
156	}
157
158	if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
159		_, err := tx.Exec(`INSERT INTO repo (name, project_name, description, private, mirror, hidden, updated_at)
160			VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP);`,
161			name, opts.ProjectName, opts.Description, opts.Private, opts.Mirror, opts.Hidden)
162		return err
163	}); err != nil {
164		d.logger.Debug("failed to create repository in database", "err", err)
165		return nil, wrapDbErr(err)
166	}
167
168	r := &Repo{
169		name: name,
170		path: rp,
171		db:   d.db,
172	}
173
174	return r, d.initRepo(name)
175}
176
177// ImportRepository imports a repository from remote.
178func (d *SqliteBackend) ImportRepository(name string, remote string, opts backend.RepositoryOptions) (backend.Repository, error) {
179	name = utils.SanitizeRepo(name)
180	if err := utils.ValidateRepo(name); err != nil {
181		return nil, err
182	}
183
184	repo := name + ".git"
185	rp := filepath.Join(d.reposPath(), repo)
186
187	if _, err := os.Stat(rp); err == nil || os.IsExist(err) {
188		return nil, ErrRepoExist
189	}
190
191	copts := git.CloneOptions{
192		Bare:    true,
193		Mirror:  opts.Mirror,
194		Quiet:   true,
195		Timeout: 15 * time.Minute,
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	}
207
208	if err := git.Clone(remote, rp, copts); err != nil {
209		d.logger.Error("failed to clone repository", "err", err, "mirror", opts.Mirror, "remote", remote, "path", rp)
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	if _, err := os.Stat(rp); err != nil {
227		return os.ErrNotExist
228	}
229
230	if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
231		_, err := tx.Exec("DELETE FROM repo WHERE name = ?;", name)
232		return err
233	}); err != nil {
234		return wrapDbErr(err)
235	}
236
237	return os.RemoveAll(rp)
238}
239
240// RenameRepository renames a repository.
241//
242// It implements backend.Backend.
243func (d *SqliteBackend) RenameRepository(oldName string, newName string) error {
244	oldName = utils.SanitizeRepo(oldName)
245	if err := utils.ValidateRepo(oldName); err != nil {
246		return err
247	}
248
249	newName = utils.SanitizeRepo(newName)
250	if err := utils.ValidateRepo(newName); err != nil {
251		return err
252	}
253	oldRepo := oldName + ".git"
254	newRepo := newName + ".git"
255	op := filepath.Join(d.reposPath(), oldRepo)
256	np := filepath.Join(d.reposPath(), newRepo)
257	if _, err := os.Stat(op); err != nil {
258		return fmt.Errorf("repository %s does not exist", oldName)
259	}
260
261	if _, err := os.Stat(np); err == nil {
262		return fmt.Errorf("repository %s already exists", newName)
263	}
264
265	if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
266		_, err := tx.Exec("UPDATE repo SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?;", newName, oldName)
267		return err
268	}); err != nil {
269		return wrapDbErr(err)
270	}
271
272	// Make sure the new repository parent directory exists.
273	if err := os.MkdirAll(filepath.Dir(np), os.ModePerm); err != nil {
274		return err
275	}
276
277	return os.Rename(op, np)
278}
279
280// Repositories returns a list of all repositories.
281//
282// It implements backend.Backend.
283func (d *SqliteBackend) Repositories() ([]backend.Repository, error) {
284	repos := make([]backend.Repository, 0)
285	if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
286		rows, err := tx.Query("SELECT name FROM repo")
287		if err != nil {
288			return err
289		}
290
291		defer rows.Close() // nolint: errcheck
292		for rows.Next() {
293			var name string
294			if err := rows.Scan(&name); err != nil {
295				return err
296			}
297
298			repos = append(repos, &Repo{
299				name: name,
300				path: filepath.Join(d.reposPath(), name+".git"),
301				db:   d.db,
302			})
303		}
304
305		return nil
306	}); err != nil {
307		return nil, wrapDbErr(err)
308	}
309
310	return repos, nil
311}
312
313// Repository returns a repository by name.
314//
315// It implements backend.Backend.
316func (d *SqliteBackend) Repository(repo string) (backend.Repository, error) {
317	repo = utils.SanitizeRepo(repo)
318	rp := filepath.Join(d.reposPath(), repo+".git")
319	if _, err := os.Stat(rp); err != nil {
320		return nil, os.ErrNotExist
321	}
322
323	var count int
324	if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
325		return tx.Get(&count, "SELECT COUNT(*) FROM repo WHERE name = ?", repo)
326	}); err != nil {
327		return nil, wrapDbErr(err)
328	}
329
330	if count == 0 {
331		d.logger.Warn("repository exists but not found in database", "repo", repo)
332		return nil, ErrRepoNotExist
333	}
334
335	return &Repo{
336		name: repo,
337		path: rp,
338		db:   d.db,
339	}, nil
340}
341
342// Description returns the description of a repository.
343//
344// It implements backend.Backend.
345func (d *SqliteBackend) Description(repo string) (string, error) {
346	repo = utils.SanitizeRepo(repo)
347	var desc string
348	if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
349		row := tx.QueryRow("SELECT description FROM repo WHERE name = ?", repo)
350		return row.Scan(&desc)
351	}); err != nil {
352		return "", wrapDbErr(err)
353	}
354
355	return desc, nil
356}
357
358// IsMirror returns true if the repository is a mirror.
359//
360// It implements backend.Backend.
361func (d *SqliteBackend) IsMirror(repo string) (bool, error) {
362	repo = utils.SanitizeRepo(repo)
363	var mirror bool
364	if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
365		return tx.Get(&mirror, "SELECT mirror FROM repo WHERE name = ?", repo)
366	}); err != nil {
367		return false, wrapDbErr(err)
368	}
369
370	return mirror, nil
371}
372
373// IsPrivate returns true if the repository is private.
374//
375// It implements backend.Backend.
376func (d *SqliteBackend) IsPrivate(repo string) (bool, error) {
377	repo = utils.SanitizeRepo(repo)
378	var private bool
379	if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
380		row := tx.QueryRow("SELECT private FROM repo WHERE name = ?", repo)
381		return row.Scan(&private)
382	}); err != nil {
383		return false, wrapDbErr(err)
384	}
385
386	return private, nil
387}
388
389// IsHidden returns true if the repository is hidden.
390//
391// It implements backend.Backend.
392func (d *SqliteBackend) IsHidden(repo string) (bool, error) {
393	repo = utils.SanitizeRepo(repo)
394	var hidden bool
395	if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
396		row := tx.QueryRow("SELECT hidden FROM repo WHERE name = ?", repo)
397		return row.Scan(&hidden)
398	}); err != nil {
399		return false, wrapDbErr(err)
400	}
401
402	return hidden, nil
403}
404
405// SetHidden sets the hidden flag of a repository.
406//
407// It implements backend.Backend.
408func (d *SqliteBackend) SetHidden(repo string, hidden bool) error {
409	repo = utils.SanitizeRepo(repo)
410	return wrapDbErr(wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
411		var count int
412		if err := tx.Get(&count, "SELECT COUNT(*) FROM repo WHERE name = ?", repo); err != nil {
413			return err
414		}
415		if count == 0 {
416			return ErrRepoNotExist
417		}
418		_, err := tx.Exec("UPDATE repo SET hidden = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?;", hidden, repo)
419		return err
420	}))
421}
422
423// ProjectName returns the project name of a repository.
424//
425// It implements backend.Backend.
426func (d *SqliteBackend) ProjectName(repo string) (string, error) {
427	repo = utils.SanitizeRepo(repo)
428	var name string
429	if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
430		row := tx.QueryRow("SELECT project_name FROM repo WHERE name = ?", repo)
431		return row.Scan(&name)
432	}); err != nil {
433		return "", wrapDbErr(err)
434	}
435
436	return name, nil
437}
438
439// SetDescription sets the description of a repository.
440//
441// It implements backend.Backend.
442func (d *SqliteBackend) SetDescription(repo string, desc string) error {
443	repo = utils.SanitizeRepo(repo)
444	return wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
445		var count int
446		if err := tx.Get(&count, "SELECT COUNT(*) FROM repo WHERE name = ?", repo); err != nil {
447			return err
448		}
449		if count == 0 {
450			return ErrRepoNotExist
451		}
452		_, err := tx.Exec("UPDATE repo SET description = ? WHERE name = ?", desc, repo)
453		return err
454	})
455}
456
457// SetPrivate sets the private flag of a repository.
458//
459// It implements backend.Backend.
460func (d *SqliteBackend) SetPrivate(repo string, private bool) error {
461	repo = utils.SanitizeRepo(repo)
462	return wrapDbErr(
463		wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
464			var count int
465			if err := tx.Get(&count, "SELECT COUNT(*) FROM repo WHERE name = ?", repo); err != nil {
466				return err
467			}
468			if count == 0 {
469				return ErrRepoNotExist
470			}
471			_, err := tx.Exec("UPDATE repo SET private = ? WHERE name = ?", private, repo)
472			return err
473		}),
474	)
475}
476
477// SetProjectName sets the project name of a repository.
478//
479// It implements backend.Backend.
480func (d *SqliteBackend) SetProjectName(repo string, name string) error {
481	repo = utils.SanitizeRepo(repo)
482	return wrapDbErr(
483		wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
484			var count int
485			if err := tx.Get(&count, "SELECT COUNT(*) FROM repo WHERE name = ?", repo); err != nil {
486				return err
487			}
488			if count == 0 {
489				return ErrRepoNotExist
490			}
491			_, err := tx.Exec("UPDATE repo SET project_name = ? WHERE name = ?", name, repo)
492			return err
493		}),
494	)
495}
496
497// AddCollaborator adds a collaborator to a repository.
498//
499// It implements backend.Backend.
500func (d *SqliteBackend) AddCollaborator(repo string, username string) error {
501	username = strings.ToLower(username)
502	if err := utils.ValidateUsername(username); err != nil {
503		return err
504	}
505
506	repo = utils.SanitizeRepo(repo)
507	return wrapDbErr(wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
508		_, err := tx.Exec(`INSERT INTO collab (user_id, repo_id, updated_at)
509			VALUES (
510			(SELECT id FROM user WHERE username = ?),
511			(SELECT id FROM repo WHERE name = ?),
512			CURRENT_TIMESTAMP
513			);`, username, repo)
514		return err
515	}),
516	)
517}
518
519// Collaborators returns a list of collaborators for a repository.
520//
521// It implements backend.Backend.
522func (d *SqliteBackend) Collaborators(repo string) ([]string, error) {
523	repo = utils.SanitizeRepo(repo)
524	var users []string
525	if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
526		return tx.Select(&users, `SELECT user.username FROM user
527			INNER JOIN collab ON user.id = collab.user_id
528			INNER JOIN repo ON repo.id = collab.repo_id
529			WHERE repo.name = ?`, repo)
530	}); err != nil {
531		return nil, wrapDbErr(err)
532	}
533
534	return users, nil
535}
536
537// IsCollaborator returns true if the user is a collaborator of the repository.
538//
539// It implements backend.Backend.
540func (d *SqliteBackend) IsCollaborator(repo string, username string) (bool, error) {
541	repo = utils.SanitizeRepo(repo)
542	var count int
543	if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
544		return tx.Get(&count, `SELECT COUNT(*) FROM user
545			INNER JOIN collab ON user.id = collab.user_id
546			INNER JOIN repo ON repo.id = collab.repo_id
547			WHERE repo.name = ? AND user.username = ?`, repo, username)
548	}); err != nil {
549		return false, wrapDbErr(err)
550	}
551
552	return count > 0, nil
553}
554
555// RemoveCollaborator removes a collaborator from a repository.
556//
557// It implements backend.Backend.
558func (d *SqliteBackend) RemoveCollaborator(repo string, username string) error {
559	repo = utils.SanitizeRepo(repo)
560	return wrapDbErr(
561		wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
562			_, err := tx.Exec(`DELETE FROM collab
563			WHERE user_id = (SELECT id FROM user WHERE username = ?)
564			AND repo_id = (SELECT id FROM repo WHERE name = ?)`, username, repo)
565			return err
566		}),
567	)
568}
569
570func (d *SqliteBackend) initRepo(repo string) error {
571	return hooks.GenerateHooks(d.ctx, d.cfg, repo)
572}
573
574func (d *SqliteBackend) initRepos() error {
575	repos, err := d.Repositories()
576	if err != nil {
577		return err
578	}
579
580	for _, repo := range repos {
581		if err := d.initRepo(repo.Name()); err != nil {
582			return err
583		}
584	}
585
586	return nil
587}