diff --git a/knotmirror/xrpc/git_get_blob.go b/knotmirror/xrpc/git_get_blob.go --- a/knotmirror/xrpc/git_get_blob.go +++ b/knotmirror/xrpc/git_get_blob.go @@ -1,7 +1,6 @@ package xrpc import ( - "context" "crypto/sha256" "fmt" "io" @@ -12,7 +11,6 @@ "strings" "github.com/bluesky-social/indigo/atproto/atclient" "github.com/bluesky-social/indigo/atproto/syntax" - "github.com/go-git/go-git/v5/plumbing/object" "tangled.org/core/knotmirror/xrpc/gitea" ) @@ -45,7 +43,7 @@ writeJson(w, http.StatusNotFound, atclient.ErrorBody{Name: "RepoNotFound", Message: fmt.Sprintf("unknown repository: %s", repo)}) return } - entry, err := x.getFile(ctx, repoPath, ref, path) + entry, err := gitea.GetEntry(ctx, repoPath, ref, path) if err != nil { l.Warn("local mirror failed", "err", err) writeJson(w, http.StatusInternalServerError, atclient.ErrorBody{Name: "InternalServerError", Message: "failed to get blob"}) @@ -109,46 +107,6 @@ writeJson(w, http.StatusInternalServerError, atclient.ErrorBody{Name: "InvalidRequest", Message: "only image, video, and text files can be accessed directly"}) return } w.Write(contents) -} - -func (x *Xrpc) getFile(ctx context.Context, repoPath, ref, path string) (*object.TreeEntry, error) { - rev := ref - if rev == "" { - rev = "HEAD" - } - - head, err := gitea.GetCommit(ctx, repoPath, rev) - if err != nil { - return nil, fmt.Errorf("get head commit: %w", err) - } - - treePath := filepath.Dir(path) - name := filepath.Base(path) - - // find subTree - subRev := head.Hash.String() + "^{tree}" - if treePath != "." { - subRev = head.Hash.String() + ":" + treePath - } - subTree, err := gitea.GetTree(ctx, repoPath, subRev) - if err != nil { - return nil, fmt.Errorf("get subtree %s: %w", subRev, err) - } - - // find entry - entry, err := func(subTree *object.Tree) (*object.TreeEntry, error) { - for _, entry := range subTree.Entries { - if entry.Name == name { - return &entry, nil - } - } - return nil, fmt.Errorf("object doesn't exist") - }(subTree) - if err != nil { - return nil, fmt.Errorf("get file: %w", err) - } - - return entry, nil } var textualMimeTypes = []string{ diff --git a/knotmirror/xrpc/git_get_entry.go b/knotmirror/xrpc/git_get_entry.go new file mode 100644 --- /dev/null +++ b/knotmirror/xrpc/git_get_entry.go @@ -0,0 +1,120 @@ +package xrpc + +import ( + "cmp" + "errors" + "fmt" + "net/http" + "time" + + "github.com/bluesky-social/indigo/atproto/atclient" + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/go-git/go-git/v5/plumbing/filemode" + "tangled.org/core/api/tangled" + "tangled.org/core/knotmirror/xrpc/gitea" +) + +func (x *Xrpc) GetEntry(w http.ResponseWriter, r *http.Request) { + var ( + repoQuery = r.URL.Query().Get("repo") + ref = cmp.Or(r.URL.Query().Get("ref"), "HEAD") + path = r.URL.Query().Get("path") + ) + + repo, err := syntax.ParseDID(repoQuery) + if err != nil { + writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "BadRequest", Message: fmt.Sprintf("repo parameter invalid: %s", repoQuery)}) + return + } + + if path == "" { + writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "BadRequest", Message: "missing path parameter"}) + return + } + + l := x.logger.With("method", "git.getEntry", "repo", repo, "ref", ref, "path", path) + l.Debug("request") + + ctx := r.Context() + + repoPath, err := x.makeRepoPath(ctx, repo) + if err != nil { + writeJson(w, http.StatusNotFound, atclient.ErrorBody{Name: "RepoNotFound", Message: fmt.Sprintf("unknown repository: %q", repo)}) + return + } + + // resolve ref + commit, err := gitea.GetCommit(ctx, repoPath, ref) + if err != nil { + writeJson(w, http.StatusNotFound, atclient.ErrorBody{Name: "RefNotFound", Message: fmt.Sprintf("unknown ref: %q", ref)}) + return + } + ref = commit.Hash.String() + + entry, err := gitea.GetEntryFromCommit(ctx, repoPath, commit, path) + if err != nil { + writeJson(w, http.StatusNotFound, atclient.ErrorBody{Name: "EntryNotFound", Message: fmt.Sprintf("entry %q not found", path)}) + return + } + + var outLastCommit *tangled.GitTempDefs_Commit + var outSubmodule *tangled.GitTempDefs_Submodule + + lastCommit, err := gitea.GetCommitByPathWithID(ctx, commit.Hash, repoPath, path) + if err != nil { + l.Error("failed to find last commit", "err", err, "repoPath", repoPath) + } else { + outLastCommit = &tangled.GitTempDefs_Commit{ + Hash: refString(lastCommit.Hash.String()), + Tree: refString(lastCommit.TreeHash.String()), + Author: &tangled.GitTempDefs_Signature{ + Name: lastCommit.Author.Name, + Email: lastCommit.Author.Email, + When: lastCommit.Author.When.Format(time.RFC3339), + }, + Committer: &tangled.GitTempDefs_Signature{ + Name: lastCommit.Committer.Name, + Email: lastCommit.Committer.Email, + When: lastCommit.Committer.When.Format(time.RFC3339), + }, + Message: lastCommit.Message, + } + } + + if entry.Mode == filemode.Submodule { + modules, err := gitea.GetSubmodules(ctx, repoPath, ref) + if err != nil && !(errors.Is(err, gitea.ErrMissingGitModules) || errors.Is(err, gitea.ErrInvalidGitModules)) { + writeJson(w, http.StatusInternalServerError, atclient.ErrorBody{Name: "InternalServerError", Message: "failed to read .gitmodules"}) + return + } + + if modules != nil { + for _, submodule := range modules.Submodules { + if submodule.Path == path { + outSubmodule = &tangled.GitTempDefs_Submodule{ + Name: submodule.Name, + Url: submodule.URL, + Branch: refOptionalString(submodule.Branch), + } + break + } + } + } + } + + writeJson(w, http.StatusOK, tangled.GitTempGetEntry_Output{ + Name: entry.Name, + Mode: entry.Mode.String(), + Oid: entry.Hash.String(), + LastCommit: outLastCommit, + Submodule: outSubmodule, + }) +} + +func refString(s string) *string { return &s } +func refOptionalString(s string) *string { + if s == "" { + return nil + } + return &s +} diff --git a/knotmirror/xrpc/gitea/commit.go b/knotmirror/xrpc/gitea/commit.go new file mode 100644 --- /dev/null +++ b/knotmirror/xrpc/gitea/commit.go @@ -0,0 +1,42 @@ +package gitea + +import ( + "context" + "errors" + "fmt" + "os/exec" + "strings" + + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/object" +) + +func GetCommitByPathWithID(ctx context.Context, oid plumbing.Hash, repoPath, relpath string) (*object.Commit, error) { + if len(relpath) == 0 { + return nil, errors.New("relpath should not be empty") + } + // File name starts with ':' must be escaped. + if relpath[0] == ':' { + relpath = `\` + relpath + } + + out, err := exec.CommandContext(ctx, + "git", + "-C", repoPath, + "log", + "-1", + "--pretty=format:%H", + oid.String(), + "--", relpath, + ).Output() + if err != nil { + return nil, err + } + + rev := plumbing.NewHash(strings.TrimSpace(string(out))) + if rev.IsZero() { + return nil, fmt.Errorf("invalid commit id: %q", string(out)) + } + + return GetCommit(ctx, repoPath, rev.String()) +} diff --git a/knotmirror/xrpc/gitea/entry.go b/knotmirror/xrpc/gitea/entry.go new file mode 100644 --- /dev/null +++ b/knotmirror/xrpc/gitea/entry.go @@ -0,0 +1,52 @@ +package gitea + +import ( + "context" + "fmt" + "path/filepath" + + "github.com/go-git/go-git/v5/plumbing/object" +) + +func GetEntry(ctx context.Context, repoPath, ref, path string) (*object.TreeEntry, error) { + if ref == "" { + ref = "HEAD" + } + + head, err := GetCommit(ctx, repoPath, ref) + if err != nil { + return nil, fmt.Errorf("get head commit: %w", err) + } + + return GetEntryFromCommit(ctx, repoPath, head, path) +} + +func GetEntryFromCommit(ctx context.Context, repoPath string, commit *object.Commit, path string) (*object.TreeEntry, error) { + treePath := filepath.Dir(path) + name := filepath.Base(path) + + // find subTree + subRev := commit.Hash.String() + "^{tree}" + if treePath != "." { + subRev = commit.Hash.String() + ":" + treePath + } + subTree, err := GetTree(ctx, repoPath, subRev) + if err != nil { + return nil, fmt.Errorf("get subtree %s: %w", subRev, err) + } + + // find entry + entry, err := func(subTree *object.Tree) (*object.TreeEntry, error) { + for _, entry := range subTree.Entries { + if entry.Name == name { + return &entry, nil + } + } + return nil, fmt.Errorf("object doesn't exist") + }(subTree) + if err != nil { + return nil, fmt.Errorf("get file: %w", err) + } + + return entry, nil +} diff --git a/knotmirror/xrpc/gitea/submodule.go b/knotmirror/xrpc/gitea/submodule.go new file mode 100644 --- /dev/null +++ b/knotmirror/xrpc/gitea/submodule.go @@ -0,0 +1,39 @@ +package gitea + +import ( + "context" + "errors" + "io" + + "github.com/go-git/go-git/v5/config" +) + +var ( + ErrMissingGitModules = errors.New("no .gitmodules file found") + ErrInvalidGitModules = errors.New("invalid .gitmodules file") +) + +func GetSubmodules(ctx context.Context, repoPath, ref string) (*config.Modules, error) { + modulesEntry, err := GetEntry(ctx, repoPath, ref, ".gitmodules") + if err != nil { + return nil, ErrMissingGitModules + } + + // at the moment we do not strictly limit the size of the .gitmodules file because some users would have huge .gitmodules files (>1MB) + _, reader, err := ReadBlob(ctx, repoPath, modulesEntry.Hash) + if err != nil { + return nil, err + } + + modulesContent, err := io.ReadAll(reader) + if err != nil { + return nil, err + } + + modules := config.NewModules() + if err := modules.Unmarshal(modulesContent); err != nil { + return nil, ErrInvalidGitModules + } + + return modules, nil +} diff --git a/knotmirror/xrpc/repo_blob.go b/knotmirror/xrpc/repo_blob.go --- a/knotmirror/xrpc/repo_blob.go +++ b/knotmirror/xrpc/repo_blob.go @@ -57,7 +57,7 @@ writeJson(w, http.StatusNotFound, atclient.ErrorBody{Name: "RepoNotFound", Message: fmt.Sprintf("unknown repository: %s", repo)}) return } - entry, err := x.getFile(ctx, repoPath, ref, path) + entry, err := gitea.GetEntry(ctx, repoPath, ref, path) if err != nil { l.Warn("local mirror failed, trying proxy", "err", err) if x.proxyToKnot(w, r, repo) { diff --git a/knotmirror/xrpc/xrpc.go b/knotmirror/xrpc/xrpc.go --- a/knotmirror/xrpc/xrpc.go +++ b/knotmirror/xrpc/xrpc.go @@ -64,7 +64,7 @@ r.Get("/"+tangled.GitTempGetBlobNSID, x.GetBlob) r.Get("/"+tangled.GitTempGetBranchNSID, x.GetBranch) // r.Get("/"+tangled.GitTempGetCommitNSID, x.GetCommit) // todo // r.Get("/"+tangled.GitTempGetDiffNSID, x.GetDiff) // todo - // r.Get("/"+tangled.GitTempGetEntityNSID, x.GetEntity) // todo + r.Get("/"+tangled.GitTempGetEntryNSID, x.GetEntry) // todo // r.Get("/"+tangled.GitTempGetHeadNSID, x.GetHead) // todo r.Get("/"+tangled.GitTempGetTagNSID, x.GetTag) // using types.Response r.Get("/"+tangled.GitTempGetTreeNSID, x.GetTree)