sqlite.go

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