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