repo.go

  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 //nolint:wrapcheck
 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 //nolint:wrapcheck
 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 //nolint:wrapcheck
 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 //nolint:wrapcheck
 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 //nolint:wrapcheck
 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 //nolint:wrapcheck
 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 //nolint:wrapcheck
 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 //nolint:wrapcheck
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 //nolint:wrapcheck
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 //nolint:wrapcheck
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 //nolint:wrapcheck
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 //nolint:wrapcheck
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) //nolint:wrapcheck
277	}
278
279	return webhook.SendEvent(ctx, wh) //nolint:wrapcheck
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 //nolint:wrapcheck
288		}
289
290		repos, err := d.store.GetUserRepos(ctx, tx, user.ID)
291		if err != nil {
292			return err //nolint:wrapcheck
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) //nolint:wrapcheck
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 //nolint:wrapcheck
316	}
317
318	newName = utils.SanitizeRepo(newName)
319	if err := utils.ValidateRepo(newName); err != nil {
320		return err //nolint:wrapcheck
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 //nolint:wrapcheck
343		}
344
345		// Make sure the new repository parent directory exists.
346		if err := os.MkdirAll(filepath.Dir(np), os.ModePerm); err != nil { //nolint:gosec
347			return err //nolint:wrapcheck
348		}
349
350		return os.Rename(op, np)
351	}); err != nil {
352		return db.WrapError(err) //nolint:wrapcheck
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 //nolint:wrapcheck
364	}
365
366	return webhook.SendEvent(ctx, wh) //nolint:wrapcheck
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 //nolint:wrapcheck
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) //nolint:wrapcheck
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) //nolint:wrapcheck
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 //nolint:wrapcheck
454	}); err != nil {
455		return "", db.WrapError(err) //nolint:wrapcheck
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 //nolint:wrapcheck
471	}); err != nil {
472		return false, db.WrapError(err) //nolint:wrapcheck
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 //nolint:wrapcheck
487	}); err != nil {
488		return false, db.WrapError(err) //nolint:wrapcheck
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 //nolint:wrapcheck
504	}); err != nil {
505		return false, db.WrapError(err) //nolint:wrapcheck
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 //nolint:wrapcheck
521	}); err != nil {
522		return "", db.WrapError(err) //nolint:wrapcheck
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 { //nolint:wrapcheck
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 { //nolint:wrapcheck
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 //nolint:wrapcheck
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 //nolint:wrapcheck
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 //nolint:wrapcheck
585					}
586				}
587			}
588
589			return d.store.SetRepoIsPrivateByName(ctx, tx, name, private)
590		}),
591	); err != nil {
592		return err //nolint:wrapcheck
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 //nolint:wrapcheck
605		}
606
607		if err := webhook.SendEvent(ctx, wh); err != nil {
608			return err //nolint:wrapcheck
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( //nolint:wrapcheck
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) //nolint:wrapcheck
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 { //nolint:gosec
742		return err //nolint:wrapcheck
743	}
744
745	return os.WriteFile(fp, []byte(t.Format(time.RFC3339)), os.ModePerm) //nolint:gosec,wrapcheck
746}
747
748func readOneline(path string) (string, error) {
749	f, err := os.Open(path)
750	if err != nil {
751		return "", err //nolint:wrapcheck
752	}
753
754	defer f.Close() //nolint: errcheck
755	s := bufio.NewScanner(f)
756	s.Scan()
757	return s.Text(), s.Err()
758}