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, hidden, updated_at)
137 VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP);`,
138 name, opts.ProjectName, opts.Description, opts.Private, opts.Mirror, opts.Hidden)
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// IsHidden returns true if the repository is hidden.
328//
329// It implements backend.Backend.
330func (d *SqliteBackend) IsHidden(repo string) bool {
331 repo = utils.SanitizeRepo(repo)
332 var hidden bool
333 if err := wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
334 return tx.Get(&hidden, "SELECT hidden FROM repo WHERE name = ?", repo)
335 }); err != nil {
336 return false
337 }
338
339 return hidden
340}
341
342// SetHidden sets the hidden flag of a repository.
343//
344// It implements backend.Backend.
345func (d *SqliteBackend) SetHidden(repo string, hidden bool) error {
346 repo = utils.SanitizeRepo(repo)
347 return wrapDbErr(wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
348 _, err := tx.Exec("UPDATE repo SET hidden = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?;", hidden, repo)
349 return err
350 }))
351}
352
353// ProjectName returns the project name of a repository.
354//
355// It implements backend.Backend.
356func (d *SqliteBackend) ProjectName(repo string) string {
357 repo = utils.SanitizeRepo(repo)
358 var name string
359 if err := wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
360 return tx.Get(&name, "SELECT project_name FROM repo WHERE name = ?", repo)
361 }); err != nil {
362 return ""
363 }
364
365 return name
366}
367
368// SetDescription sets the description of a repository.
369//
370// It implements backend.Backend.
371func (d *SqliteBackend) SetDescription(repo string, desc string) error {
372 repo = utils.SanitizeRepo(repo)
373 return wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
374 _, err := tx.Exec("UPDATE repo SET description = ? WHERE name = ?", desc, repo)
375 return err
376 })
377}
378
379// SetPrivate sets the private flag of a repository.
380//
381// It implements backend.Backend.
382func (d *SqliteBackend) SetPrivate(repo string, private bool) error {
383 repo = utils.SanitizeRepo(repo)
384 return wrapDbErr(
385 wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
386 _, err := tx.Exec("UPDATE repo SET private = ? WHERE name = ?", private, repo)
387 return err
388 }),
389 )
390}
391
392// SetProjectName sets the project name of a repository.
393//
394// It implements backend.Backend.
395func (d *SqliteBackend) SetProjectName(repo string, name string) error {
396 repo = utils.SanitizeRepo(repo)
397 return wrapDbErr(
398 wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
399 _, err := tx.Exec("UPDATE repo SET project_name = ? WHERE name = ?", name, repo)
400 return err
401 }),
402 )
403}
404
405// AddCollaborator adds a collaborator to a repository.
406//
407// It implements backend.Backend.
408func (d *SqliteBackend) AddCollaborator(repo string, username string) error {
409 repo = utils.SanitizeRepo(repo)
410 return wrapDbErr(wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
411 _, err := tx.Exec(`INSERT INTO collab (user_id, repo_id, updated_at)
412 VALUES (
413 (SELECT id FROM user WHERE username = ?),
414 (SELECT id FROM repo WHERE name = ?),
415 CURRENT_TIMESTAMP
416 );`, username, repo)
417 return err
418 }),
419 )
420}
421
422// Collaborators returns a list of collaborators for a repository.
423//
424// It implements backend.Backend.
425func (d *SqliteBackend) Collaborators(repo string) ([]string, error) {
426 repo = utils.SanitizeRepo(repo)
427 var users []string
428 if err := wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
429 return tx.Select(&users, `SELECT name FROM user
430 INNER JOIN collab ON user.id = collab.user_id
431 INNER JOIN repo ON repo.id = collab.repo_id
432 WHERE repo.name = ?`, repo)
433 }); err != nil {
434 return nil, wrapDbErr(err)
435 }
436
437 return users, nil
438}
439
440// IsCollaborator returns true if the user is a collaborator of the repository.
441//
442// It implements backend.Backend.
443func (d *SqliteBackend) IsCollaborator(repo string, username string) bool {
444 repo = utils.SanitizeRepo(repo)
445 var count int
446 if err := wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
447 return tx.Get(&count, `SELECT COUNT(*) FROM user
448 INNER JOIN collab ON user.id = collab.user_id
449 INNER JOIN repo ON repo.id = collab.repo_id
450 WHERE repo.name = ? AND user.username = ?`, repo, username)
451 }); err != nil {
452 return false
453 }
454
455 return count > 0
456}
457
458// RemoveCollaborator removes a collaborator from a repository.
459//
460// It implements backend.Backend.
461func (d *SqliteBackend) RemoveCollaborator(repo string, username string) error {
462 repo = utils.SanitizeRepo(repo)
463 return wrapDbErr(
464 wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
465 _, err := tx.Exec(`DELETE FROM collab
466 WHERE user_id = (SELECT id FROM user WHERE username = ?)
467 AND repo_id = (SELECT id FROM repo WHERE name = ?)`, username, repo)
468 return err
469 }),
470 )
471}
472
473var (
474 hookNames = []string{"pre-receive", "update", "post-update", "post-receive"}
475 hookTpls = []string{
476 // for pre-receive
477 `#!/usr/bin/env bash
478# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
479data=$(cat)
480exitcodes=""
481hookname=$(basename $0)
482GIT_DIR=${GIT_DIR:-$(dirname $0)/..}
483for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
484 test -x "${hook}" && test -f "${hook}" || continue
485 echo "${data}" | "${hook}"
486 exitcodes="${exitcodes} $?"
487done
488for i in ${exitcodes}; do
489 [ ${i} -eq 0 ] || exit ${i}
490done
491`,
492
493 // for update
494 `#!/usr/bin/env bash
495# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
496exitcodes=""
497hookname=$(basename $0)
498GIT_DIR=${GIT_DIR:-$(dirname $0/..)}
499for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
500 test -x "${hook}" && test -f "${hook}" || continue
501 "${hook}" $1 $2 $3
502 exitcodes="${exitcodes} $?"
503done
504for i in ${exitcodes}; do
505 [ ${i} -eq 0 ] || exit ${i}
506done
507`,
508
509 // for post-update
510 `#!/usr/bin/env bash
511# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
512data=$(cat)
513exitcodes=""
514hookname=$(basename $0)
515GIT_DIR=${GIT_DIR:-$(dirname $0)/..}
516for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
517 test -x "${hook}" && test -f "${hook}" || continue
518 "${hook}" $@
519 exitcodes="${exitcodes} $?"
520done
521for i in ${exitcodes}; do
522 [ ${i} -eq 0 ] || exit ${i}
523done
524`,
525
526 // for post-receive
527 `#!/usr/bin/env bash
528# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
529data=$(cat)
530exitcodes=""
531hookname=$(basename $0)
532GIT_DIR=${GIT_DIR:-$(dirname $0)/..}
533for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
534 test -x "${hook}" && test -f "${hook}" || continue
535 echo "${data}" | "${hook}"
536 exitcodes="${exitcodes} $?"
537done
538for i in ${exitcodes}; do
539 [ ${i} -eq 0 ] || exit ${i}
540done
541`,
542 }
543)
544
545// InitializeHooks updates the hooks for the given repository.
546//
547// It implements backend.Backend.
548func (d *SqliteBackend) InitializeHooks(repo string) error {
549 hookTmpl, err := template.New("hook").Parse(`#!/usr/bin/env bash
550# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
551{{ range $_, $env := .Envs }}
552{{ $env }} \{{ end }}
553{{ .Executable }} hook --config "{{ .Config }}" {{ .Hook }} {{ .Args }}
554`)
555 if err != nil {
556 return err
557 }
558
559 repo = utils.SanitizeRepo(repo) + ".git"
560 hooksPath := filepath.Join(d.reposPath(), repo, "hooks")
561 if err := os.MkdirAll(hooksPath, 0755); err != nil {
562 return err
563 }
564
565 ex, err := os.Executable()
566 if err != nil {
567 return err
568 }
569
570 dp, err := filepath.Abs(d.dp)
571 if err != nil {
572 return fmt.Errorf("failed to get absolute path for data path: %w", err)
573 }
574
575 cp := filepath.Join(dp, "config.yaml")
576 envs := []string{}
577 for i, hook := range hookNames {
578 var data bytes.Buffer
579 var args string
580 hp := filepath.Join(hooksPath, hook)
581 if err := os.WriteFile(hp, []byte(hookTpls[i]), 0755); err != nil {
582 return err
583 }
584
585 // Create hook.d directory.
586 hp += ".d"
587 if err := os.MkdirAll(hp, 0755); err != nil {
588 return err
589 }
590
591 if hook == "update" {
592 args = "$1 $2 $3"
593 } else if hook == "post-update" {
594 args = "$@"
595 }
596
597 err = hookTmpl.Execute(&data, struct {
598 Executable string
599 Hook string
600 Args string
601 Envs []string
602 Config string
603 }{
604 Executable: ex,
605 Hook: hook,
606 Args: args,
607 Envs: envs,
608 Config: cp,
609 })
610 if err != nil {
611 logger.Error("failed to execute hook template", "err", err)
612 continue
613 }
614
615 hp = filepath.Join(hp, "soft-serve")
616 err = os.WriteFile(hp, data.Bytes(), 0755) //nolint:gosec
617 if err != nil {
618 logger.Error("failed to write hook", "err", err)
619 continue
620 }
621 }
622
623 return nil
624}