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