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