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