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