1package backend23import (4 "bufio"5 "context"6 "errors"7 "fmt"8 "io/fs"9 "os"10 "path"11 "path/filepath"12 "strconv"13 "strings"14 "time"1516 "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)2728func validateImportRemote(remote string) error {29 endpoint, err := lfs.NewEndpoint(remote)30 if err != nil || endpoint.Host == "" {31 return proto.ErrInvalidRemote32 }3334 return nil35}3637// CreateRepository creates a new repository.38//39// It implements backend.Backend.40func (d *Backend) CreateRepository(ctx context.Context, name string, user proto.User, opts proto.RepositoryOptions) (proto.Repository, error) {41 name = utils.SanitizeRepo(name)42 if err := utils.ValidateRepo(name); err != nil {43 return nil, err44 }4546 rp := filepath.Join(d.repoPath(name))4748 var userID int6449 if user != nil {50 userID = user.ID()51 }5253 if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {54 if err := d.store.CreateRepo(55 ctx,56 tx,57 name,58 userID,59 opts.ProjectName,60 opts.Description,61 opts.Private,62 opts.Hidden,63 opts.Mirror,64 ); err != nil {65 return err66 }6768 _, err := git.Init(rp, true)69 if err != nil {70 d.logger.Debug("failed to create repository", "err", err)71 return err72 }7374 if err := os.WriteFile(filepath.Join(rp, "description"), []byte(opts.Description), fs.ModePerm); err != nil {75 d.logger.Error("failed to write description", "repo", name, "err", err)76 return err77 }7879 if !opts.Private {80 if err := os.WriteFile(filepath.Join(rp, "git-daemon-export-ok"), []byte{}, fs.ModePerm); err != nil {81 d.logger.Error("failed to write git-daemon-export-ok", "repo", name, "err", err)82 return err83 }84 }8586 return hooks.GenerateHooks(ctx, d.cfg, name)87 }); err != nil {88 d.logger.Debug("failed to create repository in database", "err", err)89 err = db.WrapError(err)90 if errors.Is(err, db.ErrDuplicateKey) {91 return nil, proto.ErrRepoExist92 }9394 return nil, err95 }9697 return d.Repository(ctx, name)98}99100// ImportRepository imports a repository from remote.101// XXX: This a expensive operation and should be run in a goroutine.102func (d *Backend) ImportRepository(_ context.Context, name string, user proto.User, remote string, opts proto.RepositoryOptions) (proto.Repository, error) {103 name = utils.SanitizeRepo(name)104 if err := utils.ValidateRepo(name); err != nil {105 return nil, err106 }107108 remote = utils.Sanitize(remote)109 if err := validateImportRemote(remote); err != nil {110 return nil, err111 }112113 rp := filepath.Join(d.repoPath(name))114115 tid := "import:" + name116 if d.manager.Exists(tid) {117 return nil, task.ErrAlreadyStarted118 }119120 if _, err := os.Stat(rp); err == nil || os.IsExist(err) {121 return nil, proto.ErrRepoExist122 }123124 done := make(chan error, 1)125 repoc := make(chan proto.Repository, 1)126 d.logger.Info("importing repository", "name", name, "remote", remote, "path", rp)127 d.manager.Add(tid, func(ctx context.Context) (err error) {128 ctx = proto.WithUserContext(ctx, user)129130 copts := git.CloneOptions{131 Bare: true,132 Mirror: opts.Mirror,133 Quiet: true,134 CommandOptions: git.CommandOptions{135 Timeout: -1,136 Context: ctx,137 Envs: []string{138 fmt.Sprintf(`GIT_SSH_COMMAND=ssh -o UserKnownHostsFile="%s" -o StrictHostKeyChecking=no -i "%s"`,139 filepath.Join(d.cfg.DataPath, "ssh", "known_hosts"),140 d.cfg.SSH.ClientKeyPath,141 ),142 },143 },144 }145146 if err := git.Clone(remote, rp, copts); err != nil {147 d.logger.Error("failed to clone repository", "err", err, "mirror", opts.Mirror, "remote", remote, "path", rp)148 // Cleanup the mess!149 if rerr := os.RemoveAll(rp); rerr != nil {150 err = errors.Join(err, rerr)151 }152153 return err154 }155156 r, err := d.CreateRepository(ctx, name, user, opts)157 if err != nil {158 d.logger.Error("failed to create repository", "err", err, "name", name)159 return err160 }161162 defer func() {163 if err != nil {164 if rerr := d.DeleteRepository(ctx, name); rerr != nil {165 d.logger.Error("failed to delete repository", "err", rerr, "name", name)166 }167 }168 }()169170 rr, err := r.Open()171 if err != nil {172 d.logger.Error("failed to open repository", "err", err, "path", rp)173 return err174 }175176 repoc <- r177178 rcfg, err := rr.Config()179 if err != nil {180 d.logger.Error("failed to get repository config", "err", err, "path", rp)181 return err182 }183184 endpoint := remote185 if opts.LFSEndpoint != "" {186 endpoint = opts.LFSEndpoint187 }188189 rcfg.Section("lfs").SetOption("url", endpoint)190191 if err := rr.SetConfig(rcfg); err != nil {192 d.logger.Error("failed to set repository config", "err", err, "path", rp)193 return err194 }195196 ep, err := lfs.NewEndpoint(endpoint)197 if err != nil {198 d.logger.Error("failed to create lfs endpoint", "err", err, "path", rp)199 return err200 }201202 client := lfs.NewClient(ep)203 if client == nil {204 d.logger.Warn("failed to create lfs client: unsupported endpoint", "endpoint", endpoint)205 return nil206 }207208 if err := StoreRepoMissingLFSObjects(ctx, r, d.db, d.store, client); err != nil {209 d.logger.Error("failed to store missing lfs objects", "err", err, "path", rp)210 return err211 }212213 return nil214 })215216 go func() {217 d.logger.Info("running import", "name", name)218 d.manager.Run(tid, done)219 }()220221 return <-repoc, <-done222}223224// DeleteRepository deletes a repository.225//226// It implements backend.Backend.227func (d *Backend) DeleteRepository(ctx context.Context, name string) error {228 name = utils.SanitizeRepo(name)229 rp := filepath.Join(d.repoPath(name))230231 user := proto.UserFromContext(ctx)232 r, err := d.Repository(ctx, name)233 if err != nil {234 return err235 }236237 // We create the webhook event before deleting the repository so we can238 // send the event after deleting the repository.239 wh, err := webhook.NewRepositoryEvent(ctx, user, r, webhook.RepositoryEventActionDelete)240 if err != nil {241 return err242 }243244 if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {245 // Delete repo from cache246 defer d.cache.Delete(name)247248 repom, dberr := d.store.GetRepoByName(ctx, tx, name)249 _, ferr := os.Stat(rp)250 if dberr != nil && ferr != nil {251 return proto.ErrRepoNotFound252 }253254 // If the repo is not in the database but the directory exists, remove it255 if dberr != nil && ferr == nil {256 return os.RemoveAll(rp)257 } else if dberr != nil {258 return db.WrapError(dberr)259 }260261 repoID := strconv.FormatInt(repom.ID, 10)262 strg := storage.NewLocalStorage(filepath.Join(d.cfg.DataPath, "lfs", repoID))263 objs, err := d.store.GetLFSObjectsByName(ctx, tx, name)264 if err != nil {265 return db.WrapError(err)266 }267268 for _, obj := range objs {269 p := lfs.Pointer{270 Oid: obj.Oid,271 Size: obj.Size,272 }273274 d.logger.Debug("deleting lfs object", "repo", name, "oid", obj.Oid)275 if err := strg.Delete(path.Join("objects", p.RelativePath())); err != nil {276 d.logger.Error("failed to delete lfs object", "repo", name, "err", err, "oid", obj.Oid)277 }278 }279280 if err := d.store.DeleteRepoByName(ctx, tx, name); err != nil {281 return db.WrapError(err)282 }283284 return os.RemoveAll(rp)285 }); err != nil {286 if errors.Is(err, db.ErrRecordNotFound) {287 return proto.ErrRepoNotFound288 }289290 return db.WrapError(err)291 }292293 return webhook.SendEvent(ctx, wh)294}295296// DeleteUserRepositories deletes all user repositories.297func (d *Backend) DeleteUserRepositories(ctx context.Context, username string) error {298 if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {299 user, err := d.store.FindUserByUsername(ctx, tx, username)300 if err != nil {301 return err302 }303304 repos, err := d.store.GetUserRepos(ctx, tx, user.ID)305 if err != nil {306 return err307 }308309 for _, repo := range repos {310 if err := d.DeleteRepository(ctx, repo.Name); err != nil {311 return err312 }313 }314315 return nil316 }); err != nil {317 return db.WrapError(err)318 }319320 return nil321}322323// RenameRepository renames a repository.324//325// It implements backend.Backend.326func (d *Backend) RenameRepository(ctx context.Context, oldName string, newName string) error {327 oldName = utils.SanitizeRepo(oldName)328 if err := utils.ValidateRepo(oldName); err != nil {329 return err330 }331332 newName = utils.SanitizeRepo(newName)333 if err := utils.ValidateRepo(newName); err != nil {334 return err335 }336337 if oldName == newName {338 return nil339 }340341 op := filepath.Join(d.repoPath(oldName))342 np := filepath.Join(d.repoPath(newName))343 if _, err := os.Stat(op); err != nil {344 return proto.ErrRepoNotFound345 }346347 if _, err := os.Stat(np); err == nil {348 return proto.ErrRepoExist349 }350351 if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {352 // Delete cache353 defer d.cache.Delete(oldName)354355 if err := d.store.SetRepoNameByName(ctx, tx, oldName, newName); err != nil {356 return err357 }358359 // Make sure the new repository parent directory exists.360 if err := os.MkdirAll(filepath.Dir(np), os.ModePerm); err != nil {361 return err362 }363364 return os.Rename(op, np)365 }); err != nil {366 return db.WrapError(err)367 }368369 user := proto.UserFromContext(ctx)370 repo, err := d.Repository(ctx, newName)371 if err != nil {372 return err373 }374375 wh, err := webhook.NewRepositoryEvent(ctx, user, repo, webhook.RepositoryEventActionRename)376 if err != nil {377 return err378 }379380 return webhook.SendEvent(ctx, wh)381}382383// Repositories returns a list of repositories per page.384//385// It implements backend.Backend.386func (d *Backend) Repositories(ctx context.Context) ([]proto.Repository, error) {387 repos := make([]proto.Repository, 0)388389 if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {390 ms, err := d.store.GetAllRepos(ctx, tx)391 if err != nil {392 return err393 }394395 for _, m := range ms {396 r := &repo{397 name: m.Name,398 path: filepath.Join(d.repoPath(m.Name)),399 repo: m,400 }401402 // Cache repositories403 d.cache.Set(m.Name, r)404405 repos = append(repos, r)406 }407408 return nil409 }); err != nil {410 return nil, db.WrapError(err)411 }412413 return repos, nil414}415416// Repository returns a repository by name.417//418// It implements backend.Backend.419func (d *Backend) Repository(ctx context.Context, name string) (proto.Repository, error) {420 var m models.Repo421 name = utils.SanitizeRepo(name)422423 if r, ok := d.cache.Get(name); ok && r != nil {424 return r, nil425 }426427 rp := filepath.Join(d.repoPath(name))428 if _, err := os.Stat(rp); err != nil {429 if !errors.Is(err, fs.ErrNotExist) {430 d.logger.Errorf("failed to stat repository path: %v", err)431 }432 return nil, proto.ErrRepoNotFound433 }434435 if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {436 var err error437 m, err = d.store.GetRepoByName(ctx, tx, name)438 return db.WrapError(err)439 }); err != nil {440 if errors.Is(err, db.ErrRecordNotFound) {441 return nil, proto.ErrRepoNotFound442 }443 return nil, db.WrapError(err)444 }445446 r := &repo{447 name: name,448 path: rp,449 repo: m,450 }451452 // Add to cache453 d.cache.Set(name, r)454455 return r, nil456}457458// Description returns the description of a repository.459//460// It implements backend.Backend.461func (d *Backend) Description(ctx context.Context, name string) (string, error) {462 name = utils.SanitizeRepo(name)463 var desc string464 if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {465 var err error466 desc, err = d.store.GetRepoDescriptionByName(ctx, tx, name)467 return err468 }); err != nil {469 return "", db.WrapError(err)470 }471472 return desc, nil473}474475// IsMirror returns true if the repository is a mirror.476//477// It implements backend.Backend.478func (d *Backend) IsMirror(ctx context.Context, name string) (bool, error) {479 name = utils.SanitizeRepo(name)480 var mirror bool481 if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {482 var err error483 mirror, err = d.store.GetRepoIsMirrorByName(ctx, tx, name)484 return err485 }); err != nil {486 return false, db.WrapError(err)487 }488 return mirror, nil489}490491// IsPrivate returns true if the repository is private.492//493// It implements backend.Backend.494func (d *Backend) IsPrivate(ctx context.Context, name string) (bool, error) {495 name = utils.SanitizeRepo(name)496 var private bool497 if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {498 var err error499 private, err = d.store.GetRepoIsPrivateByName(ctx, tx, name)500 return err501 }); err != nil {502 return false, db.WrapError(err)503 }504505 return private, nil506}507508// IsHidden returns true if the repository is hidden.509//510// It implements backend.Backend.511func (d *Backend) IsHidden(ctx context.Context, name string) (bool, error) {512 name = utils.SanitizeRepo(name)513 var hidden bool514 if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {515 var err error516 hidden, err = d.store.GetRepoIsHiddenByName(ctx, tx, name)517 return err518 }); err != nil {519 return false, db.WrapError(err)520 }521522 return hidden, nil523}524525// ProjectName returns the project name of a repository.526//527// It implements backend.Backend.528func (d *Backend) ProjectName(ctx context.Context, name string) (string, error) {529 name = utils.SanitizeRepo(name)530 var pname string531 if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {532 var err error533 pname, err = d.store.GetRepoProjectNameByName(ctx, tx, name)534 return err535 }); err != nil {536 return "", db.WrapError(err)537 }538539 return pname, nil540}541542// SetHidden sets the hidden flag of a repository.543//544// It implements backend.Backend.545func (d *Backend) SetHidden(ctx context.Context, name string, hidden bool) error {546 name = utils.SanitizeRepo(name)547548 // Delete cache549 d.cache.Delete(name)550551 return db.WrapError(d.db.TransactionContext(ctx, func(tx *db.Tx) error {552 return d.store.SetRepoIsHiddenByName(ctx, tx, name, hidden)553 }))554}555556// SetMirror sets the mirror flag of a repository.557// Note: enabling mirror mode requires the repository to have been imported558// with a remote URL. Use ImportRepository to create a new mirror.559func (d *Backend) SetMirror(ctx context.Context, name string, mirror bool) error {560 name = utils.SanitizeRepo(name)561 rp := filepath.Join(d.repoPath(name))562563 // Delete cache564 d.cache.Delete(name)565566 return db.WrapError(d.db.TransactionContext(ctx, func(tx *db.Tx) error {567 // Update git config568 r, err := git.Open(rp)569 if err != nil {570 return err571 }572573 rcfg, err := r.Config()574 if err != nil {575 return err576 }577578 // Update mirror option for all remotes579 remoteSection := rcfg.Section("remote")580 hasRemote := false581 for _, sub := range remoteSection.Subsections {582 // Check if this remote has a URL583 for _, opt := range sub.Options {584 if opt.Key == "url" && opt.Value != "" {585 hasRemote = true586 found := false587 for i, opt := range sub.Options {588 if opt.Key == "mirror" {589 found = true590 if mirror {591 sub.Options[i].Value = "true"592 } else {593 sub.Options = append(sub.Options[:i], sub.Options[i+1:]...)594 }595 break596 }597 }598 if !found && mirror {599 sub.SetOption("mirror", "true")600 }601 break602 }603 }604 }605606 if mirror && !hasRemote {607 return errors.New("cannot enable mirror mode: repository has no remote URL configured")608 }609610 if err := r.SetConfig(rcfg); err != nil {611 d.logger.Error("failed to set repository config", "err", err, "path", rp)612 return err613 }614615 return d.store.SetRepoIsMirrorByName(ctx, tx, name, mirror)616 }))617}618619// SetDescription sets the description of a repository.620//621// It implements backend.Backend.622func (d *Backend) SetDescription(ctx context.Context, name string, desc string) error {623 name = utils.SanitizeRepo(name)624 desc = utils.Sanitize(desc)625 rp := filepath.Join(d.repoPath(name))626627 // Delete cache628 d.cache.Delete(name)629630 return d.db.TransactionContext(ctx, func(tx *db.Tx) error {631 if err := os.WriteFile(filepath.Join(rp, "description"), []byte(desc), fs.ModePerm); err != nil {632 d.logger.Error("failed to write description", "repo", name, "err", err)633 return err634 }635636 return d.store.SetRepoDescriptionByName(ctx, tx, name, desc)637 })638}639640// SetPrivate sets the private flag of a repository.641//642// It implements backend.Backend.643func (d *Backend) SetPrivate(ctx context.Context, name string, private bool) error {644 name = utils.SanitizeRepo(name)645 rp := filepath.Join(d.repoPath(name))646647 // Delete cache648 d.cache.Delete(name)649650 if err := db.WrapError(651 d.db.TransactionContext(ctx, func(tx *db.Tx) error {652 fp := filepath.Join(rp, "git-daemon-export-ok")653 if !private {654 if err := os.WriteFile(fp, []byte{}, fs.ModePerm); err != nil {655 d.logger.Error("failed to write git-daemon-export-ok", "repo", name, "err", err)656 return err657 }658 } else {659 if _, err := os.Stat(fp); err == nil {660 if err := os.Remove(fp); err != nil {661 d.logger.Error("failed to remove git-daemon-export-ok", "repo", name, "err", err)662 return err663 }664 }665 }666667 return d.store.SetRepoIsPrivateByName(ctx, tx, name, private)668 }),669 ); err != nil {670 return err671 }672673 user := proto.UserFromContext(ctx)674 repo, err := d.Repository(ctx, name)675 if err != nil {676 return err677 }678679 if repo.IsPrivate() != !private {680 wh, err := webhook.NewRepositoryEvent(ctx, user, repo, webhook.RepositoryEventActionVisibilityChange)681 if err != nil {682 return err683 }684685 if err := webhook.SendEvent(ctx, wh); err != nil {686 return err687 }688 }689690 return nil691}692693// SetProjectName sets the project name of a repository.694//695// It implements backend.Backend.696func (d *Backend) SetProjectName(ctx context.Context, repo string, name string) error {697 repo = utils.SanitizeRepo(repo)698 name = utils.Sanitize(name)699700 // Delete cache701 d.cache.Delete(repo)702703 return db.WrapError(704 d.db.TransactionContext(ctx, func(tx *db.Tx) error {705 return d.store.SetRepoProjectNameByName(ctx, tx, repo, name)706 }),707 )708}709710// repoPath returns the path to a repository.711func (d *Backend) repoPath(name string) string {712 name = utils.SanitizeRepo(name)713 rn := strings.ReplaceAll(name, "/", string(os.PathSeparator))714 return filepath.Join(filepath.Join(d.cfg.DataPath, "repos"), rn+".git")715}716717var _ proto.Repository = (*repo)(nil)718719// repo is a Git repository with metadata stored in a SQLite database.720type repo struct {721 name string722 path string723 repo models.Repo724}725726// ID returns the repository's ID.727//728// It implements proto.Repository.729func (r *repo) ID() int64 {730 return r.repo.ID731}732733// UserID returns the repository's owner's user ID.734// If the repository is not owned by anyone, it returns 0.735//736// It implements proto.Repository.737func (r *repo) UserID() int64 {738 if r.repo.UserID.Valid {739 return r.repo.UserID.Int64740 }741 return 0742}743744// Description returns the repository's description.745//746// It implements backend.Repository.747func (r *repo) Description() string {748 return r.repo.Description749}750751// IsMirror returns whether the repository is a mirror.752//753// It implements backend.Repository.754func (r *repo) IsMirror() bool {755 return r.repo.Mirror756}757758// IsPrivate returns whether the repository is private.759//760// It implements backend.Repository.761func (r *repo) IsPrivate() bool {762 return r.repo.Private763}764765// Name returns the repository's name.766//767// It implements backend.Repository.768func (r *repo) Name() string {769 return r.name770}771772// Open opens the repository.773//774// It implements backend.Repository.775func (r *repo) Open() (*git.Repository, error) {776 return git.Open(r.path)777}778779// ProjectName returns the repository's project name.780//781// It implements backend.Repository.782func (r *repo) ProjectName() string {783 return r.repo.ProjectName784}785786// IsHidden returns whether the repository is hidden.787//788// It implements backend.Repository.789func (r *repo) IsHidden() bool {790 return r.repo.Hidden791}792793// CreatedAt returns the repository's creation time.794func (r *repo) CreatedAt() time.Time {795 return r.repo.CreatedAt796}797798// UpdatedAt returns the repository's last update time.799func (r *repo) UpdatedAt() time.Time {800 // Try to read the last modified time from the info directory.801 if t, err := readOneline(filepath.Join(r.path, "info", "last-modified")); err == nil {802 if t, err := time.Parse(time.RFC3339, t); err == nil {803 return t804 }805 }806807 rr, err := git.Open(r.path)808 if err == nil {809 t, err := rr.LatestCommitTime()810 if err == nil {811 return t812 }813 }814815 return r.repo.UpdatedAt816}817818func (r *repo) writeLastModified(t time.Time) error {819 fp := filepath.Join(r.path, "info", "last-modified")820 if err := os.MkdirAll(filepath.Dir(fp), os.ModePerm); err != nil {821 return err822 }823824 return os.WriteFile(fp, []byte(t.Format(time.RFC3339)), os.ModePerm) //nolint:gosec825}826827func readOneline(path string) (string, error) {828 f, err := os.Open(path)829 if err != nil {830 return "", err831 }832833 defer f.Close() //nolint: errcheck834 s := bufio.NewScanner(f)835 s.Scan()836 return s.Text(), s.Err()837}