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