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