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