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