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, updated_at)
137			VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP);`,
138			name, opts.ProjectName, opts.Description, opts.Private, opts.Mirror)
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 {
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 ""
292	}
293
294	return desc
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 {
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
307	}
308
309	return mirror
310}
311
312// IsPrivate returns true if the repository is private.
313//
314// It implements backend.Backend.
315func (d *SqliteBackend) IsPrivate(repo string) bool {
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
322	}
323
324	return private
325}
326
327// ProjectName returns the project name of a repository.
328//
329// It implements backend.Backend.
330func (d *SqliteBackend) ProjectName(repo string) string {
331	repo = utils.SanitizeRepo(repo)
332	var name string
333	if err := wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
334		return tx.Get(&name, "SELECT project_name FROM repo WHERE name = ?", repo)
335	}); err != nil {
336		return ""
337	}
338
339	return name
340}
341
342// SetDescription sets the description of a repository.
343//
344// It implements backend.Backend.
345func (d *SqliteBackend) SetDescription(repo string, desc string) error {
346	repo = utils.SanitizeRepo(repo)
347	return wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
348		_, err := tx.Exec("UPDATE repo SET description = ? WHERE name = ?", desc, repo)
349		return err
350	})
351}
352
353// SetPrivate sets the private flag of a repository.
354//
355// It implements backend.Backend.
356func (d *SqliteBackend) SetPrivate(repo string, private bool) error {
357	repo = utils.SanitizeRepo(repo)
358	return wrapDbErr(
359		wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
360			_, err := tx.Exec("UPDATE repo SET private = ? WHERE name = ?", private, repo)
361			return err
362		}),
363	)
364}
365
366// SetProjectName sets the project name of a repository.
367//
368// It implements backend.Backend.
369func (d *SqliteBackend) SetProjectName(repo string, name string) error {
370	repo = utils.SanitizeRepo(repo)
371	return wrapDbErr(
372		wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
373			_, err := tx.Exec("UPDATE repo SET project_name = ? WHERE name = ?", name, repo)
374			return err
375		}),
376	)
377}
378
379// AddCollaborator adds a collaborator to a repository.
380//
381// It implements backend.Backend.
382func (d *SqliteBackend) AddCollaborator(repo string, username string) error {
383	repo = utils.SanitizeRepo(repo)
384	return wrapDbErr(wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
385		_, err := tx.Exec(`INSERT INTO collab (user_id, repo_id, updated_at)
386			VALUES (
387			(SELECT id FROM user WHERE username = ?),
388			(SELECT id FROM repo WHERE name = ?),
389			CURRENT_TIMESTAMP
390			);`, username, repo)
391		return err
392	}),
393	)
394}
395
396// Collaborators returns a list of collaborators for a repository.
397//
398// It implements backend.Backend.
399func (d *SqliteBackend) Collaborators(repo string) ([]string, error) {
400	repo = utils.SanitizeRepo(repo)
401	var users []string
402	if err := wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
403		return tx.Select(&users, `SELECT name FROM user
404			INNER JOIN collab ON user.id = collab.user_id
405			INNER JOIN repo ON repo.id = collab.repo_id
406			WHERE repo.name = ?`, repo)
407	}); err != nil {
408		return nil, wrapDbErr(err)
409	}
410
411	return users, nil
412}
413
414// IsCollaborator returns true if the user is a collaborator of the repository.
415//
416// It implements backend.Backend.
417func (d *SqliteBackend) IsCollaborator(repo string, username string) bool {
418	repo = utils.SanitizeRepo(repo)
419	var count int
420	if err := wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
421		return tx.Get(&count, `SELECT COUNT(*) FROM user
422			INNER JOIN collab ON user.id = collab.user_id
423			INNER JOIN repo ON repo.id = collab.repo_id
424			WHERE repo.name = ? AND user.username = ?`, repo, username)
425	}); err != nil {
426		return false
427	}
428
429	return count > 0
430}
431
432// RemoveCollaborator removes a collaborator from a repository.
433//
434// It implements backend.Backend.
435func (d *SqliteBackend) RemoveCollaborator(repo string, username string) error {
436	repo = utils.SanitizeRepo(repo)
437	return wrapDbErr(
438		wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
439			_, err := tx.Exec(`DELETE FROM collab
440			WHERE user_id = (SELECT id FROM user WHERE username = ?)
441			AND repo_id = (SELECT id FROM repo WHERE name = ?)`, username, repo)
442			return err
443		}),
444	)
445}
446
447var (
448	hookNames = []string{"pre-receive", "update", "post-update", "post-receive"}
449	hookTpls  = []string{
450		// for pre-receive
451		`#!/usr/bin/env bash
452# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
453data=$(cat)
454exitcodes=""
455hookname=$(basename $0)
456GIT_DIR=${GIT_DIR:-$(dirname $0)/..}
457for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
458  test -x "${hook}" && test -f "${hook}" || continue
459  echo "${data}" | "${hook}"
460  exitcodes="${exitcodes} $?"
461done
462for i in ${exitcodes}; do
463  [ ${i} -eq 0 ] || exit ${i}
464done
465`,
466
467		// for update
468		`#!/usr/bin/env bash
469# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
470exitcodes=""
471hookname=$(basename $0)
472GIT_DIR=${GIT_DIR:-$(dirname $0/..)}
473for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
474  test -x "${hook}" && test -f "${hook}" || continue
475  "${hook}" $1 $2 $3
476  exitcodes="${exitcodes} $?"
477done
478for i in ${exitcodes}; do
479  [ ${i} -eq 0 ] || exit ${i}
480done
481`,
482
483		// for post-update
484		`#!/usr/bin/env bash
485# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
486data=$(cat)
487exitcodes=""
488hookname=$(basename $0)
489GIT_DIR=${GIT_DIR:-$(dirname $0)/..}
490for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
491  test -x "${hook}" && test -f "${hook}" || continue
492  "${hook}" $@
493  exitcodes="${exitcodes} $?"
494done
495for i in ${exitcodes}; do
496  [ ${i} -eq 0 ] || exit ${i}
497done
498`,
499
500		// for post-receive
501		`#!/usr/bin/env bash
502# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
503data=$(cat)
504exitcodes=""
505hookname=$(basename $0)
506GIT_DIR=${GIT_DIR:-$(dirname $0)/..}
507for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
508  test -x "${hook}" && test -f "${hook}" || continue
509  echo "${data}" | "${hook}"
510  exitcodes="${exitcodes} $?"
511done
512for i in ${exitcodes}; do
513  [ ${i} -eq 0 ] || exit ${i}
514done
515`,
516	}
517)
518
519// InitializeHooks updates the hooks for the given repository.
520//
521// It implements backend.Backend.
522func (d *SqliteBackend) InitializeHooks(repo string) error {
523	hookTmpl, err := template.New("hook").Parse(`#!/usr/bin/env bash
524# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
525{{ range $_, $env := .Envs }}
526{{ $env }} \{{ end }}
527{{ .Executable }} hook --config "{{ .Config }}" {{ .Hook }} {{ .Args }}
528`)
529	if err != nil {
530		return err
531	}
532
533	repo = utils.SanitizeRepo(repo) + ".git"
534	hooksPath := filepath.Join(d.reposPath(), repo, "hooks")
535	if err := os.MkdirAll(hooksPath, 0755); err != nil {
536		return err
537	}
538
539	ex, err := os.Executable()
540	if err != nil {
541		return err
542	}
543
544	dp, err := filepath.Abs(d.dp)
545	if err != nil {
546		return fmt.Errorf("failed to get absolute path for data path: %w", err)
547	}
548
549	cp := filepath.Join(dp, "config.yaml")
550	envs := []string{}
551	for i, hook := range hookNames {
552		var data bytes.Buffer
553		var args string
554		hp := filepath.Join(hooksPath, hook)
555		if err := os.WriteFile(hp, []byte(hookTpls[i]), 0755); err != nil {
556			return err
557		}
558
559		// Create hook.d directory.
560		hp += ".d"
561		if err := os.MkdirAll(hp, 0755); err != nil {
562			return err
563		}
564
565		if hook == "update" {
566			args = "$1 $2 $3"
567		} else if hook == "post-update" {
568			args = "$@"
569		}
570
571		err = hookTmpl.Execute(&data, struct {
572			Executable string
573			Hook       string
574			Args       string
575			Envs       []string
576			Config     string
577		}{
578			Executable: ex,
579			Hook:       hook,
580			Args:       args,
581			Envs:       envs,
582			Config:     cp,
583		})
584		if err != nil {
585			logger.Error("failed to execute hook template", "err", err)
586			continue
587		}
588
589		hp = filepath.Join(hp, "soft-serve")
590		err = os.WriteFile(hp, data.Bytes(), 0755) //nolint:gosec
591		if err != nil {
592			logger.Error("failed to write hook", "err", err)
593			continue
594		}
595	}
596
597	return nil
598}