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