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)2728// 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, err35 }3637 rp := filepath.Join(d.repoPath(name))3839 var userID int6440 if user != nil {41 userID = user.ID()42 }4344 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 err57 }5859 _, err := git.Init(rp, true)60 if err != nil {61 d.logger.Debug("failed to create repository", "err", err)62 return err63 }6465 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 err68 }6970 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 err74 }75 }7677 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.ErrRepoExist83 }8485 return nil, err86 }8788 return d.Repository(ctx, name)89}9091// 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, err97 }9899 rp := filepath.Join(d.repoPath(name))100101 tid := "import:" + name102 if d.manager.Exists(tid) {103 return nil, task.ErrAlreadyStarted104 }105106 if _, err := os.Stat(rp); err == nil || os.IsExist(err) {107 return nil, proto.ErrRepoExist108 }109110 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)115116 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 }131132 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 }138139 return err140 }141142 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 err146 }147148 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 }()155156 rr, err := r.Open()157 if err != nil {158 d.logger.Error("failed to open repository", "err", err, "path", rp)159 return err160 }161162 repoc <- r163164 rcfg, err := rr.Config()165 if err != nil {166 d.logger.Error("failed to get repository config", "err", err, "path", rp)167 return err168 }169170 endpoint := remote171 if opts.LFSEndpoint != "" {172 endpoint = opts.LFSEndpoint173 }174175 rcfg.Section("lfs").SetOption("url", endpoint)176177 if err := rr.SetConfig(rcfg); err != nil {178 d.logger.Error("failed to set repository config", "err", err, "path", rp)179 return err180 }181182 ep, err := lfs.NewEndpoint(endpoint)183 if err != nil {184 d.logger.Error("failed to create lfs endpoint", "err", err, "path", rp)185 return err186 }187188 client := lfs.NewClient(ep)189 if client == nil {190 d.logger.Warn("failed to create lfs client: unsupported endpoint", "endpoint", endpoint)191 return nil192 }193194 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 err197 }198199 return nil200 })201202 go func() {203 d.logger.Info("running import", "name", name)204 d.manager.Run(tid, done)205 }()206207 return <-repoc, <-done208}209210// 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))216217 user := proto.UserFromContext(ctx)218 r, err := d.Repository(ctx, name)219 if err != nil {220 return err221 }222223 // We create the webhook event before deleting the repository so we can224 // send the event after deleting the repository.225 wh, err := webhook.NewRepositoryEvent(ctx, user, r, webhook.RepositoryEventActionDelete)226 if err != nil {227 return err228 }229230 if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {231 // Delete repo from cache232 defer d.cache.Delete(name)233234 repom, dberr := d.store.GetRepoByName(ctx, tx, name)235 _, ferr := os.Stat(rp)236 if dberr != nil && ferr != nil {237 return proto.ErrRepoNotFound238 }239240 // If the repo is not in the database but the directory exists, remove it241 if dberr != nil && ferr == nil {242 return os.RemoveAll(rp)243 } else if dberr != nil {244 return db.WrapError(dberr)245 }246247 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 }253254 for _, obj := range objs {255 p := lfs.Pointer{256 Oid: obj.Oid,257 Size: obj.Size,258 }259260 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 }265266 if err := d.store.DeleteRepoByName(ctx, tx, name); err != nil {267 return db.WrapError(err)268 }269270 return os.RemoveAll(rp)271 }); err != nil {272 if errors.Is(err, db.ErrRecordNotFound) {273 return proto.ErrRepoNotFound274 }275276 return db.WrapError(err)277 }278279 return webhook.SendEvent(ctx, wh)280}281282// 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 err288 }289290 repos, err := d.store.GetUserRepos(ctx, tx, user.ID)291 if err != nil {292 return err293 }294295 for _, repo := range repos {296 if err := d.DeleteRepository(ctx, repo.Name); err != nil {297 return err298 }299 }300301 return nil302 }); err != nil {303 return db.WrapError(err)304 }305306 return nil307}308309// 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 err316 }317318 newName = utils.SanitizeRepo(newName)319 if err := utils.ValidateRepo(newName); err != nil {320 return err321 }322323 if oldName == newName {324 return nil325 }326327 op := filepath.Join(d.repoPath(oldName))328 np := filepath.Join(d.repoPath(newName))329 if _, err := os.Stat(op); err != nil {330 return proto.ErrRepoNotFound331 }332333 if _, err := os.Stat(np); err == nil {334 return proto.ErrRepoExist335 }336337 if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {338 // Delete cache339 defer d.cache.Delete(oldName)340341 if err := d.store.SetRepoNameByName(ctx, tx, oldName, newName); err != nil {342 return err343 }344345 // Make sure the new repository parent directory exists.346 if err := os.MkdirAll(filepath.Dir(np), os.ModePerm); err != nil {347 return err348 }349350 return os.Rename(op, np)351 }); err != nil {352 return db.WrapError(err)353 }354355 user := proto.UserFromContext(ctx)356 repo, err := d.Repository(ctx, newName)357 if err != nil {358 return err359 }360361 wh, err := webhook.NewRepositoryEvent(ctx, user, repo, webhook.RepositoryEventActionRename)362 if err != nil {363 return err364 }365366 return webhook.SendEvent(ctx, wh)367}368369// 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)374375 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 err379 }380381 for _, m := range ms {382 r := &repo{383 name: m.Name,384 path: filepath.Join(d.repoPath(m.Name)),385 repo: m,386 }387388 // Cache repositories389 d.cache.Set(m.Name, r)390391 repos = append(repos, r)392 }393394 return nil395 }); err != nil {396 return nil, db.WrapError(err)397 }398399 return repos, nil400}401402// 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.Repo407 name = utils.SanitizeRepo(name)408409 if r, ok := d.cache.Get(name); ok && r != nil {410 return r, nil411 }412413 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.ErrRepoNotFound419 }420421 if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {422 var err error423 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.ErrRepoNotFound428 }429 return nil, db.WrapError(err)430 }431432 r := &repo{433 name: name,434 path: rp,435 repo: m,436 }437438 // Add to cache439 d.cache.Set(name, r)440441 return r, nil442}443444// 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 string450 if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {451 var err error452 desc, err = d.store.GetRepoDescriptionByName(ctx, tx, name)453 return err454 }); err != nil {455 return "", db.WrapError(err)456 }457458 return desc, nil459}460461// 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 bool467 if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {468 var err error469 mirror, err = d.store.GetRepoIsMirrorByName(ctx, tx, name)470 return err471 }); err != nil {472 return false, db.WrapError(err)473 }474 return mirror, nil475}476477// 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 bool483 if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {484 var err error485 private, err = d.store.GetRepoIsPrivateByName(ctx, tx, name)486 return err487 }); err != nil {488 return false, db.WrapError(err)489 }490491 return private, nil492}493494// 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 bool500 if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {501 var err error502 hidden, err = d.store.GetRepoIsHiddenByName(ctx, tx, name)503 return err504 }); err != nil {505 return false, db.WrapError(err)506 }507508 return hidden, nil509}510511// 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 string517 if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {518 var err error519 pname, err = d.store.GetRepoProjectNameByName(ctx, tx, name)520 return err521 }); err != nil {522 return "", db.WrapError(err)523 }524525 return pname, nil526}527528// 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)533534 // Delete cache535 d.cache.Delete(name)536537 return db.WrapError(d.db.TransactionContext(ctx, func(tx *db.Tx) error {538 return d.store.SetRepoIsHiddenByName(ctx, tx, name, hidden)539 }))540}541542// 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 desc = utils.Sanitize(desc)548 rp := filepath.Join(d.repoPath(name))549550 // Delete cache551 d.cache.Delete(name)552553 return d.db.TransactionContext(ctx, func(tx *db.Tx) error {554 if err := os.WriteFile(filepath.Join(rp, "description"), []byte(desc), fs.ModePerm); err != nil {555 d.logger.Error("failed to write description", "repo", name, "err", err)556 return err557 }558559 return d.store.SetRepoDescriptionByName(ctx, tx, name, desc)560 })561}562563// SetPrivate sets the private flag of a repository.564//565// It implements backend.Backend.566func (d *Backend) SetPrivate(ctx context.Context, name string, private bool) error {567 name = utils.SanitizeRepo(name)568 rp := filepath.Join(d.repoPath(name))569570 // Delete cache571 d.cache.Delete(name)572573 if err := db.WrapError(574 d.db.TransactionContext(ctx, func(tx *db.Tx) error {575 fp := filepath.Join(rp, "git-daemon-export-ok")576 if !private {577 if err := os.WriteFile(fp, []byte{}, fs.ModePerm); err != nil {578 d.logger.Error("failed to write git-daemon-export-ok", "repo", name, "err", err)579 return err580 }581 } else {582 if _, err := os.Stat(fp); err == nil {583 if err := os.Remove(fp); err != nil {584 d.logger.Error("failed to remove git-daemon-export-ok", "repo", name, "err", err)585 return err586 }587 }588 }589590 return d.store.SetRepoIsPrivateByName(ctx, tx, name, private)591 }),592 ); err != nil {593 return err594 }595596 user := proto.UserFromContext(ctx)597 repo, err := d.Repository(ctx, name)598 if err != nil {599 return err600 }601602 if repo.IsPrivate() != !private {603 wh, err := webhook.NewRepositoryEvent(ctx, user, repo, webhook.RepositoryEventActionVisibilityChange)604 if err != nil {605 return err606 }607608 if err := webhook.SendEvent(ctx, wh); err != nil {609 return err610 }611 }612613 return nil614}615616// SetProjectName sets the project name of a repository.617//618// It implements backend.Backend.619func (d *Backend) SetProjectName(ctx context.Context, repo string, name string) error {620 repo = utils.SanitizeRepo(repo)621 name = utils.Sanitize(name)622623 // Delete cache624 d.cache.Delete(repo)625626 return db.WrapError(627 d.db.TransactionContext(ctx, func(tx *db.Tx) error {628 return d.store.SetRepoProjectNameByName(ctx, tx, repo, name)629 }),630 )631}632633// repoPath returns the path to a repository.634func (d *Backend) repoPath(name string) string {635 name = utils.SanitizeRepo(name)636 rn := strings.ReplaceAll(name, "/", string(os.PathSeparator))637 return filepath.Join(filepath.Join(d.cfg.DataPath, "repos"), rn+".git")638}639640var _ proto.Repository = (*repo)(nil)641642// repo is a Git repository with metadata stored in a SQLite database.643type repo struct {644 name string645 path string646 repo models.Repo647}648649// ID returns the repository's ID.650//651// It implements proto.Repository.652func (r *repo) ID() int64 {653 return r.repo.ID654}655656// UserID returns the repository's owner's user ID.657// If the repository is not owned by anyone, it returns 0.658//659// It implements proto.Repository.660func (r *repo) UserID() int64 {661 if r.repo.UserID.Valid {662 return r.repo.UserID.Int64663 }664 return 0665}666667// Description returns the repository's description.668//669// It implements backend.Repository.670func (r *repo) Description() string {671 return r.repo.Description672}673674// IsMirror returns whether the repository is a mirror.675//676// It implements backend.Repository.677func (r *repo) IsMirror() bool {678 return r.repo.Mirror679}680681// IsPrivate returns whether the repository is private.682//683// It implements backend.Repository.684func (r *repo) IsPrivate() bool {685 return r.repo.Private686}687688// Name returns the repository's name.689//690// It implements backend.Repository.691func (r *repo) Name() string {692 return r.name693}694695// Open opens the repository.696//697// It implements backend.Repository.698func (r *repo) Open() (*git.Repository, error) {699 return git.Open(r.path)700}701702// ProjectName returns the repository's project name.703//704// It implements backend.Repository.705func (r *repo) ProjectName() string {706 return r.repo.ProjectName707}708709// IsHidden returns whether the repository is hidden.710//711// It implements backend.Repository.712func (r *repo) IsHidden() bool {713 return r.repo.Hidden714}715716// CreatedAt returns the repository's creation time.717func (r *repo) CreatedAt() time.Time {718 return r.repo.CreatedAt719}720721// UpdatedAt returns the repository's last update time.722func (r *repo) UpdatedAt() time.Time {723 // Try to read the last modified time from the info directory.724 if t, err := readOneline(filepath.Join(r.path, "info", "last-modified")); err == nil {725 if t, err := time.Parse(time.RFC3339, t); err == nil {726 return t727 }728 }729730 rr, err := git.Open(r.path)731 if err == nil {732 t, err := rr.LatestCommitTime()733 if err == nil {734 return t735 }736 }737738 return r.repo.UpdatedAt739}740741func (r *repo) writeLastModified(t time.Time) error {742 fp := filepath.Join(r.path, "info", "last-modified")743 if err := os.MkdirAll(filepath.Dir(fp), os.ModePerm); err != nil {744 return err745 }746747 return os.WriteFile(fp, []byte(t.Format(time.RFC3339)), os.ModePerm) //nolint:gosec748}749750func readOneline(path string) (string, error) {751 f, err := os.Open(path)752 if err != nil {753 return "", err754 }755756 defer f.Close() // nolint: errcheck757 s := bufio.NewScanner(f)758 s.Scan()759 return s.Text(), s.Err()760}