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