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