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