1package sqlite
2
3import (
4 "context"
5 "fmt"
6 "os"
7 "path/filepath"
8 "strings"
9 "time"
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// 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 d.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 d.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 d.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 Timeout: 15 * time.Minute,
185 CommandOptions: git.CommandOptions{
186 Envs: []string{
187 fmt.Sprintf(`GIT_SSH_COMMAND=ssh -o UserKnownHostsFile="%s" -o StrictHostKeyChecking=no -i "%s"`,
188 filepath.Join(d.cfg.DataPath, "ssh", "known_hosts"),
189 d.cfg.SSH.ClientKeyPath,
190 ),
191 },
192 },
193 }
194
195 if err := git.Clone(remote, rp, copts); err != nil {
196 d.logger.Error("failed to clone repository", "err", err, "mirror", opts.Mirror, "remote", remote, "path", rp)
197 return nil, err
198 }
199
200 return d.CreateRepository(name, opts)
201}
202
203// DeleteRepository deletes a repository.
204//
205// It implements backend.Backend.
206func (d *SqliteBackend) DeleteRepository(name string) error {
207 name = utils.SanitizeRepo(name)
208 repo := name + ".git"
209 rp := filepath.Join(d.reposPath(), repo)
210 if _, err := os.Stat(rp); err != nil {
211 return os.ErrNotExist
212 }
213
214 if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
215 _, err := tx.Exec("DELETE FROM repo WHERE name = ?;", name)
216 return err
217 }); err != nil {
218 return wrapDbErr(err)
219 }
220
221 return os.RemoveAll(rp)
222}
223
224// RenameRepository renames a repository.
225//
226// It implements backend.Backend.
227func (d *SqliteBackend) RenameRepository(oldName string, newName string) error {
228 oldName = utils.SanitizeRepo(oldName)
229 if err := utils.ValidateRepo(oldName); err != nil {
230 return err
231 }
232
233 newName = utils.SanitizeRepo(newName)
234 if err := utils.ValidateRepo(newName); err != nil {
235 return err
236 }
237 oldRepo := oldName + ".git"
238 newRepo := newName + ".git"
239 op := filepath.Join(d.reposPath(), oldRepo)
240 np := filepath.Join(d.reposPath(), newRepo)
241 if _, err := os.Stat(op); err != nil {
242 return fmt.Errorf("repository %s does not exist", oldName)
243 }
244
245 if _, err := os.Stat(np); err == nil {
246 return fmt.Errorf("repository %s already exists", newName)
247 }
248
249 if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
250 _, err := tx.Exec("UPDATE repo SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?;", newName, oldName)
251 return err
252 }); err != nil {
253 return wrapDbErr(err)
254 }
255
256 // Make sure the new repository parent directory exists.
257 if err := os.MkdirAll(filepath.Dir(np), os.ModePerm); err != nil {
258 return err
259 }
260
261 return os.Rename(op, np)
262}
263
264// Repositories returns a list of all repositories.
265//
266// It implements backend.Backend.
267func (d *SqliteBackend) Repositories() ([]backend.Repository, error) {
268 repos := make([]backend.Repository, 0)
269 if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
270 rows, err := tx.Query("SELECT name FROM repo")
271 if err != nil {
272 return err
273 }
274
275 defer rows.Close() // nolint: errcheck
276 for rows.Next() {
277 var name string
278 if err := rows.Scan(&name); err != nil {
279 return err
280 }
281
282 repos = append(repos, &Repo{
283 name: name,
284 path: filepath.Join(d.reposPath(), name+".git"),
285 db: d.db,
286 })
287 }
288
289 return nil
290 }); err != nil {
291 return nil, wrapDbErr(err)
292 }
293
294 return repos, nil
295}
296
297// Repository returns a repository by name.
298//
299// It implements backend.Backend.
300func (d *SqliteBackend) Repository(repo string) (backend.Repository, error) {
301 repo = utils.SanitizeRepo(repo)
302 rp := filepath.Join(d.reposPath(), repo+".git")
303 if _, err := os.Stat(rp); err != nil {
304 return nil, os.ErrNotExist
305 }
306
307 var count int
308 if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
309 return tx.Get(&count, "SELECT COUNT(*) FROM repo WHERE name = ?", repo)
310 }); err != nil {
311 return nil, wrapDbErr(err)
312 }
313
314 if count == 0 {
315 d.logger.Warn("repository exists but not found in database", "repo", repo)
316 return nil, ErrRepoNotExist
317 }
318
319 return &Repo{
320 name: repo,
321 path: rp,
322 db: d.db,
323 }, nil
324}
325
326// Description returns the description of a repository.
327//
328// It implements backend.Backend.
329func (d *SqliteBackend) Description(repo string) (string, error) {
330 repo = utils.SanitizeRepo(repo)
331 var desc string
332 if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
333 row := tx.QueryRow("SELECT description FROM repo WHERE name = ?", repo)
334 return row.Scan(&desc)
335 }); err != nil {
336 return "", wrapDbErr(err)
337 }
338
339 return desc, nil
340}
341
342// IsMirror returns true if the repository is a mirror.
343//
344// It implements backend.Backend.
345func (d *SqliteBackend) IsMirror(repo string) (bool, error) {
346 repo = utils.SanitizeRepo(repo)
347 var mirror bool
348 if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
349 return tx.Get(&mirror, "SELECT mirror FROM repo WHERE name = ?", repo)
350 }); err != nil {
351 return false, wrapDbErr(err)
352 }
353
354 return mirror, nil
355}
356
357// IsPrivate returns true if the repository is private.
358//
359// It implements backend.Backend.
360func (d *SqliteBackend) IsPrivate(repo string) (bool, error) {
361 repo = utils.SanitizeRepo(repo)
362 var private bool
363 if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
364 row := tx.QueryRow("SELECT private FROM repo WHERE name = ?", repo)
365 return row.Scan(&private)
366 }); err != nil {
367 return false, wrapDbErr(err)
368 }
369
370 return private, nil
371}
372
373// IsHidden returns true if the repository is hidden.
374//
375// It implements backend.Backend.
376func (d *SqliteBackend) IsHidden(repo string) (bool, error) {
377 repo = utils.SanitizeRepo(repo)
378 var hidden bool
379 if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
380 row := tx.QueryRow("SELECT hidden FROM repo WHERE name = ?", repo)
381 return row.Scan(&hidden)
382 }); err != nil {
383 return false, wrapDbErr(err)
384 }
385
386 return hidden, nil
387}
388
389// SetHidden sets the hidden flag of a repository.
390//
391// It implements backend.Backend.
392func (d *SqliteBackend) SetHidden(repo string, hidden bool) error {
393 repo = utils.SanitizeRepo(repo)
394 return wrapDbErr(wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
395 var count int
396 if err := tx.Get(&count, "SELECT COUNT(*) FROM repo WHERE name = ?", repo); err != nil {
397 return err
398 }
399 if count == 0 {
400 return ErrRepoNotExist
401 }
402 _, err := tx.Exec("UPDATE repo SET hidden = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?;", hidden, repo)
403 return err
404 }))
405}
406
407// ProjectName returns the project name of a repository.
408//
409// It implements backend.Backend.
410func (d *SqliteBackend) ProjectName(repo string) (string, error) {
411 repo = utils.SanitizeRepo(repo)
412 var name string
413 if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
414 row := tx.QueryRow("SELECT project_name FROM repo WHERE name = ?", repo)
415 return row.Scan(&name)
416 }); err != nil {
417 return "", wrapDbErr(err)
418 }
419
420 return name, nil
421}
422
423// SetDescription sets the description of a repository.
424//
425// It implements backend.Backend.
426func (d *SqliteBackend) SetDescription(repo string, desc string) error {
427 repo = utils.SanitizeRepo(repo)
428 return wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
429 var count int
430 if err := tx.Get(&count, "SELECT COUNT(*) FROM repo WHERE name = ?", repo); err != nil {
431 return err
432 }
433 if count == 0 {
434 return ErrRepoNotExist
435 }
436 _, err := tx.Exec("UPDATE repo SET description = ? WHERE name = ?", desc, repo)
437 return err
438 })
439}
440
441// SetPrivate sets the private flag of a repository.
442//
443// It implements backend.Backend.
444func (d *SqliteBackend) SetPrivate(repo string, private bool) error {
445 repo = utils.SanitizeRepo(repo)
446 return wrapDbErr(
447 wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
448 var count int
449 if err := tx.Get(&count, "SELECT COUNT(*) FROM repo WHERE name = ?", repo); err != nil {
450 return err
451 }
452 if count == 0 {
453 return ErrRepoNotExist
454 }
455 _, err := tx.Exec("UPDATE repo SET private = ? WHERE name = ?", private, repo)
456 return err
457 }),
458 )
459}
460
461// SetProjectName sets the project name of a repository.
462//
463// It implements backend.Backend.
464func (d *SqliteBackend) SetProjectName(repo string, name string) error {
465 repo = utils.SanitizeRepo(repo)
466 return wrapDbErr(
467 wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
468 var count int
469 if err := tx.Get(&count, "SELECT COUNT(*) FROM repo WHERE name = ?", repo); err != nil {
470 return err
471 }
472 if count == 0 {
473 return ErrRepoNotExist
474 }
475 _, err := tx.Exec("UPDATE repo SET project_name = ? WHERE name = ?", name, repo)
476 return err
477 }),
478 )
479}
480
481// AddCollaborator adds a collaborator to a repository.
482//
483// It implements backend.Backend.
484func (d *SqliteBackend) AddCollaborator(repo string, username string) error {
485 username = strings.ToLower(username)
486 if err := utils.ValidateUsername(username); err != nil {
487 return err
488 }
489
490 repo = utils.SanitizeRepo(repo)
491 return wrapDbErr(wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
492 _, err := tx.Exec(`INSERT INTO collab (user_id, repo_id, updated_at)
493 VALUES (
494 (SELECT id FROM user WHERE username = ?),
495 (SELECT id FROM repo WHERE name = ?),
496 CURRENT_TIMESTAMP
497 );`, username, repo)
498 return err
499 }),
500 )
501}
502
503// Collaborators returns a list of collaborators for a repository.
504//
505// It implements backend.Backend.
506func (d *SqliteBackend) Collaborators(repo string) ([]string, error) {
507 repo = utils.SanitizeRepo(repo)
508 var users []string
509 if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
510 return tx.Select(&users, `SELECT user.username FROM user
511 INNER JOIN collab ON user.id = collab.user_id
512 INNER JOIN repo ON repo.id = collab.repo_id
513 WHERE repo.name = ?`, repo)
514 }); err != nil {
515 return nil, wrapDbErr(err)
516 }
517
518 return users, nil
519}
520
521// IsCollaborator returns true if the user is a collaborator of the repository.
522//
523// It implements backend.Backend.
524func (d *SqliteBackend) IsCollaborator(repo string, username string) (bool, error) {
525 repo = utils.SanitizeRepo(repo)
526 var count int
527 if err := wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
528 return tx.Get(&count, `SELECT COUNT(*) FROM user
529 INNER JOIN collab ON user.id = collab.user_id
530 INNER JOIN repo ON repo.id = collab.repo_id
531 WHERE repo.name = ? AND user.username = ?`, repo, username)
532 }); err != nil {
533 return false, wrapDbErr(err)
534 }
535
536 return count > 0, nil
537}
538
539// RemoveCollaborator removes a collaborator from a repository.
540//
541// It implements backend.Backend.
542func (d *SqliteBackend) RemoveCollaborator(repo string, username string) error {
543 repo = utils.SanitizeRepo(repo)
544 return wrapDbErr(
545 wrapTx(d.db, d.ctx, func(tx *sqlx.Tx) error {
546 _, err := tx.Exec(`DELETE FROM collab
547 WHERE user_id = (SELECT id FROM user WHERE username = ?)
548 AND repo_id = (SELECT id FROM repo WHERE name = ?)`, username, repo)
549 return err
550 }),
551 )
552}
553
554func (d *SqliteBackend) initRepo(repo string) error {
555 return hooks.GenerateHooks(d.ctx, d.cfg, repo)
556}
557
558func (d *SqliteBackend) initRepos() error {
559 repos, err := d.Repositories()
560 if err != nil {
561 return err
562 }
563
564 for _, repo := range repos {
565 if err := d.initRepo(repo.Name()); err != nil {
566 return err
567 }
568 }
569
570 return nil
571}