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