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