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