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