sqlite.go

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