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