Parent directory

repo.go

23118 bytes
  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/ssrf"
 23	"github.com/charmbracelet/soft-serve/pkg/storage"
 24	"github.com/charmbracelet/soft-serve/pkg/task"
 25	"github.com/charmbracelet/soft-serve/pkg/utils"
 26	"github.com/charmbracelet/soft-serve/pkg/webhook"
 27)
 28
 29// validateImportRemote validates an import remote against private, internal,
 30// and loopback ranges, and returns the git environment that must be applied to
 31// the clone subprocess for that validation to hold.
 32func validateImportRemote(remote string) ([]string, error) {
 33	endpoint, err := lfs.NewEndpoint(remote)
 34	if err != nil || endpoint.Host == "" {
 35		return nil, proto.ErrInvalidRemote
 36	}
 37
 38	// Validate the remote as given rather than the LFS endpoint, which has
 39	// already had its scheme rewritten and its path rewritten to /info/lfs.
 40	v, err := ssrf.ValidateGitRemote(remote)
 41	if err != nil {
 42		return nil, fmt.Errorf("%w: %w", proto.ErrInvalidRemote, err)
 43	}
 44
 45	return ssrf.GitEnv(v), nil
 46}
 47
 48// CreateRepository creates a new repository.
 49//
 50// It implements backend.Backend.
 51func (d *Backend) CreateRepository(ctx context.Context, name string, user proto.User, opts proto.RepositoryOptions) (proto.Repository, error) {
 52	name = utils.SanitizeRepo(name)
 53	if err := utils.ValidateRepo(name); err != nil {
 54		return nil, err
 55	}
 56
 57	rp := filepath.Join(d.repoPath(name))
 58
 59	var userID int64
 60	if user != nil {
 61		userID = user.ID()
 62	}
 63
 64	if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
 65		if userID == 0 {
 66			// Anonymous callers (e.g. an anon-access/allow-keyless
 67			// override) have no user of their own, but repos.user_id is
 68			// NOT NULL. Fall back to the default admin as owner, the same
 69			// "ownerless repos belong to the first admin" idiom already
 70			// used when migrating repos from the pre-user schema (see
 71			// 0001_create_tables.go).
 72			adminID, err := d.defaultAdminUserID(ctx, tx)
 73			if err != nil {
 74				return err
 75			}
 76			userID = adminID
 77		}
 78
 79		if err := d.store.CreateRepo(
 80			ctx,
 81			tx,
 82			name,
 83			userID,
 84			opts.ProjectName,
 85			opts.Description,
 86			opts.Private,
 87			opts.Hidden,
 88			opts.Mirror,
 89		); err != nil {
 90			return err
 91		}
 92
 93		_, err := git.Init(rp, true)
 94		if err != nil {
 95			d.logger.Debug("failed to create repository", "err", err)
 96			return err
 97		}
 98
 99		if err := os.WriteFile(filepath.Join(rp, "description"), []byte(opts.Description), fs.ModePerm); err != nil {
100			d.logger.Error("failed to write description", "repo", name, "err", err)
101			return err
102		}
103
104		if !opts.Private {
105			if err := os.WriteFile(filepath.Join(rp, "git-daemon-export-ok"), []byte{}, fs.ModePerm); err != nil {
106				d.logger.Error("failed to write git-daemon-export-ok", "repo", name, "err", err)
107				return err
108			}
109		}
110
111		return hooks.GenerateHooks(ctx, d.cfg, name)
112	}); err != nil {
113		d.logger.Debug("failed to create repository in database", "err", err)
114		err = db.WrapError(err)
115		if errors.Is(err, db.ErrDuplicateKey) {
116			return nil, proto.ErrRepoExist
117		}
118
119		return nil, err
120	}
121
122	return d.Repository(ctx, name)
123}
124
125// defaultAdminUserID returns the ID of the lowest-ID admin user, used to
126// own repositories created by callers with no user of their own (anonymous
127// access via an anon-access/allow-keyless override). The migration that
128// seeds the initial database always creates an admin user, so this should
129// never fail to find one in practice.
130func (d *Backend) defaultAdminUserID(ctx context.Context, tx *db.Tx) (int64, error) {
131	users, err := d.store.GetAllUsers(ctx, tx)
132	if err != nil {
133		return 0, err
134	}
135
136	var adminID int64
137	for _, u := range users {
138		if u.Admin && (adminID == 0 || u.ID < adminID) {
139			adminID = u.ID
140		}
141	}
142
143	if adminID == 0 {
144		return 0, errors.New("no admin user found to own anonymous repository")
145	}
146
147	return adminID, nil
148}
149
150// ImportRepository imports a repository from remote.
151// XXX: This a expensive operation and should be run in a goroutine.
152func (d *Backend) ImportRepository(_ context.Context, name string, user proto.User, remote string, opts proto.RepositoryOptions) (proto.Repository, error) {
153	name = utils.SanitizeRepo(name)
154	if err := utils.ValidateRepo(name); err != nil {
155		return nil, err
156	}
157
158	remote = utils.Sanitize(remote)
159	remoteEnv, err := validateImportRemote(remote)
160	if err != nil {
161		return nil, err
162	}
163
164	// The LFS endpoint is user-supplied and gets persisted to the repo config,
165	// where the mirror job later reads it. Validate it up front rather than
166	// relying on the LFS client to refuse the dial.
167	if opts.LFSEndpoint != "" {
168		if _, err := ssrf.ValidateGitRemote(opts.LFSEndpoint); err != nil {
169			return nil, fmt.Errorf("%w: %w", proto.ErrInvalidRemote, err)
170		}
171	}
172
173	rp := filepath.Join(d.repoPath(name))
174
175	tid := "import:" + name
176	if d.manager.Exists(tid) {
177		return nil, task.ErrAlreadyStarted
178	}
179
180	if _, err := os.Stat(rp); err == nil || os.IsExist(err) {
181		return nil, proto.ErrRepoExist
182	}
183
184	done := make(chan error, 1)
185	repoc := make(chan proto.Repository, 1)
186	d.logger.Info("importing repository", "name", name, "remote", remote, "path", rp)
187	d.manager.Add(tid, func(ctx context.Context) (err error) {
188		ctx = proto.WithUserContext(ctx, user)
189
190		// remoteEnv carries the SSRF guard: it disables redirect following
191		// and pins the validated address. Without it the clone can reach a
192		// different host than the one validated above.
193		cloneEnv := append(remoteEnv,
194			fmt.Sprintf(`GIT_SSH_COMMAND=ssh -o UserKnownHostsFile="%s" -o StrictHostKeyChecking=no -i "%s"`,
195				filepath.Join(d.cfg.DataPath, "ssh", "known_hosts"),
196				d.cfg.SSH.ClientKeyPath,
197			),
198		)
199
200		copts := git.CloneOptions{
201			Bare:   true,
202			Mirror: opts.Mirror,
203			Quiet:  true,
204			CommandOptions: git.CommandOptions{
205				Timeout: -1,
206				Context: ctx,
207				Envs:    cloneEnv,
208			},
209		}
210
211		if err := git.Clone(remote, rp, copts); err != nil {
212			d.logger.Error("failed to clone repository", "err", err, "mirror", opts.Mirror, "remote", remote, "path", rp)
213			// Cleanup the mess!
214			if rerr := os.RemoveAll(rp); rerr != nil {
215				err = errors.Join(err, rerr)
216			}
217
218			return err
219		}
220
221		r, err := d.CreateRepository(ctx, name, user, opts)
222		if err != nil {
223			d.logger.Error("failed to create repository", "err", err, "name", name)
224			return err
225		}
226
227		defer func() {
228			if err != nil {
229				if rerr := d.DeleteRepository(ctx, name); rerr != nil {
230					d.logger.Error("failed to delete repository", "err", rerr, "name", name)
231				}
232			}
233		}()
234
235		rr, err := r.Open()
236		if err != nil {
237			d.logger.Error("failed to open repository", "err", err, "path", rp)
238			return err
239		}
240
241		repoc <- r
242
243		rcfg, err := rr.Config()
244		if err != nil {
245			d.logger.Error("failed to get repository config", "err", err, "path", rp)
246			return err
247		}
248
249		endpoint := remote
250		if opts.LFSEndpoint != "" {
251			endpoint = opts.LFSEndpoint
252		}
253
254		rcfg.Section("lfs").SetOption("url", endpoint)
255
256		if err := rr.SetConfig(rcfg); err != nil {
257			d.logger.Error("failed to set repository config", "err", err, "path", rp)
258			return err
259		}
260
261		ep, err := lfs.NewEndpoint(endpoint)
262		if err != nil {
263			d.logger.Error("failed to create lfs endpoint", "err", err, "path", rp)
264			return err
265		}
266
267		client := lfs.NewClient(ep)
268		if client == nil {
269			d.logger.Warn("failed to create lfs client: unsupported endpoint", "endpoint", endpoint)
270			return nil
271		}
272
273		if err := StoreRepoMissingLFSObjects(ctx, r, d.db, d.store, client); err != nil {
274			d.logger.Error("failed to store missing lfs objects", "err", err, "path", rp)
275			return err
276		}
277
278		return nil
279	})
280
281	go func() {
282		d.logger.Info("running import", "name", name)
283		d.manager.Run(tid, done)
284	}()
285
286	return <-repoc, <-done
287}
288
289// DeleteRepository deletes a repository.
290//
291// It implements backend.Backend.
292func (d *Backend) DeleteRepository(ctx context.Context, name string) error {
293	name = utils.SanitizeRepo(name)
294	rp := filepath.Join(d.repoPath(name))
295
296	user := proto.UserFromContext(ctx)
297	r, err := d.Repository(ctx, name)
298	if err != nil {
299		return err
300	}
301
302	// We create the webhook event before deleting the repository so we can
303	// send the event after deleting the repository.
304	wh, err := webhook.NewRepositoryEvent(ctx, user, r, webhook.RepositoryEventActionDelete)
305	if err != nil {
306		return err
307	}
308
309	if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
310		// Delete repo from cache
311		defer d.cache.Delete(name)
312
313		repom, dberr := d.store.GetRepoByName(ctx, tx, name)
314		_, ferr := os.Stat(rp)
315		if dberr != nil && ferr != nil {
316			return proto.ErrRepoNotFound
317		}
318
319		// If the repo is not in the database but the directory exists, remove it
320		if dberr != nil && ferr == nil {
321			return os.RemoveAll(rp)
322		} else if dberr != nil {
323			return db.WrapError(dberr)
324		}
325
326		repoID := strconv.FormatInt(repom.ID, 10)
327		strg := storage.NewLocalStorage(filepath.Join(d.cfg.DataPath, "lfs", repoID))
328		objs, err := d.store.GetLFSObjectsByName(ctx, tx, name)
329		if err != nil {
330			return db.WrapError(err)
331		}
332
333		for _, obj := range objs {
334			p := lfs.Pointer{
335				Oid:  obj.Oid,
336				Size: obj.Size,
337			}
338
339			d.logger.Debug("deleting lfs object", "repo", name, "oid", obj.Oid)
340			if err := strg.Delete(path.Join("objects", p.RelativePath())); err != nil {
341				d.logger.Error("failed to delete lfs object", "repo", name, "err", err, "oid", obj.Oid)
342			}
343		}
344
345		if err := d.store.DeleteRepoByName(ctx, tx, name); err != nil {
346			return db.WrapError(err)
347		}
348
349		return os.RemoveAll(rp)
350	}); err != nil {
351		if errors.Is(err, db.ErrRecordNotFound) {
352			return proto.ErrRepoNotFound
353		}
354
355		return db.WrapError(err)
356	}
357
358	return webhook.SendEvent(ctx, wh)
359}
360
361// DeleteUserRepositories deletes all user repositories.
362func (d *Backend) DeleteUserRepositories(ctx context.Context, username string) error {
363	if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
364		user, err := d.store.FindUserByUsername(ctx, tx, username)
365		if err != nil {
366			return err
367		}
368
369		repos, err := d.store.GetUserRepos(ctx, tx, user.ID)
370		if err != nil {
371			return err
372		}
373
374		for _, repo := range repos {
375			if err := d.DeleteRepository(ctx, repo.Name); err != nil {
376				return err
377			}
378		}
379
380		return nil
381	}); err != nil {
382		return db.WrapError(err)
383	}
384
385	return nil
386}
387
388// RenameRepository renames a repository.
389//
390// It implements backend.Backend.
391func (d *Backend) RenameRepository(ctx context.Context, oldName string, newName string) error {
392	oldName = utils.SanitizeRepo(oldName)
393	if err := utils.ValidateRepo(oldName); err != nil {
394		return err
395	}
396
397	newName = utils.SanitizeRepo(newName)
398	if err := utils.ValidateRepo(newName); err != nil {
399		return err
400	}
401
402	if oldName == newName {
403		return nil
404	}
405
406	op := filepath.Join(d.repoPath(oldName))
407	np := filepath.Join(d.repoPath(newName))
408	if _, err := os.Stat(op); err != nil {
409		return proto.ErrRepoNotFound
410	}
411
412	if _, err := os.Stat(np); err == nil {
413		return proto.ErrRepoExist
414	}
415
416	if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
417		// Delete cache
418		defer d.cache.Delete(oldName)
419
420		if err := d.store.SetRepoNameByName(ctx, tx, oldName, newName); err != nil {
421			return err
422		}
423
424		// Make sure the new repository parent directory exists.
425		if err := os.MkdirAll(filepath.Dir(np), os.ModePerm); err != nil {
426			return err
427		}
428
429		return os.Rename(op, np)
430	}); err != nil {
431		return db.WrapError(err)
432	}
433
434	user := proto.UserFromContext(ctx)
435	repo, err := d.Repository(ctx, newName)
436	if err != nil {
437		return err
438	}
439
440	wh, err := webhook.NewRepositoryEvent(ctx, user, repo, webhook.RepositoryEventActionRename)
441	if err != nil {
442		return err
443	}
444
445	return webhook.SendEvent(ctx, wh)
446}
447
448// Repositories returns a list of repositories per page.
449//
450// It implements backend.Backend.
451func (d *Backend) Repositories(ctx context.Context) ([]proto.Repository, error) {
452	repos := make([]proto.Repository, 0)
453
454	if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
455		ms, err := d.store.GetAllRepos(ctx, tx)
456		if err != nil {
457			return err
458		}
459
460		for _, m := range ms {
461			r := &repo{
462				name: m.Name,
463				path: filepath.Join(d.repoPath(m.Name)),
464				repo: m,
465			}
466
467			// Cache repositories
468			d.cache.Set(m.Name, r)
469
470			repos = append(repos, r)
471		}
472
473		return nil
474	}); err != nil {
475		return nil, db.WrapError(err)
476	}
477
478	return repos, nil
479}
480
481// Repository returns a repository by name.
482//
483// It implements backend.Backend.
484func (d *Backend) Repository(ctx context.Context, name string) (proto.Repository, error) {
485	var m models.Repo
486	name = utils.SanitizeRepo(name)
487
488	if r, ok := d.cache.Get(name); ok && r != nil {
489		return r, nil
490	}
491
492	rp := filepath.Join(d.repoPath(name))
493	if _, err := os.Stat(rp); err != nil {
494		if !errors.Is(err, fs.ErrNotExist) {
495			d.logger.Errorf("failed to stat repository path: %v", err)
496		}
497		return nil, proto.ErrRepoNotFound
498	}
499
500	if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
501		var err error
502		m, err = d.store.GetRepoByName(ctx, tx, name)
503		return db.WrapError(err)
504	}); err != nil {
505		if errors.Is(err, db.ErrRecordNotFound) {
506			return nil, proto.ErrRepoNotFound
507		}
508		return nil, db.WrapError(err)
509	}
510
511	r := &repo{
512		name: name,
513		path: rp,
514		repo: m,
515	}
516
517	// Add to cache
518	d.cache.Set(name, r)
519
520	return r, nil
521}
522
523// Description returns the description of a repository.
524//
525// It implements backend.Backend.
526func (d *Backend) Description(ctx context.Context, name string) (string, error) {
527	name = utils.SanitizeRepo(name)
528	var desc string
529	if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
530		var err error
531		desc, err = d.store.GetRepoDescriptionByName(ctx, tx, name)
532		return err
533	}); err != nil {
534		return "", db.WrapError(err)
535	}
536
537	return desc, nil
538}
539
540// IsMirror returns true if the repository is a mirror.
541//
542// It implements backend.Backend.
543func (d *Backend) IsMirror(ctx context.Context, name string) (bool, error) {
544	name = utils.SanitizeRepo(name)
545	var mirror bool
546	if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
547		var err error
548		mirror, err = d.store.GetRepoIsMirrorByName(ctx, tx, name)
549		return err
550	}); err != nil {
551		return false, db.WrapError(err)
552	}
553	return mirror, nil
554}
555
556// IsPrivate returns true if the repository is private.
557//
558// It implements backend.Backend.
559func (d *Backend) IsPrivate(ctx context.Context, name string) (bool, error) {
560	name = utils.SanitizeRepo(name)
561	var private bool
562	if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
563		var err error
564		private, err = d.store.GetRepoIsPrivateByName(ctx, tx, name)
565		return err
566	}); err != nil {
567		return false, db.WrapError(err)
568	}
569
570	return private, nil
571}
572
573// IsHidden returns true if the repository is hidden.
574//
575// It implements backend.Backend.
576func (d *Backend) IsHidden(ctx context.Context, name string) (bool, error) {
577	name = utils.SanitizeRepo(name)
578	var hidden bool
579	if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
580		var err error
581		hidden, err = d.store.GetRepoIsHiddenByName(ctx, tx, name)
582		return err
583	}); err != nil {
584		return false, db.WrapError(err)
585	}
586
587	return hidden, nil
588}
589
590// ProjectName returns the project name of a repository.
591//
592// It implements backend.Backend.
593func (d *Backend) ProjectName(ctx context.Context, name string) (string, error) {
594	name = utils.SanitizeRepo(name)
595	var pname string
596	if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
597		var err error
598		pname, err = d.store.GetRepoProjectNameByName(ctx, tx, name)
599		return err
600	}); err != nil {
601		return "", db.WrapError(err)
602	}
603
604	return pname, nil
605}
606
607// SetHidden sets the hidden flag of a repository.
608//
609// It implements backend.Backend.
610func (d *Backend) SetHidden(ctx context.Context, name string, hidden bool) error {
611	name = utils.SanitizeRepo(name)
612
613	// Delete cache
614	d.cache.Delete(name)
615
616	return db.WrapError(d.db.TransactionContext(ctx, func(tx *db.Tx) error {
617		return d.store.SetRepoIsHiddenByName(ctx, tx, name, hidden)
618	}))
619}
620
621// SetMirror sets the mirror flag of a repository.
622// Note: enabling mirror mode requires the repository to have been imported
623// with a remote URL. Use ImportRepository to create a new mirror.
624func (d *Backend) SetMirror(ctx context.Context, name string, mirror bool) error {
625	name = utils.SanitizeRepo(name)
626	rp := filepath.Join(d.repoPath(name))
627
628	// Delete cache
629	d.cache.Delete(name)
630
631	return db.WrapError(d.db.TransactionContext(ctx, func(tx *db.Tx) error {
632		// Update git config
633		r, err := git.Open(rp)
634		if err != nil {
635			return err
636		}
637
638		rcfg, err := r.Config()
639		if err != nil {
640			return err
641		}
642
643		// Update mirror option for all remotes
644		remoteSection := rcfg.Section("remote")
645		hasRemote := false
646		for _, sub := range remoteSection.Subsections {
647			// Check if this remote has a URL
648			for _, opt := range sub.Options {
649				if opt.Key == "url" && opt.Value != "" {
650					hasRemote = true
651					found := false
652					for i, opt := range sub.Options {
653						if opt.Key == "mirror" {
654							found = true
655							if mirror {
656								sub.Options[i].Value = "true"
657							} else {
658								sub.Options = append(sub.Options[:i], sub.Options[i+1:]...)
659							}
660							break
661						}
662					}
663					if !found && mirror {
664						sub.SetOption("mirror", "true")
665					}
666					break
667				}
668			}
669		}
670
671		if mirror && !hasRemote {
672			return errors.New("cannot enable mirror mode: repository has no remote URL configured")
673		}
674
675		if err := r.SetConfig(rcfg); err != nil {
676			d.logger.Error("failed to set repository config", "err", err, "path", rp)
677			return err
678		}
679
680		return d.store.SetRepoIsMirrorByName(ctx, tx, name, mirror)
681	}))
682}
683
684// SetDescription sets the description of a repository.
685//
686// It implements backend.Backend.
687func (d *Backend) SetDescription(ctx context.Context, name string, desc string) error {
688	name = utils.SanitizeRepo(name)
689	desc = utils.Sanitize(desc)
690	rp := filepath.Join(d.repoPath(name))
691
692	// Delete cache
693	d.cache.Delete(name)
694
695	return d.db.TransactionContext(ctx, func(tx *db.Tx) error {
696		if err := os.WriteFile(filepath.Join(rp, "description"), []byte(desc), fs.ModePerm); err != nil {
697			d.logger.Error("failed to write description", "repo", name, "err", err)
698			return err
699		}
700
701		return d.store.SetRepoDescriptionByName(ctx, tx, name, desc)
702	})
703}
704
705// SetPrivate sets the private flag of a repository.
706//
707// It implements backend.Backend.
708func (d *Backend) SetPrivate(ctx context.Context, name string, private bool) error {
709	name = utils.SanitizeRepo(name)
710	rp := filepath.Join(d.repoPath(name))
711
712	// Delete cache
713	d.cache.Delete(name)
714
715	if err := db.WrapError(
716		d.db.TransactionContext(ctx, func(tx *db.Tx) error {
717			fp := filepath.Join(rp, "git-daemon-export-ok")
718			if !private {
719				if err := os.WriteFile(fp, []byte{}, fs.ModePerm); err != nil {
720					d.logger.Error("failed to write git-daemon-export-ok", "repo", name, "err", err)
721					return err
722				}
723			} else {
724				if _, err := os.Stat(fp); err == nil {
725					if err := os.Remove(fp); err != nil {
726						d.logger.Error("failed to remove git-daemon-export-ok", "repo", name, "err", err)
727						return err
728					}
729				}
730			}
731
732			return d.store.SetRepoIsPrivateByName(ctx, tx, name, private)
733		}),
734	); err != nil {
735		return err
736	}
737
738	user := proto.UserFromContext(ctx)
739	repo, err := d.Repository(ctx, name)
740	if err != nil {
741		return err
742	}
743
744	if repo.IsPrivate() != !private {
745		wh, err := webhook.NewRepositoryEvent(ctx, user, repo, webhook.RepositoryEventActionVisibilityChange)
746		if err != nil {
747			return err
748		}
749
750		if err := webhook.SendEvent(ctx, wh); err != nil {
751			return err
752		}
753	}
754
755	return nil
756}
757
758// SetProjectName sets the project name of a repository.
759//
760// It implements backend.Backend.
761func (d *Backend) SetProjectName(ctx context.Context, repo string, name string) error {
762	repo = utils.SanitizeRepo(repo)
763	name = utils.Sanitize(name)
764
765	// Delete cache
766	d.cache.Delete(repo)
767
768	return db.WrapError(
769		d.db.TransactionContext(ctx, func(tx *db.Tx) error {
770			return d.store.SetRepoProjectNameByName(ctx, tx, repo, name)
771		}),
772	)
773}
774
775// repoPath returns the path to a repository.
776func (d *Backend) repoPath(name string) string {
777	name = utils.SanitizeRepo(name)
778	rn := strings.ReplaceAll(name, "/", string(os.PathSeparator))
779	return filepath.Join(filepath.Join(d.cfg.DataPath, "repos"), rn+".git")
780}
781
782var _ proto.Repository = (*repo)(nil)
783
784// repo is a Git repository with metadata stored in a SQLite database.
785type repo struct {
786	name string
787	path string
788	repo models.Repo
789}
790
791// ID returns the repository's ID.
792//
793// It implements proto.Repository.
794func (r *repo) ID() int64 {
795	return r.repo.ID
796}
797
798// UserID returns the repository's owner's user ID.
799// If the repository is not owned by anyone, it returns 0.
800//
801// It implements proto.Repository.
802func (r *repo) UserID() int64 {
803	if r.repo.UserID.Valid {
804		return r.repo.UserID.Int64
805	}
806	return 0
807}
808
809// Description returns the repository's description.
810//
811// It implements backend.Repository.
812func (r *repo) Description() string {
813	return r.repo.Description
814}
815
816// IsMirror returns whether the repository is a mirror.
817//
818// It implements backend.Repository.
819func (r *repo) IsMirror() bool {
820	return r.repo.Mirror
821}
822
823// IsPrivate returns whether the repository is private.
824//
825// It implements backend.Repository.
826func (r *repo) IsPrivate() bool {
827	return r.repo.Private
828}
829
830// Name returns the repository's name.
831//
832// It implements backend.Repository.
833func (r *repo) Name() string {
834	return r.name
835}
836
837// Open opens the repository.
838//
839// It implements backend.Repository.
840func (r *repo) Open() (*git.Repository, error) {
841	return git.Open(r.path)
842}
843
844// ProjectName returns the repository's project name.
845//
846// It implements backend.Repository.
847func (r *repo) ProjectName() string {
848	return r.repo.ProjectName
849}
850
851// IsHidden returns whether the repository is hidden.
852//
853// It implements backend.Repository.
854func (r *repo) IsHidden() bool {
855	return r.repo.Hidden
856}
857
858// CreatedAt returns the repository's creation time.
859func (r *repo) CreatedAt() time.Time {
860	return r.repo.CreatedAt
861}
862
863// UpdatedAt returns the repository's last update time.
864func (r *repo) UpdatedAt() time.Time {
865	// Try to read the last modified time from the info directory.
866	if t, err := readOneline(filepath.Join(r.path, "info", "last-modified")); err == nil {
867		if t, err := time.Parse(time.RFC3339, t); err == nil {
868			return t
869		}
870	}
871
872	rr, err := git.Open(r.path)
873	if err == nil {
874		t, err := rr.LatestCommitTime()
875		if err == nil {
876			return t
877		}
878	}
879
880	return r.repo.UpdatedAt
881}
882
883func (r *repo) writeLastModified(t time.Time) error {
884	fp := filepath.Join(r.path, "info", "last-modified")
885	if err := os.MkdirAll(filepath.Dir(fp), os.ModePerm); err != nil {
886		return err
887	}
888
889	return os.WriteFile(fp, []byte(t.Format(time.RFC3339)), os.ModePerm) //nolint:gosec
890}
891
892func readOneline(path string) (string, error) {
893	f, err := os.Open(path)
894	if err != nil {
895		return "", err
896	}
897
898	defer f.Close() //nolint: errcheck
899	s := bufio.NewScanner(f)
900	s.Scan()
901	return s.Text(), s.Err()
902}