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