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