sqlite.go

  1package sqlite
  2
  3import (
  4	"bytes"
  5	"context"
  6	"fmt"
  7	"os"
  8	"path/filepath"
  9	"strings"
 10	"text/template"
 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/utils"
 17	"github.com/jmoiron/sqlx"
 18	_ "modernc.org/sqlite"
 19)
 20
 21var (
 22	logger = log.WithPrefix("backend.sqlite")
 23)
 24
 25// SqliteBackend is a backend that uses a SQLite database as a Soft Serve
 26// backend.
 27type SqliteBackend struct {
 28	cfg *config.Config
 29	ctx context.Context
 30	dp  string
 31	db  *sqlx.DB
 32}
 33
 34var _ backend.Backend = (*SqliteBackend)(nil)
 35
 36func (d *SqliteBackend) reposPath() string {
 37	return filepath.Join(d.dp, "repos")
 38}
 39
 40// NewSqliteBackend creates a new SqliteBackend.
 41func NewSqliteBackend(ctx context.Context, cfg *config.Config) (*SqliteBackend, error) {
 42	dataPath := cfg.DataPath
 43	if err := os.MkdirAll(dataPath, 0755); err != nil {
 44		return nil, err
 45	}
 46
 47	db, err := sqlx.Connect("sqlite", filepath.Join(dataPath, "soft-serve.db"+
 48		"?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)"))
 49	if err != nil {
 50		return nil, err
 51	}
 52
 53	d := &SqliteBackend{
 54		cfg: cfg,
 55		ctx: ctx,
 56		dp:  dataPath,
 57		db:  db,
 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// AllowKeyless returns whether or not keyless access is allowed.
 72//
 73// It implements backend.Backend.
 74func (d *SqliteBackend) AllowKeyless() bool {
 75	var allow bool
 76	if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
 77		return tx.Get(&allow, "SELECT value FROM settings WHERE key = ?;", "allow_keyless")
 78	}); err != nil {
 79		return false
 80	}
 81
 82	return allow
 83}
 84
 85// AnonAccess returns the level of anonymous access.
 86//
 87// It implements backend.Backend.
 88func (d *SqliteBackend) AnonAccess() backend.AccessLevel {
 89	var level string
 90	if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
 91		return tx.Get(&level, "SELECT value FROM settings WHERE key = ?;", "anon_access")
 92	}); err != nil {
 93		return backend.NoAccess
 94	}
 95
 96	return backend.ParseAccessLevel(level)
 97}
 98
 99// SetAllowKeyless sets whether or not keyless access is allowed.
100//
101// It implements backend.Backend.
102func (d *SqliteBackend) SetAllowKeyless(allow bool) error {
103	return wrapDbErr(
104		wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
105			_, err := tx.Exec("UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = ?;", allow, "allow_keyless")
106			return err
107		}),
108	)
109}
110
111// SetAnonAccess sets the level of anonymous access.
112//
113// It implements backend.Backend.
114func (d *SqliteBackend) SetAnonAccess(level backend.AccessLevel) error {
115	return wrapDbErr(
116		wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
117			_, err := tx.Exec("UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = ?;", level.String(), "anon_access")
118			return err
119		}),
120	)
121}
122
123// CreateRepository creates a new repository.
124//
125// It implements backend.Backend.
126func (d *SqliteBackend) CreateRepository(name string, opts backend.RepositoryOptions) (backend.Repository, error) {
127	name = utils.SanitizeRepo(name)
128	if err := utils.ValidateRepo(name); err != nil {
129		return nil, err
130	}
131
132	repo := name + ".git"
133	rp := filepath.Join(d.reposPath(), repo)
134
135	cleanup := func() error {
136		return os.RemoveAll(rp)
137	}
138
139	rr, err := git.Init(rp, true)
140	if err != nil {
141		logger.Debug("failed to create repository", "err", err)
142		cleanup() // nolint: errcheck
143		return nil, err
144	}
145
146	if err := rr.UpdateServerInfo(); err != nil {
147		logger.Debug("failed to update server info", "err", err)
148		cleanup() // nolint: errcheck
149		return nil, err
150	}
151
152	if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
153		_, err := tx.Exec(`INSERT INTO repo (name, project_name, description, private, mirror, hidden, updated_at)
154			VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP);`,
155			name, opts.ProjectName, opts.Description, opts.Private, opts.Mirror, opts.Hidden)
156		return err
157	}); err != nil {
158		logger.Debug("failed to create repository in database", "err", err)
159		return nil, wrapDbErr(err)
160	}
161
162	r := &Repo{
163		name: name,
164		path: rp,
165		db:   d.db,
166	}
167
168	return r, d.InitializeHooks(name)
169}
170
171// ImportRepository imports a repository from remote.
172func (d *SqliteBackend) ImportRepository(name string, remote string, opts backend.RepositoryOptions) (backend.Repository, error) {
173	name = utils.SanitizeRepo(name)
174	if err := utils.ValidateRepo(name); err != nil {
175		return nil, err
176	}
177
178	repo := name + ".git"
179	rp := filepath.Join(d.reposPath(), repo)
180
181	copts := git.CloneOptions{
182		Bare:   true,
183		Mirror: opts.Mirror,
184		Quiet:  true,
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.Internal.ClientKeyPath,
190				),
191			},
192		},
193	}
194
195	if err := git.Clone(remote, rp, copts); err != nil {
196		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), 0755); 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		logger.Warn("repository exists but not found in database", "repo", repo)
316		return nil, fmt.Errorf("repository does not exist")
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		return tx.Get(&desc, "SELECT description FROM repo WHERE name = ?", repo)
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		return tx.Get(&private, "SELECT private FROM repo WHERE name = ?", repo)
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		return tx.Get(&hidden, "SELECT hidden FROM repo WHERE name = ?", repo)
379	}); err != nil {
380		return false, wrapDbErr(err)
381	}
382
383	return hidden, nil
384}
385
386// SetHidden sets the hidden flag of a repository.
387//
388// It implements backend.Backend.
389func (d *SqliteBackend) SetHidden(repo string, hidden bool) error {
390	repo = utils.SanitizeRepo(repo)
391	return wrapDbErr(wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
392		_, err := tx.Exec("UPDATE repo SET hidden = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?;", hidden, repo)
393		return err
394	}))
395}
396
397// ProjectName returns the project name of a repository.
398//
399// It implements backend.Backend.
400func (d *SqliteBackend) ProjectName(repo string) (string, error) {
401	repo = utils.SanitizeRepo(repo)
402	var name string
403	if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
404		return tx.Get(&name, "SELECT project_name FROM repo WHERE name = ?", repo)
405	}); err != nil {
406		return "", wrapDbErr(err)
407	}
408
409	return name, nil
410}
411
412// SetDescription sets the description of a repository.
413//
414// It implements backend.Backend.
415func (d *SqliteBackend) SetDescription(repo string, desc string) error {
416	repo = utils.SanitizeRepo(repo)
417	return wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
418		_, err := tx.Exec("UPDATE repo SET description = ? WHERE name = ?", desc, repo)
419		return err
420	})
421}
422
423// SetPrivate sets the private flag of a repository.
424//
425// It implements backend.Backend.
426func (d *SqliteBackend) SetPrivate(repo string, private bool) error {
427	repo = utils.SanitizeRepo(repo)
428	return wrapDbErr(
429		wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
430			_, err := tx.Exec("UPDATE repo SET private = ? WHERE name = ?", private, repo)
431			return err
432		}),
433	)
434}
435
436// SetProjectName sets the project name of a repository.
437//
438// It implements backend.Backend.
439func (d *SqliteBackend) SetProjectName(repo string, name string) error {
440	repo = utils.SanitizeRepo(repo)
441	return wrapDbErr(
442		wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
443			_, err := tx.Exec("UPDATE repo SET project_name = ? WHERE name = ?", name, repo)
444			return err
445		}),
446	)
447}
448
449// AddCollaborator adds a collaborator to a repository.
450//
451// It implements backend.Backend.
452func (d *SqliteBackend) AddCollaborator(repo string, username string) error {
453	username = strings.ToLower(username)
454	if err := utils.ValidateUsername(username); err != nil {
455		return err
456	}
457
458	repo = utils.SanitizeRepo(repo)
459	return wrapDbErr(wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
460		_, err := tx.Exec(`INSERT INTO collab (user_id, repo_id, updated_at)
461			VALUES (
462			(SELECT id FROM user WHERE username = ?),
463			(SELECT id FROM repo WHERE name = ?),
464			CURRENT_TIMESTAMP
465			);`, username, repo)
466		return err
467	}),
468	)
469}
470
471// Collaborators returns a list of collaborators for a repository.
472//
473// It implements backend.Backend.
474func (d *SqliteBackend) Collaborators(repo string) ([]string, error) {
475	repo = utils.SanitizeRepo(repo)
476	var users []string
477	if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
478		return tx.Select(&users, `SELECT user.username FROM user
479			INNER JOIN collab ON user.id = collab.user_id
480			INNER JOIN repo ON repo.id = collab.repo_id
481			WHERE repo.name = ?`, repo)
482	}); err != nil {
483		return nil, wrapDbErr(err)
484	}
485
486	return users, nil
487}
488
489// IsCollaborator returns true if the user is a collaborator of the repository.
490//
491// It implements backend.Backend.
492func (d *SqliteBackend) IsCollaborator(repo string, username string) (bool, error) {
493	repo = utils.SanitizeRepo(repo)
494	var count int
495	if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
496		return tx.Get(&count, `SELECT COUNT(*) FROM user
497			INNER JOIN collab ON user.id = collab.user_id
498			INNER JOIN repo ON repo.id = collab.repo_id
499			WHERE repo.name = ? AND user.username = ?`, repo, username)
500	}); err != nil {
501		return false, wrapDbErr(err)
502	}
503
504	return count > 0, nil
505}
506
507// RemoveCollaborator removes a collaborator from a repository.
508//
509// It implements backend.Backend.
510func (d *SqliteBackend) RemoveCollaborator(repo string, username string) error {
511	repo = utils.SanitizeRepo(repo)
512	return wrapDbErr(
513		wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
514			_, err := tx.Exec(`DELETE FROM collab
515			WHERE user_id = (SELECT id FROM user WHERE username = ?)
516			AND repo_id = (SELECT id FROM repo WHERE name = ?)`, username, repo)
517			return err
518		}),
519	)
520}
521
522var (
523	hookNames = []string{"pre-receive", "update", "post-update", "post-receive"}
524	hookTpls  = []string{
525		// for pre-receive
526		`#!/usr/bin/env bash
527# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
528data=$(cat)
529exitcodes=""
530hookname=$(basename $0)
531GIT_DIR=${GIT_DIR:-$(dirname $0)/..}
532for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
533  test -x "${hook}" && test -f "${hook}" || continue
534  echo "${data}" | "${hook}"
535  exitcodes="${exitcodes} $?"
536done
537for i in ${exitcodes}; do
538  [ ${i} -eq 0 ] || exit ${i}
539done
540`,
541
542		// for update
543		`#!/usr/bin/env bash
544# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
545exitcodes=""
546hookname=$(basename $0)
547GIT_DIR=${GIT_DIR:-$(dirname $0/..)}
548for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
549  test -x "${hook}" && test -f "${hook}" || continue
550  "${hook}" $1 $2 $3
551  exitcodes="${exitcodes} $?"
552done
553for i in ${exitcodes}; do
554  [ ${i} -eq 0 ] || exit ${i}
555done
556`,
557
558		// for post-update
559		`#!/usr/bin/env bash
560# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
561data=$(cat)
562exitcodes=""
563hookname=$(basename $0)
564GIT_DIR=${GIT_DIR:-$(dirname $0)/..}
565for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
566  test -x "${hook}" && test -f "${hook}" || continue
567  "${hook}" $@
568  exitcodes="${exitcodes} $?"
569done
570for i in ${exitcodes}; do
571  [ ${i} -eq 0 ] || exit ${i}
572done
573`,
574
575		// for post-receive
576		`#!/usr/bin/env bash
577# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
578data=$(cat)
579exitcodes=""
580hookname=$(basename $0)
581GIT_DIR=${GIT_DIR:-$(dirname $0)/..}
582for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
583  test -x "${hook}" && test -f "${hook}" || continue
584  echo "${data}" | "${hook}"
585  exitcodes="${exitcodes} $?"
586done
587for i in ${exitcodes}; do
588  [ ${i} -eq 0 ] || exit ${i}
589done
590`,
591	}
592)
593
594// InitializeHooks updates the hooks for the given repository.
595//
596// It implements backend.Backend.
597func (d *SqliteBackend) InitializeHooks(repo string) error {
598	hookTmpl, err := template.New("hook").Parse(`#!/usr/bin/env bash
599# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
600{{ range $_, $env := .Envs }}
601{{ $env }} \{{ end }}
602{{ .Executable }} hook --config "{{ .Config }}" {{ .Hook }} {{ .Args }}
603`)
604	if err != nil {
605		return err
606	}
607
608	repo = utils.SanitizeRepo(repo) + ".git"
609	hooksPath := filepath.Join(d.reposPath(), repo, "hooks")
610	if err := os.MkdirAll(hooksPath, 0755); err != nil {
611		return err
612	}
613
614	ex, err := os.Executable()
615	if err != nil {
616		return err
617	}
618
619	dp, err := filepath.Abs(d.dp)
620	if err != nil {
621		return fmt.Errorf("failed to get absolute path for data path: %w", err)
622	}
623
624	cp := filepath.Join(dp, "config.yaml")
625	envs := []string{}
626	for i, hook := range hookNames {
627		var data bytes.Buffer
628		var args string
629		hp := filepath.Join(hooksPath, hook)
630		if err := os.WriteFile(hp, []byte(hookTpls[i]), 0755); err != nil {
631			return err
632		}
633
634		// Create hook.d directory.
635		hp += ".d"
636		if err := os.MkdirAll(hp, 0755); err != nil {
637			return err
638		}
639
640		if hook == "update" {
641			args = "$1 $2 $3"
642		} else if hook == "post-update" {
643			args = "$@"
644		}
645
646		err = hookTmpl.Execute(&data, struct {
647			Executable string
648			Hook       string
649			Args       string
650			Envs       []string
651			Config     string
652		}{
653			Executable: ex,
654			Hook:       hook,
655			Args:       args,
656			Envs:       envs,
657			Config:     cp,
658		})
659		if err != nil {
660			logger.Error("failed to execute hook template", "err", err)
661			continue
662		}
663
664		hp = filepath.Join(hp, "soft-serve")
665		err = os.WriteFile(hp, data.Bytes(), 0755) //nolint:gosec
666		if err != nil {
667			logger.Error("failed to write hook", "err", err)
668			continue
669		}
670	}
671
672	return nil
673}
674
675func (d *SqliteBackend) initRepos() error {
676	repos, err := d.Repositories()
677	if err != nil {
678		return err
679	}
680
681	for _, repo := range repos {
682		if err := d.InitializeHooks(repo.Name()); err != nil {
683			return err
684		}
685	}
686
687	return nil
688}