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