1package backend
2
3import (
4 "bufio"
5 "context"
6 "errors"
7 "fmt"
8 "io/fs"
9 "os"
10 "path"
11 "path/filepath"
12 "strconv"
13 "time"
14
15 "github.com/charmbracelet/soft-serve/git"
16 "github.com/charmbracelet/soft-serve/server/db"
17 "github.com/charmbracelet/soft-serve/server/db/models"
18 "github.com/charmbracelet/soft-serve/server/hooks"
19 "github.com/charmbracelet/soft-serve/server/lfs"
20 "github.com/charmbracelet/soft-serve/server/proto"
21 "github.com/charmbracelet/soft-serve/server/storage"
22 "github.com/charmbracelet/soft-serve/server/utils"
23)
24
25func (d *Backend) reposPath() string {
26 return filepath.Join(d.cfg.DataPath, "repos")
27}
28
29// CreateRepository creates a new repository.
30//
31// It implements backend.Backend.
32func (d *Backend) CreateRepository(ctx context.Context, name string, user proto.User, opts proto.RepositoryOptions) (proto.Repository, error) {
33 name = utils.SanitizeRepo(name)
34 if err := utils.ValidateRepo(name); err != nil {
35 return nil, err
36 }
37
38 repo := name + ".git"
39 rp := filepath.Join(d.reposPath(), repo)
40
41 var userID int64
42 if user != nil {
43 userID = user.ID()
44 }
45
46 if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
47 if err := d.store.CreateRepo(
48 ctx,
49 tx,
50 name,
51 userID,
52 opts.ProjectName,
53 opts.Description,
54 opts.Private,
55 opts.Hidden,
56 opts.Mirror,
57 ); err != nil {
58 return err
59 }
60
61 _, err := git.Init(rp, true)
62 if err != nil {
63 d.logger.Debug("failed to create repository", "err", err)
64 return err
65 }
66
67 if err := os.WriteFile(filepath.Join(rp, "description"), []byte(opts.Description), fs.ModePerm); err != nil {
68 d.logger.Error("failed to write description", "repo", name, "err", err)
69 return err
70 }
71
72 if !opts.Private {
73 if err := os.WriteFile(filepath.Join(rp, "git-daemon-export-ok"), []byte{}, fs.ModePerm); err != nil {
74 d.logger.Error("failed to write git-daemon-export-ok", "repo", name, "err", err)
75 return err
76 }
77 }
78
79 return hooks.GenerateHooks(ctx, d.cfg, repo)
80 }); err != nil {
81 d.logger.Debug("failed to create repository in database", "err", err)
82 err = db.WrapError(err)
83 if errors.Is(err, db.ErrDuplicateKey) {
84 return nil, proto.ErrRepoExist
85 }
86
87 return nil, err
88 }
89
90 return d.Repository(ctx, name)
91}
92
93// ImportRepository imports a repository from remote.
94func (d *Backend) ImportRepository(ctx context.Context, name string, user proto.User, remote string, opts proto.RepositoryOptions) (proto.Repository, error) {
95 name = utils.SanitizeRepo(name)
96 if err := utils.ValidateRepo(name); err != nil {
97 return nil, err
98 }
99
100 repo := name + ".git"
101 rp := filepath.Join(d.reposPath(), repo)
102
103 if _, err := os.Stat(rp); err == nil || os.IsExist(err) {
104 return nil, proto.ErrRepoExist
105 }
106
107 copts := git.CloneOptions{
108 Bare: true,
109 Mirror: opts.Mirror,
110 Quiet: true,
111 CommandOptions: git.CommandOptions{
112 Timeout: -1,
113 Context: ctx,
114 Envs: []string{
115 fmt.Sprintf(`GIT_SSH_COMMAND=ssh -o UserKnownHostsFile="%s" -o StrictHostKeyChecking=no -i "%s"`,
116 filepath.Join(d.cfg.DataPath, "ssh", "known_hosts"),
117 d.cfg.SSH.ClientKeyPath,
118 ),
119 },
120 },
121 }
122
123 if err := git.Clone(remote, rp, copts); err != nil {
124 d.logger.Error("failed to clone repository", "err", err, "mirror", opts.Mirror, "remote", remote, "path", rp)
125 // Cleanup the mess!
126 if rerr := os.RemoveAll(rp); rerr != nil {
127 err = errors.Join(err, rerr)
128 }
129
130 return nil, err
131 }
132
133 r, err := d.CreateRepository(ctx, name, user, opts)
134 if err != nil {
135 d.logger.Error("failed to create repository", "err", err, "name", name)
136 return nil, err
137 }
138
139 defer func() {
140 if err != nil {
141 if rerr := d.DeleteRepository(ctx, name); rerr != nil {
142 d.logger.Error("failed to delete repository", "err", rerr, "name", name)
143 }
144 }
145 }()
146
147 rr, err := r.Open()
148 if err != nil {
149 d.logger.Error("failed to open repository", "err", err, "path", rp)
150 return nil, err
151 }
152
153 rcfg, err := rr.Config()
154 if err != nil {
155 d.logger.Error("failed to get repository config", "err", err, "path", rp)
156 return nil, err
157 }
158
159 endpoint := remote
160 if opts.LFSEndpoint != "" {
161 endpoint = opts.LFSEndpoint
162 }
163
164 rcfg.Section("lfs").SetOption("url", endpoint)
165
166 if err := rr.SetConfig(rcfg); err != nil {
167 d.logger.Error("failed to set repository config", "err", err, "path", rp)
168 return nil, err
169 }
170
171 ep, err := lfs.NewEndpoint(endpoint)
172 if err != nil {
173 d.logger.Error("failed to create lfs endpoint", "err", err, "path", rp)
174 return nil, err
175 }
176
177 client := lfs.NewClient(ep)
178 if client == nil {
179 return nil, fmt.Errorf("failed to create lfs client: unsupported endpoint %s", endpoint)
180 }
181
182 if err := StoreRepoMissingLFSObjects(ctx, r, d.db, d.store, client); err != nil {
183 d.logger.Error("failed to store missing lfs objects", "err", err, "path", rp)
184 return nil, err
185 }
186
187 return r, nil
188}
189
190// DeleteRepository deletes a repository.
191//
192// It implements backend.Backend.
193func (d *Backend) DeleteRepository(ctx context.Context, name string) error {
194 name = utils.SanitizeRepo(name)
195 repo := name + ".git"
196 rp := filepath.Join(d.reposPath(), repo)
197
198 err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
199 // Delete repo from cache
200 defer d.cache.Delete(name)
201
202 repom, dberr := d.store.GetRepoByName(ctx, tx, name)
203 _, ferr := os.Stat(rp)
204 if dberr != nil && ferr != nil {
205 return proto.ErrRepoNotFound
206 }
207
208 // If the repo is not in the database but the directory exists, remove it
209 if dberr != nil && ferr == nil {
210 return os.RemoveAll(rp)
211 } else if dberr != nil {
212 return db.WrapError(dberr)
213 }
214
215 repoID := strconv.FormatInt(repom.ID, 10)
216 strg := storage.NewLocalStorage(filepath.Join(d.cfg.DataPath, "lfs", repoID))
217 objs, err := d.store.GetLFSObjectsByName(ctx, tx, name)
218 if err != nil {
219 return db.WrapError(err)
220 }
221
222 for _, obj := range objs {
223 p := lfs.Pointer{
224 Oid: obj.Oid,
225 Size: obj.Size,
226 }
227
228 d.logger.Debug("deleting lfs object", "repo", name, "oid", obj.Oid)
229 if err := strg.Delete(path.Join("objects", p.RelativePath())); err != nil {
230 d.logger.Error("failed to delete lfs object", "repo", name, "err", err, "oid", obj.Oid)
231 }
232 }
233
234 if err := d.store.DeleteRepoByName(ctx, tx, name); err != nil {
235 return db.WrapError(err)
236 }
237
238 return os.RemoveAll(rp)
239 })
240 if errors.Is(err, db.ErrRecordNotFound) {
241 return proto.ErrRepoNotFound
242 }
243
244 return err
245}
246
247// DeleteUserRepositories deletes all user repositories.
248func (d *Backend) DeleteUserRepositories(ctx context.Context, username string) error {
249 return d.db.TransactionContext(ctx, func(tx *db.Tx) error {
250 user, err := d.store.FindUserByUsername(ctx, tx, username)
251 if err != nil {
252 return err
253 }
254
255 repos, err := d.store.GetUserRepos(ctx, tx, user.ID)
256 if err != nil {
257 return err
258 }
259
260 for _, repo := range repos {
261 if err := d.DeleteRepository(ctx, repo.Name); err != nil {
262 return err
263 }
264 }
265
266 return nil
267 })
268}
269
270// RenameRepository renames a repository.
271//
272// It implements backend.Backend.
273func (d *Backend) RenameRepository(ctx context.Context, oldName string, newName string) error {
274 oldName = utils.SanitizeRepo(oldName)
275 if err := utils.ValidateRepo(oldName); err != nil {
276 return err
277 }
278
279 newName = utils.SanitizeRepo(newName)
280 if err := utils.ValidateRepo(newName); err != nil {
281 return err
282 }
283 oldRepo := oldName + ".git"
284 newRepo := newName + ".git"
285 op := filepath.Join(d.reposPath(), oldRepo)
286 np := filepath.Join(d.reposPath(), newRepo)
287 if _, err := os.Stat(op); err != nil {
288 return proto.ErrRepoNotFound
289 }
290
291 if _, err := os.Stat(np); err == nil {
292 return proto.ErrRepoExist
293 }
294
295 if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
296 // Delete cache
297 defer d.cache.Delete(oldName)
298
299 if err := d.store.SetRepoNameByName(ctx, tx, oldName, newName); err != nil {
300 return err
301 }
302
303 // Make sure the new repository parent directory exists.
304 if err := os.MkdirAll(filepath.Dir(np), os.ModePerm); err != nil {
305 return err
306 }
307
308 return os.Rename(op, np)
309 }); err != nil {
310 return db.WrapError(err)
311 }
312
313 return nil
314}
315
316// Repositories returns a list of repositories per page.
317//
318// It implements backend.Backend.
319func (d *Backend) Repositories(ctx context.Context) ([]proto.Repository, error) {
320 repos := make([]proto.Repository, 0)
321
322 if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
323 ms, err := d.store.GetAllRepos(ctx, tx)
324 if err != nil {
325 return err
326 }
327
328 for _, m := range ms {
329 r := &repo{
330 name: m.Name,
331 path: filepath.Join(d.reposPath(), m.Name+".git"),
332 repo: m,
333 }
334
335 // Cache repositories
336 d.cache.Set(m.Name, r)
337
338 repos = append(repos, r)
339 }
340
341 return nil
342 }); err != nil {
343 return nil, db.WrapError(err)
344 }
345
346 return repos, nil
347}
348
349// Repository returns a repository by name.
350//
351// It implements backend.Backend.
352func (d *Backend) Repository(ctx context.Context, name string) (proto.Repository, error) {
353 var m models.Repo
354 name = utils.SanitizeRepo(name)
355
356 if r, ok := d.cache.Get(name); ok && r != nil {
357 return r, nil
358 }
359
360 rp := filepath.Join(d.reposPath(), name+".git")
361 if _, err := os.Stat(rp); err != nil {
362 if !errors.Is(err, fs.ErrNotExist) {
363 d.logger.Errorf("failed to stat repository path: %v", err)
364 }
365 return nil, proto.ErrRepoNotFound
366 }
367
368 if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
369 var err error
370 m, err = d.store.GetRepoByName(ctx, tx, name)
371 return db.WrapError(err)
372 }); err != nil {
373 if errors.Is(err, db.ErrRecordNotFound) {
374 return nil, proto.ErrRepoNotFound
375 }
376 return nil, db.WrapError(err)
377 }
378
379 r := &repo{
380 name: name,
381 path: rp,
382 repo: m,
383 }
384
385 // Add to cache
386 d.cache.Set(name, r)
387
388 return r, nil
389}
390
391// Description returns the description of a repository.
392//
393// It implements backend.Backend.
394func (d *Backend) Description(ctx context.Context, name string) (string, error) {
395 name = utils.SanitizeRepo(name)
396 var desc string
397 if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
398 var err error
399 desc, err = d.store.GetRepoDescriptionByName(ctx, tx, name)
400 return err
401 }); err != nil {
402 return "", db.WrapError(err)
403 }
404
405 return desc, nil
406}
407
408// IsMirror returns true if the repository is a mirror.
409//
410// It implements backend.Backend.
411func (d *Backend) IsMirror(ctx context.Context, name string) (bool, error) {
412 name = utils.SanitizeRepo(name)
413 var mirror bool
414 if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
415 var err error
416 mirror, err = d.store.GetRepoIsMirrorByName(ctx, tx, name)
417 return err
418 }); err != nil {
419 return false, db.WrapError(err)
420 }
421 return mirror, nil
422}
423
424// IsPrivate returns true if the repository is private.
425//
426// It implements backend.Backend.
427func (d *Backend) IsPrivate(ctx context.Context, name string) (bool, error) {
428 name = utils.SanitizeRepo(name)
429 var private bool
430 if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
431 var err error
432 private, err = d.store.GetRepoIsPrivateByName(ctx, tx, name)
433 return err
434 }); err != nil {
435 return false, db.WrapError(err)
436 }
437
438 return private, nil
439}
440
441// IsHidden returns true if the repository is hidden.
442//
443// It implements backend.Backend.
444func (d *Backend) IsHidden(ctx context.Context, name string) (bool, error) {
445 name = utils.SanitizeRepo(name)
446 var hidden bool
447 if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
448 var err error
449 hidden, err = d.store.GetRepoIsHiddenByName(ctx, tx, name)
450 return err
451 }); err != nil {
452 return false, db.WrapError(err)
453 }
454
455 return hidden, nil
456}
457
458// ProjectName returns the project name of a repository.
459//
460// It implements backend.Backend.
461func (d *Backend) ProjectName(ctx context.Context, name string) (string, error) {
462 name = utils.SanitizeRepo(name)
463 var pname string
464 if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
465 var err error
466 pname, err = d.store.GetRepoProjectNameByName(ctx, tx, name)
467 return err
468 }); err != nil {
469 return "", db.WrapError(err)
470 }
471
472 return pname, nil
473}
474
475// SetHidden sets the hidden flag of a repository.
476//
477// It implements backend.Backend.
478func (d *Backend) SetHidden(ctx context.Context, name string, hidden bool) error {
479 name = utils.SanitizeRepo(name)
480
481 // Delete cache
482 d.cache.Delete(name)
483
484 return db.WrapError(d.db.TransactionContext(ctx, func(tx *db.Tx) error {
485 return d.store.SetRepoIsHiddenByName(ctx, tx, name, hidden)
486 }))
487}
488
489// SetDescription sets the description of a repository.
490//
491// It implements backend.Backend.
492func (d *Backend) SetDescription(ctx context.Context, name string, desc string) error {
493 name = utils.SanitizeRepo(name)
494 rp := filepath.Join(d.reposPath(), name+".git")
495
496 // Delete cache
497 d.cache.Delete(name)
498
499 return d.db.TransactionContext(ctx, func(tx *db.Tx) error {
500 if err := os.WriteFile(filepath.Join(rp, "description"), []byte(desc), fs.ModePerm); err != nil {
501 d.logger.Error("failed to write description", "repo", name, "err", err)
502 return err
503 }
504
505 return d.store.SetRepoDescriptionByName(ctx, tx, name, desc)
506 })
507}
508
509// SetPrivate sets the private flag of a repository.
510//
511// It implements backend.Backend.
512func (d *Backend) SetPrivate(ctx context.Context, name string, private bool) error {
513 name = utils.SanitizeRepo(name)
514 rp := filepath.Join(d.reposPath(), name+".git")
515
516 // Delete cache
517 d.cache.Delete(name)
518
519 return db.WrapError(
520 d.db.TransactionContext(ctx, func(tx *db.Tx) error {
521 fp := filepath.Join(rp, "git-daemon-export-ok")
522 if !private {
523 if err := os.WriteFile(fp, []byte{}, fs.ModePerm); err != nil {
524 d.logger.Error("failed to write git-daemon-export-ok", "repo", name, "err", err)
525 return err
526 }
527 } else {
528 if _, err := os.Stat(fp); err == nil {
529 if err := os.Remove(fp); err != nil {
530 d.logger.Error("failed to remove git-daemon-export-ok", "repo", name, "err", err)
531 return err
532 }
533 }
534 }
535
536 return d.store.SetRepoIsPrivateByName(ctx, tx, name, private)
537 }),
538 )
539}
540
541// SetProjectName sets the project name of a repository.
542//
543// It implements backend.Backend.
544func (d *Backend) SetProjectName(ctx context.Context, repo string, name string) error {
545 repo = utils.SanitizeRepo(repo)
546
547 // Delete cache
548 d.cache.Delete(repo)
549
550 return db.WrapError(
551 d.db.TransactionContext(ctx, func(tx *db.Tx) error {
552 return d.store.SetRepoProjectNameByName(ctx, tx, repo, name)
553 }),
554 )
555}
556
557var _ proto.Repository = (*repo)(nil)
558
559// repo is a Git repository with metadata stored in a SQLite database.
560type repo struct {
561 name string
562 path string
563 repo models.Repo
564}
565
566// ID returns the repository's ID.
567//
568// It implements proto.Repository.
569func (r *repo) ID() int64 {
570 return r.repo.ID
571}
572
573// UserID returns the repository's owner's user ID.
574// If the repository is not owned by anyone, it returns 0.
575//
576// It implements proto.Repository.
577func (r *repo) UserID() int64 {
578 if r.repo.UserID.Valid {
579 return r.repo.UserID.Int64
580 }
581 return 0
582}
583
584// Description returns the repository's description.
585//
586// It implements backend.Repository.
587func (r *repo) Description() string {
588 return r.repo.Description
589}
590
591// IsMirror returns whether the repository is a mirror.
592//
593// It implements backend.Repository.
594func (r *repo) IsMirror() bool {
595 return r.repo.Mirror
596}
597
598// IsPrivate returns whether the repository is private.
599//
600// It implements backend.Repository.
601func (r *repo) IsPrivate() bool {
602 return r.repo.Private
603}
604
605// Name returns the repository's name.
606//
607// It implements backend.Repository.
608func (r *repo) Name() string {
609 return r.name
610}
611
612// Open opens the repository.
613//
614// It implements backend.Repository.
615func (r *repo) Open() (*git.Repository, error) {
616 return git.Open(r.path)
617}
618
619// ProjectName returns the repository's project name.
620//
621// It implements backend.Repository.
622func (r *repo) ProjectName() string {
623 return r.repo.ProjectName
624}
625
626// IsHidden returns whether the repository is hidden.
627//
628// It implements backend.Repository.
629func (r *repo) IsHidden() bool {
630 return r.repo.Hidden
631}
632
633// UpdatedAt returns the repository's last update time.
634func (r *repo) UpdatedAt() time.Time {
635 // Try to read the last modified time from the info directory.
636 if t, err := readOneline(filepath.Join(r.path, "info", "last-modified")); err == nil {
637 if t, err := time.Parse(time.RFC3339, t); err == nil {
638 return t
639 }
640 }
641
642 rr, err := git.Open(r.path)
643 if err == nil {
644 t, err := rr.LatestCommitTime()
645 if err == nil {
646 return t
647 }
648 }
649
650 return r.repo.UpdatedAt
651}
652
653func (r *repo) writeLastModified(t time.Time) error {
654 fp := filepath.Join(r.path, "info", "last-modified")
655 if err := os.MkdirAll(filepath.Dir(fp), os.ModePerm); err != nil {
656 return err
657 }
658
659 return os.WriteFile(fp, []byte(t.Format(time.RFC3339)), os.ModePerm)
660}
661
662func readOneline(path string) (string, error) {
663 f, err := os.Open(path)
664 if err != nil {
665 return "", err
666 }
667
668 defer f.Close() // nolint: errcheck
669 s := bufio.NewScanner(f)
670 s.Scan()
671 return s.Text(), s.Err()
672}