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