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