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