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