sqlite.go

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