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