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