1package filesqlite
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "os"
8 "path/filepath"
9
10 "github.com/charmbracelet/soft-serve/git"
11 "github.com/charmbracelet/soft-serve/server/db/sqlite"
12 "github.com/charmbracelet/soft-serve/server/store"
13 "github.com/charmbracelet/soft-serve/server/utils"
14 "github.com/jmoiron/sqlx"
15)
16
17// TODO: use billy.Filesystem instead of filepath
18
19// CreateRepository creates a new repository.
20//
21// It implements store.Backend.
22func (d *SqliteStore) CreateRepository(ctx context.Context, name string, opts store.RepositoryOptions) (store.Repository, error) {
23 name = utils.SanitizeRepo(name)
24 if err := utils.ValidateRepo(name); err != nil {
25 return nil, err
26 }
27
28 repo := name + ".git"
29 rp := filepath.Join(d.reposPath(), repo)
30
31 if err := sqlite.WrapTx(d.db.DBx(), ctx, func(tx *sqlx.Tx) error {
32 if _, err := tx.Exec(`INSERT INTO repo (name, project_name, description, private, mirror, hidden, updated_at)
33 VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP);`,
34 name, opts.ProjectName, opts.Description, opts.Private, opts.Mirror, opts.Hidden); err != nil {
35 return err
36 }
37
38 _, err := git.Init(rp, true)
39 if err != nil {
40 d.logger.Debug("failed to create repository", "err", err)
41 return err
42 }
43
44 if err := d.updateGitDaemonExportOk(name, opts.Private); err != nil {
45 return err
46 }
47
48 return d.updateDescriptionFile(name, opts.Description)
49 }); err != nil {
50 d.logger.Debug("failed to create repository in database", "err", err)
51 return nil, sqlite.WrapDbErr(err)
52 }
53
54 r := &Repo{
55 name: name,
56 path: rp,
57 db: d.db.DBx(),
58 }
59
60 // Set cache
61 d.cache.Set(ctx, cacheKey(name), r)
62
63 return r, nil
64}
65
66// ImportRepository imports a repository from remote.
67func (d *SqliteStore) ImportRepository(ctx context.Context, name string, remote string, opts store.RepositoryOptions) (store.Repository, error) {
68 cfg := d.cfg
69 name = utils.SanitizeRepo(name)
70 if err := utils.ValidateRepo(name); err != nil {
71 return nil, err
72 }
73
74 repo := name + ".git"
75 rp := filepath.Join(d.reposPath(), repo)
76
77 if _, err := os.Stat(rp); err == nil || os.IsExist(err) {
78 return nil, ErrRepoExist
79 }
80
81 copts := git.CloneOptions{
82 Bare: true,
83 Mirror: opts.Mirror,
84 Quiet: true,
85 CommandOptions: git.CommandOptions{
86 Timeout: -1,
87 Context: ctx,
88 Envs: []string{
89 fmt.Sprintf(`GIT_SSH_COMMAND=ssh -o UserKnownHostsFile="%s" -o StrictHostKeyChecking=no -i "%s"`,
90 filepath.Join(cfg.DataPath, "ssh", "known_hosts"),
91 cfg.SSH.ClientKeyPath,
92 ),
93 },
94 },
95 // Timeout: time.Hour,
96 }
97
98 if err := git.Clone(remote, rp, copts); err != nil {
99 d.logger.Error("failed to clone repository", "err", err, "mirror", opts.Mirror, "remote", remote, "path", rp)
100 // Cleanup the mess!
101 if rerr := os.RemoveAll(rp); rerr != nil {
102 err = errors.Join(err, rerr)
103 }
104 return nil, err
105 }
106
107 return d.CreateRepository(ctx, name, opts)
108}
109
110// DeleteRepository deletes a repository.
111//
112// It implements store.Backend.
113func (d *SqliteStore) DeleteRepository(ctx context.Context, name string) error {
114 name = utils.SanitizeRepo(name)
115 repo := name + ".git"
116 rp := filepath.Join(d.reposPath(), repo)
117
118 return sqlite.WrapTx(d.db.DBx(), ctx, func(tx *sqlx.Tx) error {
119 // Delete repo from cache
120 defer d.cache.Delete(ctx, cacheKey(name))
121
122 if _, err := tx.Exec("DELETE FROM repo WHERE name = ?;", name); err != nil {
123 return err
124 }
125
126 return os.RemoveAll(rp)
127 })
128}
129
130// RenameRepository renames a repository.
131//
132// It implements store.Backend.
133func (d *SqliteStore) RenameRepository(ctx context.Context, oldName string, newName string) error {
134 oldName = utils.SanitizeRepo(oldName)
135 if err := utils.ValidateRepo(oldName); err != nil {
136 return err
137 }
138
139 newName = utils.SanitizeRepo(newName)
140 if err := utils.ValidateRepo(newName); err != nil {
141 return err
142 }
143 oldRepo := oldName + ".git"
144 newRepo := newName + ".git"
145 op := filepath.Join(d.reposPath(), oldRepo)
146 np := filepath.Join(d.reposPath(), newRepo)
147 if _, err := os.Stat(op); err != nil {
148 return ErrRepoNotExist
149 }
150
151 if _, err := os.Stat(np); err == nil {
152 return ErrRepoExist
153 }
154
155 if err := sqlite.WrapTx(d.db.DBx(), ctx, func(tx *sqlx.Tx) error {
156 // Delete cache
157 defer d.cache.Delete(ctx, cacheKey(oldName))
158
159 _, err := tx.Exec("UPDATE repo SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?;", newName, oldName)
160 if err != nil {
161 return err
162 }
163
164 // Make sure the new repository parent directory exists.
165 if err := os.MkdirAll(filepath.Dir(np), os.ModePerm); err != nil {
166 return err
167 }
168
169 if err := os.Rename(op, np); err != nil {
170 return err
171 }
172
173 return nil
174 }); err != nil {
175 return sqlite.WrapDbErr(err)
176 }
177
178 return nil
179}
180
181// CountRepositories returns the total number of repositories.
182func (d *SqliteStore) CountRepositories(ctx context.Context) (uint64, error) {
183 var count uint64
184
185 if err := sqlite.WrapTx(d.db.DBx(), ctx, func(tx *sqlx.Tx) error {
186 return tx.QueryRow("SELECT COUNT(*) FROM repo;").Scan(&count)
187 }); err != nil {
188 return 0, sqlite.WrapDbErr(err)
189 }
190
191 return count, nil
192}
193
194// Repositories returns a list of all repositories.
195//
196// It implements store.Backend.
197func (d *SqliteStore) Repositories(ctx context.Context, page int, perPage int) ([]store.Repository, error) {
198 repos := make([]store.Repository, 0)
199
200 if err := sqlite.WrapTx(d.db.DBx(), ctx, func(tx *sqlx.Tx) error {
201 rows, err := tx.Query("SELECT name FROM repo ORDER BY updated_at DESC LIMIT ? OFFSET ?;",
202 perPage, (page-1)*perPage)
203 if err != nil {
204 return err
205 }
206
207 defer rows.Close() // nolint: errcheck
208 for rows.Next() {
209 var name string
210 if err := rows.Scan(&name); err != nil {
211 return err
212 }
213
214 if r, ok := d.cache.Get(ctx, cacheKey(name)); ok && r != nil {
215 if r, ok := r.(*Repo); ok {
216 repos = append(repos, r)
217 }
218 continue
219 }
220
221 r := &Repo{
222 name: name,
223 path: filepath.Join(d.reposPath(), name+".git"),
224 db: d.db.DBx(),
225 }
226
227 // Cache repositories
228 d.cache.Set(ctx, cacheKey(name), r)
229
230 repos = append(repos, r)
231 }
232
233 return nil
234 }); err != nil {
235 return nil, sqlite.WrapDbErr(err)
236 }
237
238 return repos, nil
239}
240
241// Repository returns a repository by name.
242//
243// It implements store.Backend.
244func (d *SqliteStore) Repository(ctx context.Context, repo string) (store.Repository, error) {
245 repo = utils.SanitizeRepo(repo)
246
247 if r, ok := d.cache.Get(ctx, cacheKey(repo)); ok && r != nil {
248 if r, ok := r.(*Repo); ok {
249 return r, nil
250 }
251 }
252
253 rp := filepath.Join(d.reposPath(), repo+".git")
254 if _, err := os.Stat(rp); err != nil {
255 return nil, os.ErrNotExist
256 }
257
258 var count int
259 if err := sqlite.WrapTx(d.db.DBx(), ctx, func(tx *sqlx.Tx) error {
260 return tx.Get(&count, "SELECT COUNT(*) FROM repo WHERE name = ?", repo)
261 }); err != nil {
262 return nil, sqlite.WrapDbErr(err)
263 }
264
265 if count == 0 {
266 d.logger.Warn("repository exists but not found in database", "repo", repo)
267 return nil, ErrRepoNotExist
268 }
269
270 r := &Repo{
271 name: repo,
272 path: rp,
273 db: d.db.DBx(),
274 }
275
276 // Add to cache
277 d.cache.Set(ctx, cacheKey(repo), r)
278
279 return r, nil
280}
281
282// Description returns the description of a repository.
283//
284// It implements store.Backend.
285func (d *SqliteStore) Description(ctx context.Context, repo string) (string, error) {
286 r, err := d.Repository(ctx, repo)
287 if err != nil {
288 return "", err
289 }
290
291 return r.Description(), nil
292}
293
294// IsMirror returns true if the repository is a mirror.
295//
296// It implements store.Backend.
297func (d *SqliteStore) IsMirror(ctx context.Context, repo string) (bool, error) {
298 r, err := d.Repository(ctx, repo)
299 if err != nil {
300 return false, err
301 }
302
303 return r.IsMirror(), nil
304}
305
306// IsPrivate returns true if the repository is private.
307//
308// It implements store.Backend.
309func (d *SqliteStore) IsPrivate(ctx context.Context, repo string) (bool, error) {
310 r, err := d.Repository(ctx, repo)
311 if err != nil {
312 return false, err
313 }
314
315 return r.IsPrivate(), nil
316}
317
318// IsHidden returns true if the repository is hidden.
319//
320// It implements store.Backend.
321func (d *SqliteStore) IsHidden(ctx context.Context, repo string) (bool, error) {
322 r, err := d.Repository(ctx, repo)
323 if err != nil {
324 return false, err
325 }
326
327 return r.IsHidden(), nil
328}
329
330// SetHidden sets the hidden flag of a repository.
331//
332// It implements store.Backend.
333func (d *SqliteStore) SetHidden(ctx context.Context, repo string, hidden bool) error {
334 repo = utils.SanitizeRepo(repo)
335
336 // Delete cache
337 d.cache.Delete(ctx, cacheKey(repo))
338
339 return sqlite.WrapDbErr(sqlite.WrapTx(d.db.DBx(), ctx, func(tx *sqlx.Tx) error {
340 var count int
341 if err := tx.Get(&count, "SELECT COUNT(*) FROM repo WHERE name = ?", repo); err != nil {
342 return err
343 }
344 if count == 0 {
345 return ErrRepoNotExist
346 }
347 _, err := tx.Exec("UPDATE repo SET hidden = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?;", hidden, repo)
348 return err
349 }))
350}
351
352// ProjectName returns the project name of a repository.
353//
354// It implements store.Backend.
355func (d *SqliteStore) ProjectName(ctx context.Context, repo string) (string, error) {
356 r, err := d.Repository(ctx, repo)
357 if err != nil {
358 return "", err
359 }
360
361 return r.ProjectName(), nil
362}
363
364func (d *SqliteStore) updateDescriptionFile(name, desc string) error {
365 repo := utils.RepoPath(d.reposPath(), name)
366 fp := filepath.Join(repo, "description")
367 return os.WriteFile(fp, []byte(desc), 0644)
368}
369
370// SetDescription sets the description of a repository.
371//
372// It implements store.Backend.
373func (d *SqliteStore) SetDescription(ctx context.Context, repo string, desc string) error {
374 repo = utils.SanitizeRepo(repo)
375
376 // Delete cache
377 d.cache.Delete(ctx, cacheKey(repo))
378
379 return sqlite.WrapTx(d.db.DBx(), ctx, func(tx *sqlx.Tx) error {
380 var count int
381 if err := tx.Get(&count, "SELECT COUNT(*) FROM repo WHERE name = ?", repo); err != nil {
382 return err
383 }
384 if count == 0 {
385 return ErrRepoNotExist
386 }
387 _, err := tx.Exec("UPDATE repo SET description = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?", desc, repo)
388 if err != nil {
389 return err
390 }
391
392 return d.updateDescriptionFile(repo, desc)
393 })
394}
395
396func (d *SqliteStore) updateGitDaemonExportOk(name string, isPrivate bool) error {
397 repo := utils.RepoPath(d.reposPath(), name)
398 fp := filepath.Join(repo, "git-daemon-export-ok")
399 if isPrivate {
400 return os.Remove(fp)
401 }
402 return os.WriteFile(fp, []byte{}, 0644) // nolint: gosec
403}
404
405// SetPrivate sets the private flag of a repository.
406//
407// It implements store.Backend.
408func (d *SqliteStore) SetPrivate(ctx context.Context, name string, private bool) error {
409 name = utils.SanitizeRepo(name)
410
411 // Delete cache
412 d.cache.Delete(ctx, cacheKey(name))
413
414 return sqlite.WrapDbErr(
415 sqlite.WrapTx(d.db.DBx(), ctx, func(tx *sqlx.Tx) error {
416 var count int
417 if err := tx.Get(&count, "SELECT COUNT(*) FROM repo WHERE name = ?", name); err != nil {
418 return err
419 }
420 if count == 0 {
421 return ErrRepoNotExist
422 }
423
424 _, err := tx.Exec("UPDATE repo SET private = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?", private, name)
425 if err != nil {
426 return err
427 }
428
429 return d.updateGitDaemonExportOk(name, private)
430 }),
431 )
432}
433
434// SetProjectName sets the project name of a repository.
435//
436// It implements store.Backend.
437func (d *SqliteStore) SetProjectName(ctx context.Context, repo string, name string) error {
438 repo = utils.SanitizeRepo(repo)
439
440 // Delete cache
441 d.cache.Delete(ctx, cacheKey(repo))
442
443 return sqlite.WrapDbErr(
444 sqlite.WrapTx(d.db.DBx(), ctx, func(tx *sqlx.Tx) error {
445 var count int
446 if err := tx.Get(&count, "SELECT COUNT(*) FROM repo WHERE name = ?", repo); err != nil {
447 return err
448 }
449 if count == 0 {
450 return ErrRepoNotExist
451 }
452 _, err := tx.Exec("UPDATE repo SET project_name = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?", name, repo)
453 return err
454 }),
455 )
456}
457
458// Touch updates the last update time of a repository.
459func (d *SqliteStore) Touch(ctx context.Context, repo string) error {
460 repo = utils.SanitizeRepo(repo)
461
462 // Delete cache
463 d.cache.Delete(ctx, cacheKey(repo))
464
465 return sqlite.WrapDbErr(
466 sqlite.WrapTx(d.db.DBx(), ctx, func(tx *sqlx.Tx) error {
467 _, err := tx.Exec("UPDATE repo SET updated_at = CURRENT_TIMESTAMP WHERE name = ?", repo)
468 if err != nil {
469 return err
470 }
471
472 if err := d.populateLastModified(ctx, repo); err != nil {
473 d.logger.Error("error populating last-modified", "repo", repo, "err", err)
474 return err
475 }
476
477 return nil
478 }),
479 )
480}
481
482func (d *SqliteStore) populateLastModified(ctx context.Context, repo string) error {
483 var rr *Repo
484 _rr, err := d.Repository(ctx, repo)
485 if err != nil {
486 return err
487 }
488
489 if r, ok := _rr.(*Repo); ok {
490 rr = r
491 } else {
492 return ErrRepoNotExist
493 }
494
495 r, err := rr.Open()
496 if err != nil {
497 return err
498 }
499
500 c, err := r.LatestCommitTime()
501 if err != nil {
502 return err
503 }
504
505 return rr.writeLastModified(c)
506}