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