1package sqlite
2
3import (
4 "context"
5 "fmt"
6 "os"
7 "path/filepath"
8 "strings"
9
10 "github.com/charmbracelet/log"
11 "github.com/charmbracelet/soft-serve/git"
12 "github.com/charmbracelet/soft-serve/server/backend"
13 "github.com/charmbracelet/soft-serve/server/config"
14 "github.com/charmbracelet/soft-serve/server/hooks"
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 cfg *config.Config
28 ctx context.Context
29 dp string
30 db *sqlx.DB
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(ctx context.Context, cfg *config.Config) (*SqliteBackend, error) {
41 dataPath := cfg.DataPath
42 if err := os.MkdirAll(dataPath, os.ModePerm); err != nil {
43 return nil, err
44 }
45
46 db, err := sqlx.Connect("sqlite", filepath.Join(dataPath, "soft-serve.db"+
47 "?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)"))
48 if err != nil {
49 return nil, err
50 }
51
52 d := &SqliteBackend{
53 cfg: cfg,
54 ctx: ctx,
55 dp: dataPath,
56 db: db,
57 }
58
59 if err := d.init(); err != nil {
60 return nil, err
61 }
62
63 if err := d.db.Ping(); err != nil {
64 return nil, err
65 }
66
67 return d, d.initRepos()
68}
69
70// AllowKeyless returns whether or not keyless access is allowed.
71//
72// It implements backend.Backend.
73func (d *SqliteBackend) AllowKeyless() bool {
74 var allow bool
75 if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
76 return tx.Get(&allow, "SELECT value FROM settings WHERE key = ?;", "allow_keyless")
77 }); err != nil {
78 return false
79 }
80
81 return allow
82}
83
84// AnonAccess returns the level of anonymous access.
85//
86// It implements backend.Backend.
87func (d *SqliteBackend) AnonAccess() backend.AccessLevel {
88 var level string
89 if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
90 return tx.Get(&level, "SELECT value FROM settings WHERE key = ?;", "anon_access")
91 }); err != nil {
92 return backend.NoAccess
93 }
94
95 return backend.ParseAccessLevel(level)
96}
97
98// SetAllowKeyless sets whether or not keyless access is allowed.
99//
100// It implements backend.Backend.
101func (d *SqliteBackend) SetAllowKeyless(allow bool) error {
102 return wrapDbErr(
103 wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
104 _, err := tx.Exec("UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = ?;", allow, "allow_keyless")
105 return err
106 }),
107 )
108}
109
110// SetAnonAccess sets the level of anonymous access.
111//
112// It implements backend.Backend.
113func (d *SqliteBackend) SetAnonAccess(level backend.AccessLevel) error {
114 return wrapDbErr(
115 wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
116 _, err := tx.Exec("UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = ?;", level.String(), "anon_access")
117 return err
118 }),
119 )
120}
121
122// CreateRepository creates a new repository.
123//
124// It implements backend.Backend.
125func (d *SqliteBackend) CreateRepository(name string, opts backend.RepositoryOptions) (backend.Repository, error) {
126 name = utils.SanitizeRepo(name)
127 if err := utils.ValidateRepo(name); err != nil {
128 return nil, err
129 }
130
131 repo := name + ".git"
132 rp := filepath.Join(d.reposPath(), repo)
133
134 cleanup := func() error {
135 return os.RemoveAll(rp)
136 }
137
138 rr, err := git.Init(rp, true)
139 if err != nil {
140 logger.Debug("failed to create repository", "err", err)
141 cleanup() // nolint: errcheck
142 return nil, err
143 }
144
145 if err := rr.UpdateServerInfo(); err != nil {
146 logger.Debug("failed to update server info", "err", err)
147 cleanup() // nolint: errcheck
148 return nil, err
149 }
150
151 if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
152 _, err := tx.Exec(`INSERT INTO repo (name, project_name, description, private, mirror, hidden, updated_at)
153 VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP);`,
154 name, opts.ProjectName, opts.Description, opts.Private, opts.Mirror, opts.Hidden)
155 return err
156 }); err != nil {
157 logger.Debug("failed to create repository in database", "err", err)
158 return nil, wrapDbErr(err)
159 }
160
161 r := &Repo{
162 name: name,
163 path: rp,
164 db: d.db,
165 }
166
167 return r, d.initRepo(name)
168}
169
170// ImportRepository imports a repository from remote.
171func (d *SqliteBackend) ImportRepository(name string, remote string, opts backend.RepositoryOptions) (backend.Repository, error) {
172 name = utils.SanitizeRepo(name)
173 if err := utils.ValidateRepo(name); err != nil {
174 return nil, err
175 }
176
177 repo := name + ".git"
178 rp := filepath.Join(d.reposPath(), repo)
179
180 copts := git.CloneOptions{
181 Bare: true,
182 Mirror: opts.Mirror,
183 Quiet: true,
184 CommandOptions: git.CommandOptions{
185 Envs: []string{
186 fmt.Sprintf(`GIT_SSH_COMMAND=ssh -o UserKnownHostsFile="%s" -o StrictHostKeyChecking=no -i "%s"`,
187 filepath.Join(d.cfg.DataPath, "ssh", "known_hosts"),
188 d.cfg.SSH.ClientKeyPath,
189 ),
190 },
191 },
192 }
193
194 if err := git.Clone(remote, rp, copts); err != nil {
195 logger.Error("failed to clone repository", "err", err, "mirror", opts.Mirror, "remote", remote, "path", rp)
196 return nil, err
197 }
198
199 return d.CreateRepository(name, opts)
200}
201
202// DeleteRepository deletes a repository.
203//
204// It implements backend.Backend.
205func (d *SqliteBackend) DeleteRepository(name string) error {
206 name = utils.SanitizeRepo(name)
207 repo := name + ".git"
208 rp := filepath.Join(d.reposPath(), repo)
209 if _, err := os.Stat(rp); err != nil {
210 return os.ErrNotExist
211 }
212
213 if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
214 _, err := tx.Exec("DELETE FROM repo WHERE name = ?;", name)
215 return err
216 }); err != nil {
217 return wrapDbErr(err)
218 }
219
220 return os.RemoveAll(rp)
221}
222
223// RenameRepository renames a repository.
224//
225// It implements backend.Backend.
226func (d *SqliteBackend) RenameRepository(oldName string, newName string) error {
227 oldName = utils.SanitizeRepo(oldName)
228 if err := utils.ValidateRepo(oldName); err != nil {
229 return err
230 }
231
232 newName = utils.SanitizeRepo(newName)
233 if err := utils.ValidateRepo(newName); err != nil {
234 return err
235 }
236 oldRepo := oldName + ".git"
237 newRepo := newName + ".git"
238 op := filepath.Join(d.reposPath(), oldRepo)
239 np := filepath.Join(d.reposPath(), newRepo)
240 if _, err := os.Stat(op); err != nil {
241 return fmt.Errorf("repository %s does not exist", oldName)
242 }
243
244 if _, err := os.Stat(np); err == nil {
245 return fmt.Errorf("repository %s already exists", newName)
246 }
247
248 if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
249 _, err := tx.Exec("UPDATE repo SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?;", newName, oldName)
250 return err
251 }); err != nil {
252 return wrapDbErr(err)
253 }
254
255 // Make sure the new repository parent directory exists.
256 if err := os.MkdirAll(filepath.Dir(np), os.ModePerm); err != nil {
257 return err
258 }
259
260 return os.Rename(op, np)
261}
262
263// Repositories returns a list of all repositories.
264//
265// It implements backend.Backend.
266func (d *SqliteBackend) Repositories() ([]backend.Repository, error) {
267 repos := make([]backend.Repository, 0)
268 if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
269 rows, err := tx.Query("SELECT name FROM repo")
270 if err != nil {
271 return err
272 }
273
274 defer rows.Close() // nolint: errcheck
275 for rows.Next() {
276 var name string
277 if err := rows.Scan(&name); err != nil {
278 return err
279 }
280
281 repos = append(repos, &Repo{
282 name: name,
283 path: filepath.Join(d.reposPath(), name+".git"),
284 db: d.db,
285 })
286 }
287
288 return nil
289 }); err != nil {
290 return nil, wrapDbErr(err)
291 }
292
293 return repos, nil
294}
295
296// Repository returns a repository by name.
297//
298// It implements backend.Backend.
299func (d *SqliteBackend) Repository(repo string) (backend.Repository, error) {
300 repo = utils.SanitizeRepo(repo)
301 rp := filepath.Join(d.reposPath(), repo+".git")
302 if _, err := os.Stat(rp); err != nil {
303 return nil, os.ErrNotExist
304 }
305
306 var count int
307 if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
308 return tx.Get(&count, "SELECT COUNT(*) FROM repo WHERE name = ?", repo)
309 }); err != nil {
310 return nil, wrapDbErr(err)
311 }
312
313 if count == 0 {
314 logger.Warn("repository exists but not found in database", "repo", repo)
315 return nil, ErrRepoNotExist
316 }
317
318 return &Repo{
319 name: repo,
320 path: rp,
321 db: d.db,
322 }, nil
323}
324
325// Description returns the description of a repository.
326//
327// It implements backend.Backend.
328func (d *SqliteBackend) Description(repo string) (string, error) {
329 repo = utils.SanitizeRepo(repo)
330 var desc string
331 if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
332 row := tx.QueryRow("SELECT description FROM repo WHERE name = ?", repo)
333 return row.Scan(&desc)
334 }); err != nil {
335 return "", wrapDbErr(err)
336 }
337
338 return desc, nil
339}
340
341// IsMirror returns true if the repository is a mirror.
342//
343// It implements backend.Backend.
344func (d *SqliteBackend) IsMirror(repo string) (bool, error) {
345 repo = utils.SanitizeRepo(repo)
346 var mirror bool
347 if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
348 return tx.Get(&mirror, "SELECT mirror FROM repo WHERE name = ?", repo)
349 }); err != nil {
350 return false, wrapDbErr(err)
351 }
352
353 return mirror, nil
354}
355
356// IsPrivate returns true if the repository is private.
357//
358// It implements backend.Backend.
359func (d *SqliteBackend) IsPrivate(repo string) (bool, error) {
360 repo = utils.SanitizeRepo(repo)
361 var private bool
362 if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
363 row := tx.QueryRow("SELECT private FROM repo WHERE name = ?", repo)
364 return row.Scan(&private)
365 }); err != nil {
366 return false, wrapDbErr(err)
367 }
368
369 return private, nil
370}
371
372// IsHidden returns true if the repository is hidden.
373//
374// It implements backend.Backend.
375func (d *SqliteBackend) IsHidden(repo string) (bool, error) {
376 repo = utils.SanitizeRepo(repo)
377 var hidden bool
378 if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
379 row := tx.QueryRow("SELECT hidden FROM repo WHERE name = ?", repo)
380 return row.Scan(&hidden)
381 }); err != nil {
382 return false, wrapDbErr(err)
383 }
384
385 return hidden, nil
386}
387
388// SetHidden sets the hidden flag of a repository.
389//
390// It implements backend.Backend.
391func (d *SqliteBackend) SetHidden(repo string, hidden bool) error {
392 repo = utils.SanitizeRepo(repo)
393 return wrapDbErr(wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
394 var count int
395 if err := tx.Get(&count, "SELECT COUNT(*) FROM repo WHERE name = ?", repo); err != nil {
396 return err
397 }
398 if count == 0 {
399 return ErrRepoNotExist
400 }
401 _, err := tx.Exec("UPDATE repo SET hidden = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?;", hidden, repo)
402 return err
403 }))
404}
405
406// ProjectName returns the project name of a repository.
407//
408// It implements backend.Backend.
409func (d *SqliteBackend) ProjectName(repo string) (string, error) {
410 repo = utils.SanitizeRepo(repo)
411 var name string
412 if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
413 row := tx.QueryRow("SELECT project_name FROM repo WHERE name = ?", repo)
414 return row.Scan(&name)
415 }); err != nil {
416 return "", wrapDbErr(err)
417 }
418
419 return name, nil
420}
421
422// SetDescription sets the description of a repository.
423//
424// It implements backend.Backend.
425func (d *SqliteBackend) SetDescription(repo string, desc string) error {
426 repo = utils.SanitizeRepo(repo)
427 return wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
428 var count int
429 if err := tx.Get(&count, "SELECT COUNT(*) FROM repo WHERE name = ?", repo); err != nil {
430 return err
431 }
432 if count == 0 {
433 return ErrRepoNotExist
434 }
435 _, err := tx.Exec("UPDATE repo SET description = ? WHERE name = ?", desc, repo)
436 return err
437 })
438}
439
440// SetPrivate sets the private flag of a repository.
441//
442// It implements backend.Backend.
443func (d *SqliteBackend) SetPrivate(repo string, private bool) error {
444 repo = utils.SanitizeRepo(repo)
445 return wrapDbErr(
446 wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
447 var count int
448 if err := tx.Get(&count, "SELECT COUNT(*) FROM repo WHERE name = ?", repo); err != nil {
449 return err
450 }
451 if count == 0 {
452 return ErrRepoNotExist
453 }
454 _, err := tx.Exec("UPDATE repo SET private = ? WHERE name = ?", private, repo)
455 return err
456 }),
457 )
458}
459
460// SetProjectName sets the project name of a repository.
461//
462// It implements backend.Backend.
463func (d *SqliteBackend) SetProjectName(repo string, name string) error {
464 repo = utils.SanitizeRepo(repo)
465 return wrapDbErr(
466 wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
467 var count int
468 if err := tx.Get(&count, "SELECT COUNT(*) FROM repo WHERE name = ?", repo); err != nil {
469 return err
470 }
471 if count == 0 {
472 return ErrRepoNotExist
473 }
474 _, err := tx.Exec("UPDATE repo SET project_name = ? WHERE name = ?", name, repo)
475 return err
476 }),
477 )
478}
479
480// AddCollaborator adds a collaborator to a repository.
481//
482// It implements backend.Backend.
483func (d *SqliteBackend) AddCollaborator(repo string, username string) error {
484 username = strings.ToLower(username)
485 if err := utils.ValidateUsername(username); err != nil {
486 return err
487 }
488
489 repo = utils.SanitizeRepo(repo)
490 return wrapDbErr(wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
491 _, err := tx.Exec(`INSERT INTO collab (user_id, repo_id, updated_at)
492 VALUES (
493 (SELECT id FROM user WHERE username = ?),
494 (SELECT id FROM repo WHERE name = ?),
495 CURRENT_TIMESTAMP
496 );`, username, repo)
497 return err
498 }),
499 )
500}
501
502// Collaborators returns a list of collaborators for a repository.
503//
504// It implements backend.Backend.
505func (d *SqliteBackend) Collaborators(repo string) ([]string, error) {
506 repo = utils.SanitizeRepo(repo)
507 var users []string
508 if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
509 return tx.Select(&users, `SELECT user.username FROM user
510 INNER JOIN collab ON user.id = collab.user_id
511 INNER JOIN repo ON repo.id = collab.repo_id
512 WHERE repo.name = ?`, repo)
513 }); err != nil {
514 return nil, wrapDbErr(err)
515 }
516
517 return users, nil
518}
519
520// IsCollaborator returns true if the user is a collaborator of the repository.
521//
522// It implements backend.Backend.
523func (d *SqliteBackend) IsCollaborator(repo string, username string) (bool, error) {
524 repo = utils.SanitizeRepo(repo)
525 var count int
526 if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
527 return tx.Get(&count, `SELECT COUNT(*) FROM user
528 INNER JOIN collab ON user.id = collab.user_id
529 INNER JOIN repo ON repo.id = collab.repo_id
530 WHERE repo.name = ? AND user.username = ?`, repo, username)
531 }); err != nil {
532 return false, wrapDbErr(err)
533 }
534
535 return count > 0, nil
536}
537
538// RemoveCollaborator removes a collaborator from a repository.
539//
540// It implements backend.Backend.
541func (d *SqliteBackend) RemoveCollaborator(repo string, username string) error {
542 repo = utils.SanitizeRepo(repo)
543 return wrapDbErr(
544 wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
545 _, err := tx.Exec(`DELETE FROM collab
546 WHERE user_id = (SELECT id FROM user WHERE username = ?)
547 AND repo_id = (SELECT id FROM repo WHERE name = ?)`, username, repo)
548 return err
549 }),
550 )
551}
552
553func (d *SqliteBackend) initRepo(repo string) error {
554 return hooks.GenerateHooks(d.ctx, d.cfg, repo)
555}
556
557func (d *SqliteBackend) initRepos() error {
558 repos, err := d.Repositories()
559 if err != nil {
560 return err
561 }
562
563 for _, repo := range repos {
564 if err := d.initRepo(repo.Name()); err != nil {
565 return err
566 }
567 }
568
569 return nil
570}