sqlite.go

  1package sqlite
  2
  3import (
  4	"context"
  5	"fmt"
  6	"os"
  7	"path/filepath"
  8	"strings"
  9	"time"
 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// 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		d.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		d.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		d.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		Timeout: 15 * time.Minute,
185		CommandOptions: git.CommandOptions{
186			Envs: []string{
187				fmt.Sprintf(`GIT_SSH_COMMAND=ssh -o UserKnownHostsFile="%s" -o StrictHostKeyChecking=no -i "%s"`,
188					filepath.Join(d.cfg.DataPath, "ssh", "known_hosts"),
189					d.cfg.SSH.ClientKeyPath,
190				),
191			},
192		},
193	}
194
195	if err := git.Clone(remote, rp, copts); err != nil {
196		d.logger.Error("failed to clone repository", "err", err, "mirror", opts.Mirror, "remote", remote, "path", rp)
197		return nil, err
198	}
199
200	return d.CreateRepository(name, opts)
201}
202
203// DeleteRepository deletes a repository.
204//
205// It implements backend.Backend.
206func (d *SqliteBackend) DeleteRepository(name string) error {
207	name = utils.SanitizeRepo(name)
208	repo := name + ".git"
209	rp := filepath.Join(d.reposPath(), repo)
210	if _, err := os.Stat(rp); err != nil {
211		return os.ErrNotExist
212	}
213
214	if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
215		_, err := tx.Exec("DELETE FROM repo WHERE name = ?;", name)
216		return err
217	}); err != nil {
218		return wrapDbErr(err)
219	}
220
221	return os.RemoveAll(rp)
222}
223
224// RenameRepository renames a repository.
225//
226// It implements backend.Backend.
227func (d *SqliteBackend) RenameRepository(oldName string, newName string) error {
228	oldName = utils.SanitizeRepo(oldName)
229	if err := utils.ValidateRepo(oldName); err != nil {
230		return err
231	}
232
233	newName = utils.SanitizeRepo(newName)
234	if err := utils.ValidateRepo(newName); err != nil {
235		return err
236	}
237	oldRepo := oldName + ".git"
238	newRepo := newName + ".git"
239	op := filepath.Join(d.reposPath(), oldRepo)
240	np := filepath.Join(d.reposPath(), newRepo)
241	if _, err := os.Stat(op); err != nil {
242		return fmt.Errorf("repository %s does not exist", oldName)
243	}
244
245	if _, err := os.Stat(np); err == nil {
246		return fmt.Errorf("repository %s already exists", newName)
247	}
248
249	if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
250		_, err := tx.Exec("UPDATE repo SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?;", newName, oldName)
251		return err
252	}); err != nil {
253		return wrapDbErr(err)
254	}
255
256	// Make sure the new repository parent directory exists.
257	if err := os.MkdirAll(filepath.Dir(np), os.ModePerm); err != nil {
258		return err
259	}
260
261	return os.Rename(op, np)
262}
263
264// Repositories returns a list of all repositories.
265//
266// It implements backend.Backend.
267func (d *SqliteBackend) Repositories() ([]backend.Repository, error) {
268	repos := make([]backend.Repository, 0)
269	if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
270		rows, err := tx.Query("SELECT name FROM repo")
271		if err != nil {
272			return err
273		}
274
275		defer rows.Close() // nolint: errcheck
276		for rows.Next() {
277			var name string
278			if err := rows.Scan(&name); err != nil {
279				return err
280			}
281
282			repos = append(repos, &Repo{
283				name: name,
284				path: filepath.Join(d.reposPath(), name+".git"),
285				db:   d.db,
286			})
287		}
288
289		return nil
290	}); err != nil {
291		return nil, wrapDbErr(err)
292	}
293
294	return repos, nil
295}
296
297// Repository returns a repository by name.
298//
299// It implements backend.Backend.
300func (d *SqliteBackend) Repository(repo string) (backend.Repository, error) {
301	repo = utils.SanitizeRepo(repo)
302	rp := filepath.Join(d.reposPath(), repo+".git")
303	if _, err := os.Stat(rp); err != nil {
304		return nil, os.ErrNotExist
305	}
306
307	var count int
308	if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
309		return tx.Get(&count, "SELECT COUNT(*) FROM repo WHERE name = ?", repo)
310	}); err != nil {
311		return nil, wrapDbErr(err)
312	}
313
314	if count == 0 {
315		d.logger.Warn("repository exists but not found in database", "repo", repo)
316		return nil, ErrRepoNotExist
317	}
318
319	return &Repo{
320		name: repo,
321		path: rp,
322		db:   d.db,
323	}, nil
324}
325
326// Description returns the description of a repository.
327//
328// It implements backend.Backend.
329func (d *SqliteBackend) Description(repo string) (string, error) {
330	repo = utils.SanitizeRepo(repo)
331	var desc string
332	if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
333		row := tx.QueryRow("SELECT description FROM repo WHERE name = ?", repo)
334		return row.Scan(&desc)
335	}); err != nil {
336		return "", wrapDbErr(err)
337	}
338
339	return desc, nil
340}
341
342// IsMirror returns true if the repository is a mirror.
343//
344// It implements backend.Backend.
345func (d *SqliteBackend) IsMirror(repo string) (bool, error) {
346	repo = utils.SanitizeRepo(repo)
347	var mirror bool
348	if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
349		return tx.Get(&mirror, "SELECT mirror FROM repo WHERE name = ?", repo)
350	}); err != nil {
351		return false, wrapDbErr(err)
352	}
353
354	return mirror, nil
355}
356
357// IsPrivate returns true if the repository is private.
358//
359// It implements backend.Backend.
360func (d *SqliteBackend) IsPrivate(repo string) (bool, error) {
361	repo = utils.SanitizeRepo(repo)
362	var private bool
363	if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
364		row := tx.QueryRow("SELECT private FROM repo WHERE name = ?", repo)
365		return row.Scan(&private)
366	}); err != nil {
367		return false, wrapDbErr(err)
368	}
369
370	return private, nil
371}
372
373// IsHidden returns true if the repository is hidden.
374//
375// It implements backend.Backend.
376func (d *SqliteBackend) IsHidden(repo string) (bool, error) {
377	repo = utils.SanitizeRepo(repo)
378	var hidden bool
379	if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
380		row := tx.QueryRow("SELECT hidden FROM repo WHERE name = ?", repo)
381		return row.Scan(&hidden)
382	}); err != nil {
383		return false, wrapDbErr(err)
384	}
385
386	return hidden, nil
387}
388
389// SetHidden sets the hidden flag of a repository.
390//
391// It implements backend.Backend.
392func (d *SqliteBackend) SetHidden(repo string, hidden bool) error {
393	repo = utils.SanitizeRepo(repo)
394	return wrapDbErr(wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
395		var count int
396		if err := tx.Get(&count, "SELECT COUNT(*) FROM repo WHERE name = ?", repo); err != nil {
397			return err
398		}
399		if count == 0 {
400			return ErrRepoNotExist
401		}
402		_, err := tx.Exec("UPDATE repo SET hidden = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?;", hidden, repo)
403		return err
404	}))
405}
406
407// ProjectName returns the project name of a repository.
408//
409// It implements backend.Backend.
410func (d *SqliteBackend) ProjectName(repo string) (string, error) {
411	repo = utils.SanitizeRepo(repo)
412	var name string
413	if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
414		row := tx.QueryRow("SELECT project_name FROM repo WHERE name = ?", repo)
415		return row.Scan(&name)
416	}); err != nil {
417		return "", wrapDbErr(err)
418	}
419
420	return name, nil
421}
422
423// SetDescription sets the description of a repository.
424//
425// It implements backend.Backend.
426func (d *SqliteBackend) SetDescription(repo string, desc string) error {
427	repo = utils.SanitizeRepo(repo)
428	return wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
429		var count int
430		if err := tx.Get(&count, "SELECT COUNT(*) FROM repo WHERE name = ?", repo); err != nil {
431			return err
432		}
433		if count == 0 {
434			return ErrRepoNotExist
435		}
436		_, err := tx.Exec("UPDATE repo SET description = ? WHERE name = ?", desc, repo)
437		return err
438	})
439}
440
441// SetPrivate sets the private flag of a repository.
442//
443// It implements backend.Backend.
444func (d *SqliteBackend) SetPrivate(repo string, private bool) error {
445	repo = utils.SanitizeRepo(repo)
446	return wrapDbErr(
447		wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
448			var count int
449			if err := tx.Get(&count, "SELECT COUNT(*) FROM repo WHERE name = ?", repo); err != nil {
450				return err
451			}
452			if count == 0 {
453				return ErrRepoNotExist
454			}
455			_, err := tx.Exec("UPDATE repo SET private = ? WHERE name = ?", private, repo)
456			return err
457		}),
458	)
459}
460
461// SetProjectName sets the project name of a repository.
462//
463// It implements backend.Backend.
464func (d *SqliteBackend) SetProjectName(repo string, name string) error {
465	repo = utils.SanitizeRepo(repo)
466	return wrapDbErr(
467		wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
468			var count int
469			if err := tx.Get(&count, "SELECT COUNT(*) FROM repo WHERE name = ?", repo); err != nil {
470				return err
471			}
472			if count == 0 {
473				return ErrRepoNotExist
474			}
475			_, err := tx.Exec("UPDATE repo SET project_name = ? WHERE name = ?", name, repo)
476			return err
477		}),
478	)
479}
480
481// AddCollaborator adds a collaborator to a repository.
482//
483// It implements backend.Backend.
484func (d *SqliteBackend) AddCollaborator(repo string, username string) error {
485	username = strings.ToLower(username)
486	if err := utils.ValidateUsername(username); err != nil {
487		return err
488	}
489
490	repo = utils.SanitizeRepo(repo)
491	return wrapDbErr(wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
492		_, err := tx.Exec(`INSERT INTO collab (user_id, repo_id, updated_at)
493			VALUES (
494			(SELECT id FROM user WHERE username = ?),
495			(SELECT id FROM repo WHERE name = ?),
496			CURRENT_TIMESTAMP
497			);`, username, repo)
498		return err
499	}),
500	)
501}
502
503// Collaborators returns a list of collaborators for a repository.
504//
505// It implements backend.Backend.
506func (d *SqliteBackend) Collaborators(repo string) ([]string, error) {
507	repo = utils.SanitizeRepo(repo)
508	var users []string
509	if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
510		return tx.Select(&users, `SELECT user.username FROM user
511			INNER JOIN collab ON user.id = collab.user_id
512			INNER JOIN repo ON repo.id = collab.repo_id
513			WHERE repo.name = ?`, repo)
514	}); err != nil {
515		return nil, wrapDbErr(err)
516	}
517
518	return users, nil
519}
520
521// IsCollaborator returns true if the user is a collaborator of the repository.
522//
523// It implements backend.Backend.
524func (d *SqliteBackend) IsCollaborator(repo string, username string) (bool, error) {
525	repo = utils.SanitizeRepo(repo)
526	var count int
527	if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
528		return tx.Get(&count, `SELECT COUNT(*) FROM user
529			INNER JOIN collab ON user.id = collab.user_id
530			INNER JOIN repo ON repo.id = collab.repo_id
531			WHERE repo.name = ? AND user.username = ?`, repo, username)
532	}); err != nil {
533		return false, wrapDbErr(err)
534	}
535
536	return count > 0, nil
537}
538
539// RemoveCollaborator removes a collaborator from a repository.
540//
541// It implements backend.Backend.
542func (d *SqliteBackend) RemoveCollaborator(repo string, username string) error {
543	repo = utils.SanitizeRepo(repo)
544	return wrapDbErr(
545		wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
546			_, err := tx.Exec(`DELETE FROM collab
547			WHERE user_id = (SELECT id FROM user WHERE username = ?)
548			AND repo_id = (SELECT id FROM repo WHERE name = ?)`, username, repo)
549			return err
550		}),
551	)
552}
553
554func (d *SqliteBackend) initRepo(repo string) error {
555	return hooks.GenerateHooks(d.ctx, d.cfg, repo)
556}
557
558func (d *SqliteBackend) initRepos() error {
559	repos, err := d.Repositories()
560	if err != nil {
561		return err
562	}
563
564	for _, repo := range repos {
565		if err := d.initRepo(repo.Name()); err != nil {
566			return err
567		}
568	}
569
570	return nil
571}