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