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 filepath.Join(d.cfg.DataPath, 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 return os.Rename(op, np)
258}
259
260// Repositories returns a list of all repositories.
261//
262// It implements backend.Backend.
263func (d *SqliteBackend) Repositories() ([]backend.Repository, error) {
264 repos := make([]backend.Repository, 0)
265 if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
266 rows, err := tx.Query("SELECT name FROM repo")
267 if err != nil {
268 return err
269 }
270
271 defer rows.Close() // nolint: errcheck
272 for rows.Next() {
273 var name string
274 if err := rows.Scan(&name); err != nil {
275 return err
276 }
277
278 repos = append(repos, &Repo{
279 name: name,
280 path: filepath.Join(d.reposPath(), name+".git"),
281 db: d.db,
282 })
283 }
284
285 return nil
286 }); err != nil {
287 return nil, wrapDbErr(err)
288 }
289
290 return repos, nil
291}
292
293// Repository returns a repository by name.
294//
295// It implements backend.Backend.
296func (d *SqliteBackend) Repository(repo string) (backend.Repository, error) {
297 repo = utils.SanitizeRepo(repo)
298 rp := filepath.Join(d.reposPath(), repo+".git")
299 if _, err := os.Stat(rp); err != nil {
300 return nil, os.ErrNotExist
301 }
302
303 var count int
304 if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
305 return tx.Get(&count, "SELECT COUNT(*) FROM repo WHERE name = ?", repo)
306 }); err != nil {
307 return nil, wrapDbErr(err)
308 }
309
310 if count == 0 {
311 logger.Warn("repository exists but not found in database", "repo", repo)
312 return nil, fmt.Errorf("repository does not exist")
313 }
314
315 return &Repo{
316 name: repo,
317 path: rp,
318 db: d.db,
319 }, nil
320}
321
322// Description returns the description of a repository.
323//
324// It implements backend.Backend.
325func (d *SqliteBackend) Description(repo string) (string, error) {
326 repo = utils.SanitizeRepo(repo)
327 var desc string
328 if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
329 return tx.Get(&desc, "SELECT description FROM repo WHERE name = ?", repo)
330 }); err != nil {
331 return "", wrapDbErr(err)
332 }
333
334 return desc, nil
335}
336
337// IsMirror returns true if the repository is a mirror.
338//
339// It implements backend.Backend.
340func (d *SqliteBackend) IsMirror(repo string) (bool, error) {
341 repo = utils.SanitizeRepo(repo)
342 var mirror bool
343 if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
344 return tx.Get(&mirror, "SELECT mirror FROM repo WHERE name = ?", repo)
345 }); err != nil {
346 return false, wrapDbErr(err)
347 }
348
349 return mirror, nil
350}
351
352// IsPrivate returns true if the repository is private.
353//
354// It implements backend.Backend.
355func (d *SqliteBackend) IsPrivate(repo string) (bool, error) {
356 repo = utils.SanitizeRepo(repo)
357 var private bool
358 if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
359 return tx.Get(&private, "SELECT private FROM repo WHERE name = ?", repo)
360 }); err != nil {
361 return false, wrapDbErr(err)
362 }
363
364 return private, nil
365}
366
367// IsHidden returns true if the repository is hidden.
368//
369// It implements backend.Backend.
370func (d *SqliteBackend) IsHidden(repo string) (bool, error) {
371 repo = utils.SanitizeRepo(repo)
372 var hidden bool
373 if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
374 return tx.Get(&hidden, "SELECT hidden FROM repo WHERE name = ?", repo)
375 }); err != nil {
376 return false, wrapDbErr(err)
377 }
378
379 return hidden, nil
380}
381
382// SetHidden sets the hidden flag of a repository.
383//
384// It implements backend.Backend.
385func (d *SqliteBackend) SetHidden(repo string, hidden bool) error {
386 repo = utils.SanitizeRepo(repo)
387 return wrapDbErr(wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
388 _, err := tx.Exec("UPDATE repo SET hidden = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?;", hidden, repo)
389 return err
390 }))
391}
392
393// ProjectName returns the project name of a repository.
394//
395// It implements backend.Backend.
396func (d *SqliteBackend) ProjectName(repo string) (string, error) {
397 repo = utils.SanitizeRepo(repo)
398 var name string
399 if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
400 return tx.Get(&name, "SELECT project_name FROM repo WHERE name = ?", repo)
401 }); err != nil {
402 return "", wrapDbErr(err)
403 }
404
405 return name, nil
406}
407
408// SetDescription sets the description of a repository.
409//
410// It implements backend.Backend.
411func (d *SqliteBackend) SetDescription(repo string, desc string) error {
412 repo = utils.SanitizeRepo(repo)
413 return wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
414 _, err := tx.Exec("UPDATE repo SET description = ? WHERE name = ?", desc, repo)
415 return err
416 })
417}
418
419// SetPrivate sets the private flag of a repository.
420//
421// It implements backend.Backend.
422func (d *SqliteBackend) SetPrivate(repo string, private bool) error {
423 repo = utils.SanitizeRepo(repo)
424 return wrapDbErr(
425 wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
426 _, err := tx.Exec("UPDATE repo SET private = ? WHERE name = ?", private, repo)
427 return err
428 }),
429 )
430}
431
432// SetProjectName sets the project name of a repository.
433//
434// It implements backend.Backend.
435func (d *SqliteBackend) SetProjectName(repo string, name string) error {
436 repo = utils.SanitizeRepo(repo)
437 return wrapDbErr(
438 wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
439 _, err := tx.Exec("UPDATE repo SET project_name = ? WHERE name = ?", name, repo)
440 return err
441 }),
442 )
443}
444
445// AddCollaborator adds a collaborator to a repository.
446//
447// It implements backend.Backend.
448func (d *SqliteBackend) AddCollaborator(repo string, username string) error {
449 username = strings.ToLower(username)
450 if err := utils.ValidateUsername(username); err != nil {
451 return err
452 }
453
454 repo = utils.SanitizeRepo(repo)
455 return wrapDbErr(wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
456 _, err := tx.Exec(`INSERT INTO collab (user_id, repo_id, updated_at)
457 VALUES (
458 (SELECT id FROM user WHERE username = ?),
459 (SELECT id FROM repo WHERE name = ?),
460 CURRENT_TIMESTAMP
461 );`, username, repo)
462 return err
463 }),
464 )
465}
466
467// Collaborators returns a list of collaborators for a repository.
468//
469// It implements backend.Backend.
470func (d *SqliteBackend) Collaborators(repo string) ([]string, error) {
471 repo = utils.SanitizeRepo(repo)
472 var users []string
473 if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
474 return tx.Select(&users, `SELECT name FROM user
475 INNER JOIN collab ON user.id = collab.user_id
476 INNER JOIN repo ON repo.id = collab.repo_id
477 WHERE repo.name = ?`, repo)
478 }); err != nil {
479 return nil, wrapDbErr(err)
480 }
481
482 return users, nil
483}
484
485// IsCollaborator returns true if the user is a collaborator of the repository.
486//
487// It implements backend.Backend.
488func (d *SqliteBackend) IsCollaborator(repo string, username string) (bool, error) {
489 repo = utils.SanitizeRepo(repo)
490 var count int
491 if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
492 return tx.Get(&count, `SELECT COUNT(*) FROM user
493 INNER JOIN collab ON user.id = collab.user_id
494 INNER JOIN repo ON repo.id = collab.repo_id
495 WHERE repo.name = ? AND user.username = ?`, repo, username)
496 }); err != nil {
497 return false, wrapDbErr(err)
498 }
499
500 return count > 0, nil
501}
502
503// RemoveCollaborator removes a collaborator from a repository.
504//
505// It implements backend.Backend.
506func (d *SqliteBackend) RemoveCollaborator(repo string, username string) error {
507 repo = utils.SanitizeRepo(repo)
508 return wrapDbErr(
509 wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
510 _, err := tx.Exec(`DELETE FROM collab
511 WHERE user_id = (SELECT id FROM user WHERE username = ?)
512 AND repo_id = (SELECT id FROM repo WHERE name = ?)`, username, repo)
513 return err
514 }),
515 )
516}
517
518var (
519 hookNames = []string{"pre-receive", "update", "post-update", "post-receive"}
520 hookTpls = []string{
521 // for pre-receive
522 `#!/usr/bin/env bash
523# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
524data=$(cat)
525exitcodes=""
526hookname=$(basename $0)
527GIT_DIR=${GIT_DIR:-$(dirname $0)/..}
528for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
529 test -x "${hook}" && test -f "${hook}" || continue
530 echo "${data}" | "${hook}"
531 exitcodes="${exitcodes} $?"
532done
533for i in ${exitcodes}; do
534 [ ${i} -eq 0 ] || exit ${i}
535done
536`,
537
538 // for update
539 `#!/usr/bin/env bash
540# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
541exitcodes=""
542hookname=$(basename $0)
543GIT_DIR=${GIT_DIR:-$(dirname $0/..)}
544for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
545 test -x "${hook}" && test -f "${hook}" || continue
546 "${hook}" $1 $2 $3
547 exitcodes="${exitcodes} $?"
548done
549for i in ${exitcodes}; do
550 [ ${i} -eq 0 ] || exit ${i}
551done
552`,
553
554 // for post-update
555 `#!/usr/bin/env bash
556# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
557data=$(cat)
558exitcodes=""
559hookname=$(basename $0)
560GIT_DIR=${GIT_DIR:-$(dirname $0)/..}
561for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
562 test -x "${hook}" && test -f "${hook}" || continue
563 "${hook}" $@
564 exitcodes="${exitcodes} $?"
565done
566for i in ${exitcodes}; do
567 [ ${i} -eq 0 ] || exit ${i}
568done
569`,
570
571 // for post-receive
572 `#!/usr/bin/env bash
573# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
574data=$(cat)
575exitcodes=""
576hookname=$(basename $0)
577GIT_DIR=${GIT_DIR:-$(dirname $0)/..}
578for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
579 test -x "${hook}" && test -f "${hook}" || continue
580 echo "${data}" | "${hook}"
581 exitcodes="${exitcodes} $?"
582done
583for i in ${exitcodes}; do
584 [ ${i} -eq 0 ] || exit ${i}
585done
586`,
587 }
588)
589
590// InitializeHooks updates the hooks for the given repository.
591//
592// It implements backend.Backend.
593func (d *SqliteBackend) InitializeHooks(repo string) error {
594 hookTmpl, err := template.New("hook").Parse(`#!/usr/bin/env bash
595# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
596{{ range $_, $env := .Envs }}
597{{ $env }} \{{ end }}
598{{ .Executable }} hook --config "{{ .Config }}" {{ .Hook }} {{ .Args }}
599`)
600 if err != nil {
601 return err
602 }
603
604 repo = utils.SanitizeRepo(repo) + ".git"
605 hooksPath := filepath.Join(d.reposPath(), repo, "hooks")
606 if err := os.MkdirAll(hooksPath, 0755); err != nil {
607 return err
608 }
609
610 ex, err := os.Executable()
611 if err != nil {
612 return err
613 }
614
615 dp, err := filepath.Abs(d.dp)
616 if err != nil {
617 return fmt.Errorf("failed to get absolute path for data path: %w", err)
618 }
619
620 cp := filepath.Join(dp, "config.yaml")
621 envs := []string{}
622 for i, hook := range hookNames {
623 var data bytes.Buffer
624 var args string
625 hp := filepath.Join(hooksPath, hook)
626 if err := os.WriteFile(hp, []byte(hookTpls[i]), 0755); err != nil {
627 return err
628 }
629
630 // Create hook.d directory.
631 hp += ".d"
632 if err := os.MkdirAll(hp, 0755); err != nil {
633 return err
634 }
635
636 if hook == "update" {
637 args = "$1 $2 $3"
638 } else if hook == "post-update" {
639 args = "$@"
640 }
641
642 err = hookTmpl.Execute(&data, struct {
643 Executable string
644 Hook string
645 Args string
646 Envs []string
647 Config string
648 }{
649 Executable: ex,
650 Hook: hook,
651 Args: args,
652 Envs: envs,
653 Config: cp,
654 })
655 if err != nil {
656 logger.Error("failed to execute hook template", "err", err)
657 continue
658 }
659
660 hp = filepath.Join(hp, "soft-serve")
661 err = os.WriteFile(hp, data.Bytes(), 0755) //nolint:gosec
662 if err != nil {
663 logger.Error("failed to write hook", "err", err)
664 continue
665 }
666 }
667
668 return nil
669}
670
671func (d *SqliteBackend) initRepos() error {
672 repos, err := d.Repositories()
673 if err != nil {
674 return err
675 }
676
677 for _, repo := range repos {
678 if err := d.InitializeHooks(repo.Name()); err != nil {
679 return err
680 }
681 }
682
683 return nil
684}