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					// FIXME: upstream keygen appends _ed25519 to the key path.
190					filepath.Join(d.cfg.DataPath, d.cfg.SSH.ClientKeyPath)+"_ed25519",
191				),
192			},
193		},
194	}
195
196	if err := git.Clone(remote, rp, copts); err != nil {
197		logger.Error("failed to clone repository", "err", err, "mirror", opts.Mirror, "remote", remote, "path", rp)
198		return nil, err
199	}
200
201	return d.CreateRepository(name, opts)
202}
203
204// DeleteRepository deletes a repository.
205//
206// It implements backend.Backend.
207func (d *SqliteBackend) DeleteRepository(name string) error {
208	name = utils.SanitizeRepo(name)
209	repo := name + ".git"
210	rp := filepath.Join(d.reposPath(), repo)
211	if _, err := os.Stat(rp); err != nil {
212		return os.ErrNotExist
213	}
214
215	if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
216		_, err := tx.Exec("DELETE FROM repo WHERE name = ?;", name)
217		return err
218	}); err != nil {
219		return wrapDbErr(err)
220	}
221
222	return os.RemoveAll(rp)
223}
224
225// RenameRepository renames a repository.
226//
227// It implements backend.Backend.
228func (d *SqliteBackend) RenameRepository(oldName string, newName string) error {
229	oldName = utils.SanitizeRepo(oldName)
230	if err := utils.ValidateRepo(oldName); err != nil {
231		return err
232	}
233
234	newName = utils.SanitizeRepo(newName)
235	if err := utils.ValidateRepo(newName); err != nil {
236		return err
237	}
238	oldRepo := oldName + ".git"
239	newRepo := newName + ".git"
240	op := filepath.Join(d.reposPath(), oldRepo)
241	np := filepath.Join(d.reposPath(), newRepo)
242	if _, err := os.Stat(op); err != nil {
243		return fmt.Errorf("repository %s does not exist", oldName)
244	}
245
246	if _, err := os.Stat(np); err == nil {
247		return fmt.Errorf("repository %s already exists", newName)
248	}
249
250	if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
251		_, err := tx.Exec("UPDATE repo SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?;", newName, oldName)
252		return err
253	}); err != nil {
254		return wrapDbErr(err)
255	}
256
257	return os.Rename(op, np)
258}
259
260// Repositories returns a list of all repositories.
261//
262// It implements backend.Backend.
263func (d *SqliteBackend) Repositories() ([]backend.Repository, error) {
264	repos := make([]backend.Repository, 0)
265	if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
266		rows, err := tx.Query("SELECT name FROM repo")
267		if err != nil {
268			return err
269		}
270
271		defer rows.Close() // nolint: errcheck
272		for rows.Next() {
273			var name string
274			if err := rows.Scan(&name); err != nil {
275				return err
276			}
277
278			repos = append(repos, &Repo{
279				name: name,
280				path: filepath.Join(d.reposPath(), name+".git"),
281				db:   d.db,
282			})
283		}
284
285		return nil
286	}); err != nil {
287		return nil, wrapDbErr(err)
288	}
289
290	return repos, nil
291}
292
293// Repository returns a repository by name.
294//
295// It implements backend.Backend.
296func (d *SqliteBackend) Repository(repo string) (backend.Repository, error) {
297	repo = utils.SanitizeRepo(repo)
298	rp := filepath.Join(d.reposPath(), repo+".git")
299	if _, err := os.Stat(rp); err != nil {
300		return nil, os.ErrNotExist
301	}
302
303	var count int
304	if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
305		return tx.Get(&count, "SELECT COUNT(*) FROM repo WHERE name = ?", repo)
306	}); err != nil {
307		return nil, wrapDbErr(err)
308	}
309
310	if count == 0 {
311		logger.Warn("repository exists but not found in database", "repo", repo)
312		return nil, fmt.Errorf("repository does not exist")
313	}
314
315	return &Repo{
316		name: repo,
317		path: rp,
318		db:   d.db,
319	}, nil
320}
321
322// Description returns the description of a repository.
323//
324// It implements backend.Backend.
325func (d *SqliteBackend) Description(repo string) (string, error) {
326	repo = utils.SanitizeRepo(repo)
327	var desc string
328	if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
329		return tx.Get(&desc, "SELECT description FROM repo WHERE name = ?", repo)
330	}); err != nil {
331		return "", wrapDbErr(err)
332	}
333
334	return desc, nil
335}
336
337// IsMirror returns true if the repository is a mirror.
338//
339// It implements backend.Backend.
340func (d *SqliteBackend) IsMirror(repo string) (bool, error) {
341	repo = utils.SanitizeRepo(repo)
342	var mirror bool
343	if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
344		return tx.Get(&mirror, "SELECT mirror FROM repo WHERE name = ?", repo)
345	}); err != nil {
346		return false, wrapDbErr(err)
347	}
348
349	return mirror, nil
350}
351
352// IsPrivate returns true if the repository is private.
353//
354// It implements backend.Backend.
355func (d *SqliteBackend) IsPrivate(repo string) (bool, error) {
356	repo = utils.SanitizeRepo(repo)
357	var private bool
358	if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
359		return tx.Get(&private, "SELECT private FROM repo WHERE name = ?", repo)
360	}); err != nil {
361		return false, wrapDbErr(err)
362	}
363
364	return private, nil
365}
366
367// IsHidden returns true if the repository is hidden.
368//
369// It implements backend.Backend.
370func (d *SqliteBackend) IsHidden(repo string) (bool, error) {
371	repo = utils.SanitizeRepo(repo)
372	var hidden bool
373	if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
374		return tx.Get(&hidden, "SELECT hidden FROM repo WHERE name = ?", repo)
375	}); err != nil {
376		return false, wrapDbErr(err)
377	}
378
379	return hidden, nil
380}
381
382// SetHidden sets the hidden flag of a repository.
383//
384// It implements backend.Backend.
385func (d *SqliteBackend) SetHidden(repo string, hidden bool) error {
386	repo = utils.SanitizeRepo(repo)
387	return wrapDbErr(wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
388		_, err := tx.Exec("UPDATE repo SET hidden = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?;", hidden, repo)
389		return err
390	}))
391}
392
393// ProjectName returns the project name of a repository.
394//
395// It implements backend.Backend.
396func (d *SqliteBackend) ProjectName(repo string) (string, error) {
397	repo = utils.SanitizeRepo(repo)
398	var name string
399	if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
400		return tx.Get(&name, "SELECT project_name FROM repo WHERE name = ?", repo)
401	}); err != nil {
402		return "", wrapDbErr(err)
403	}
404
405	return name, nil
406}
407
408// SetDescription sets the description of a repository.
409//
410// It implements backend.Backend.
411func (d *SqliteBackend) SetDescription(repo string, desc string) error {
412	repo = utils.SanitizeRepo(repo)
413	return wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
414		_, err := tx.Exec("UPDATE repo SET description = ? WHERE name = ?", desc, repo)
415		return err
416	})
417}
418
419// SetPrivate sets the private flag of a repository.
420//
421// It implements backend.Backend.
422func (d *SqliteBackend) SetPrivate(repo string, private bool) error {
423	repo = utils.SanitizeRepo(repo)
424	return wrapDbErr(
425		wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
426			_, err := tx.Exec("UPDATE repo SET private = ? WHERE name = ?", private, repo)
427			return err
428		}),
429	)
430}
431
432// SetProjectName sets the project name of a repository.
433//
434// It implements backend.Backend.
435func (d *SqliteBackend) SetProjectName(repo string, name string) error {
436	repo = utils.SanitizeRepo(repo)
437	return wrapDbErr(
438		wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
439			_, err := tx.Exec("UPDATE repo SET project_name = ? WHERE name = ?", name, repo)
440			return err
441		}),
442	)
443}
444
445// AddCollaborator adds a collaborator to a repository.
446//
447// It implements backend.Backend.
448func (d *SqliteBackend) AddCollaborator(repo string, username string) error {
449	username = strings.ToLower(username)
450	if err := utils.ValidateUsername(username); err != nil {
451		return err
452	}
453
454	repo = utils.SanitizeRepo(repo)
455	return wrapDbErr(wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
456		_, err := tx.Exec(`INSERT INTO collab (user_id, repo_id, updated_at)
457			VALUES (
458			(SELECT id FROM user WHERE username = ?),
459			(SELECT id FROM repo WHERE name = ?),
460			CURRENT_TIMESTAMP
461			);`, username, repo)
462		return err
463	}),
464	)
465}
466
467// Collaborators returns a list of collaborators for a repository.
468//
469// It implements backend.Backend.
470func (d *SqliteBackend) Collaborators(repo string) ([]string, error) {
471	repo = utils.SanitizeRepo(repo)
472	var users []string
473	if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
474		return tx.Select(&users, `SELECT name FROM user
475			INNER JOIN collab ON user.id = collab.user_id
476			INNER JOIN repo ON repo.id = collab.repo_id
477			WHERE repo.name = ?`, repo)
478	}); err != nil {
479		return nil, wrapDbErr(err)
480	}
481
482	return users, nil
483}
484
485// IsCollaborator returns true if the user is a collaborator of the repository.
486//
487// It implements backend.Backend.
488func (d *SqliteBackend) IsCollaborator(repo string, username string) (bool, error) {
489	repo = utils.SanitizeRepo(repo)
490	var count int
491	if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
492		return tx.Get(&count, `SELECT COUNT(*) FROM user
493			INNER JOIN collab ON user.id = collab.user_id
494			INNER JOIN repo ON repo.id = collab.repo_id
495			WHERE repo.name = ? AND user.username = ?`, repo, username)
496	}); err != nil {
497		return false, wrapDbErr(err)
498	}
499
500	return count > 0, nil
501}
502
503// RemoveCollaborator removes a collaborator from a repository.
504//
505// It implements backend.Backend.
506func (d *SqliteBackend) RemoveCollaborator(repo string, username string) error {
507	repo = utils.SanitizeRepo(repo)
508	return wrapDbErr(
509		wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
510			_, err := tx.Exec(`DELETE FROM collab
511			WHERE user_id = (SELECT id FROM user WHERE username = ?)
512			AND repo_id = (SELECT id FROM repo WHERE name = ?)`, username, repo)
513			return err
514		}),
515	)
516}
517
518var (
519	hookNames = []string{"pre-receive", "update", "post-update", "post-receive"}
520	hookTpls  = []string{
521		// for pre-receive
522		`#!/usr/bin/env bash
523# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
524data=$(cat)
525exitcodes=""
526hookname=$(basename $0)
527GIT_DIR=${GIT_DIR:-$(dirname $0)/..}
528for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
529  test -x "${hook}" && test -f "${hook}" || continue
530  echo "${data}" | "${hook}"
531  exitcodes="${exitcodes} $?"
532done
533for i in ${exitcodes}; do
534  [ ${i} -eq 0 ] || exit ${i}
535done
536`,
537
538		// for update
539		`#!/usr/bin/env bash
540# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
541exitcodes=""
542hookname=$(basename $0)
543GIT_DIR=${GIT_DIR:-$(dirname $0/..)}
544for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
545  test -x "${hook}" && test -f "${hook}" || continue
546  "${hook}" $1 $2 $3
547  exitcodes="${exitcodes} $?"
548done
549for i in ${exitcodes}; do
550  [ ${i} -eq 0 ] || exit ${i}
551done
552`,
553
554		// for post-update
555		`#!/usr/bin/env bash
556# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
557data=$(cat)
558exitcodes=""
559hookname=$(basename $0)
560GIT_DIR=${GIT_DIR:-$(dirname $0)/..}
561for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
562  test -x "${hook}" && test -f "${hook}" || continue
563  "${hook}" $@
564  exitcodes="${exitcodes} $?"
565done
566for i in ${exitcodes}; do
567  [ ${i} -eq 0 ] || exit ${i}
568done
569`,
570
571		// for post-receive
572		`#!/usr/bin/env bash
573# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
574data=$(cat)
575exitcodes=""
576hookname=$(basename $0)
577GIT_DIR=${GIT_DIR:-$(dirname $0)/..}
578for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
579  test -x "${hook}" && test -f "${hook}" || continue
580  echo "${data}" | "${hook}"
581  exitcodes="${exitcodes} $?"
582done
583for i in ${exitcodes}; do
584  [ ${i} -eq 0 ] || exit ${i}
585done
586`,
587	}
588)
589
590// InitializeHooks updates the hooks for the given repository.
591//
592// It implements backend.Backend.
593func (d *SqliteBackend) InitializeHooks(repo string) error {
594	hookTmpl, err := template.New("hook").Parse(`#!/usr/bin/env bash
595# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
596{{ range $_, $env := .Envs }}
597{{ $env }} \{{ end }}
598{{ .Executable }} hook --config "{{ .Config }}" {{ .Hook }} {{ .Args }}
599`)
600	if err != nil {
601		return err
602	}
603
604	repo = utils.SanitizeRepo(repo) + ".git"
605	hooksPath := filepath.Join(d.reposPath(), repo, "hooks")
606	if err := os.MkdirAll(hooksPath, 0755); err != nil {
607		return err
608	}
609
610	ex, err := os.Executable()
611	if err != nil {
612		return err
613	}
614
615	dp, err := filepath.Abs(d.dp)
616	if err != nil {
617		return fmt.Errorf("failed to get absolute path for data path: %w", err)
618	}
619
620	cp := filepath.Join(dp, "config.yaml")
621	envs := []string{}
622	for i, hook := range hookNames {
623		var data bytes.Buffer
624		var args string
625		hp := filepath.Join(hooksPath, hook)
626		if err := os.WriteFile(hp, []byte(hookTpls[i]), 0755); err != nil {
627			return err
628		}
629
630		// Create hook.d directory.
631		hp += ".d"
632		if err := os.MkdirAll(hp, 0755); err != nil {
633			return err
634		}
635
636		if hook == "update" {
637			args = "$1 $2 $3"
638		} else if hook == "post-update" {
639			args = "$@"
640		}
641
642		err = hookTmpl.Execute(&data, struct {
643			Executable string
644			Hook       string
645			Args       string
646			Envs       []string
647			Config     string
648		}{
649			Executable: ex,
650			Hook:       hook,
651			Args:       args,
652			Envs:       envs,
653			Config:     cp,
654		})
655		if err != nil {
656			logger.Error("failed to execute hook template", "err", err)
657			continue
658		}
659
660		hp = filepath.Join(hp, "soft-serve")
661		err = os.WriteFile(hp, data.Bytes(), 0755) //nolint:gosec
662		if err != nil {
663			logger.Error("failed to write hook", "err", err)
664			continue
665		}
666	}
667
668	return nil
669}
670
671func (d *SqliteBackend) initRepos() error {
672	repos, err := d.Repositories()
673	if err != nil {
674		return err
675	}
676
677	for _, repo := range repos {
678		if err := d.InitializeHooks(repo.Name()); err != nil {
679			return err
680		}
681	}
682
683	return nil
684}