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