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