Files
anonymous_github/backend/internal/server/provider_github.go
T

286 lines
10 KiB
Go

package server
import (
"context"
"errors"
"github.com/tdurieux/anonymous_github/backend/internal/anonymize"
"github.com/tdurieux/anonymous_github/backend/internal/provider"
"github.com/tdurieux/anonymous_github/backend/internal/security"
"io"
"net/http"
"net/url"
"path"
"strings"
)
// GitHub-specific protocol and access binding stay in this adapter. Shared
// preparation, publication and file serving only see provider value types.
type githubProvider struct{ server *Server }
func (g *githubProvider) ID() string { return "github" }
func (g *githubProvider) token(ctx context.Context, a provider.Access) (string, error) {
owner, e := oid(a.OwnerID)
if e != nil {
return "", fail("not_connected", 403)
}
return g.server.boundToken(ctx, owner, M(a.Binding), a.Ref.Name)
}
func (g *githubProvider) Connect(ctx context.Context, q provider.ConnectRequest) (provider.Connection, error) {
owner, e := oid(q.OwnerID)
if e != nil {
return provider.Connection{}, fail("not_connected", 403)
}
_, binding, resolvedName, e := g.server.selectAccessIdentity(ctx, owner, q.Name, q.Connection, q.RepositoryID)
if resolvedName == "" {
resolvedName = q.Name
}
repositoryID := str(binding["repositoryId"])
if repositoryID == "" {
repositoryID = q.RepositoryID
}
return provider.Connection{Ref: provider.Ref{Provider: g.ID(), Name: resolvedName, ID: repositoryID}, Binding: map[string]any(binding)}, e
}
func (g *githubProvider) Authorize(ctx context.Context, a provider.Access) error {
_, e := g.token(ctx, a)
return e
}
func (g *githubProvider) Repository(ctx context.Context, a provider.Access) (provider.Repository, error) {
return g.repositoryMetadata(ctx, a, nil)
}
func (g *githubProvider) PreviewRepository(ctx context.Context, a provider.Access, cached *provider.Repository) (provider.Repository, error) {
return g.repositoryMetadata(ctx, a, cached)
}
func (g *githubProvider) repositoryMetadata(ctx context.Context, a provider.Access, cached *provider.Repository) (provider.Repository, error) {
if !safeRepositoryName(a.Ref.Name) {
return provider.Repository{}, fail("repo_not_found", 404)
}
token, e := g.token(ctx, a)
if e != nil {
return provider.Repository{}, e
}
endpoint := "/repos/" + a.Ref.Name
// The stable upstream ID survives repository and organization renames.
if a.Ref.ID != "" {
endpoint = "/repositories/" + segment(a.Ref.ID)
}
raw, e := g.server.githubObject(ctx, "GET", endpoint, token, nil)
if e != nil {
return provider.Repository{}, e
}
result := provider.Repository{ID: str(raw["id"]), Name: str(raw["full_name"]), URL: str(raw["html_url"]), DefaultBranch: str(raw["default_branch"]), Size: number(raw["size"]), HasPage: boolean(raw["has_pages"])}
if err := validateRepositoryIdentity(a, result); err != nil {
return provider.Repository{}, err
}
if cached != nil {
if cached.ID != result.ID {
return provider.Repository{}, fail("connection_changed", 409)
}
return *cached, nil
}
if result.HasPage {
if !safeRepositoryName(result.Name) {
return provider.Repository{}, fail("github_invalid_response", 502)
}
pages, err := g.server.githubObject(ctx, "GET", "/repos/"+result.Name+"/pages", token, nil)
if err != nil {
var upstream *githubHTTPError
if !errors.As(err, &upstream) || upstream.status != 403 && upstream.status != 404 {
return provider.Repository{}, err
}
} else {
source := obj(pages["source"])
result.PageBranch, result.PagePath = str(source["branch"]), str(source["path"])
}
}
return result, nil
}
func (g *githubProvider) Commit(ctx context.Context, a provider.Access, ref string) (provider.Commit, error) {
if !safeRepositoryName(a.Ref.Name) {
return provider.Commit{}, fail("repo_not_found", 404)
}
token, e := g.token(ctx, a)
if e != nil {
return provider.Commit{}, e
}
raw, e := g.server.githubObject(ctx, "GET", "/repos/"+a.Ref.Name+"/commits/"+segment(ref), token, nil)
if e != nil {
return provider.Commit{}, e
}
id := str(raw["sha"])
if !commitID.MatchString(id) {
return provider.Commit{}, fail("github_invalid_response", 502)
}
stamp, valid := storedDate(obj(obj(raw["commit"])["author"])["date"])
if !valid {
stamp = date(obj(obj(raw["commit"])["committer"])["date"])
}
return provider.Commit{ID: id, Date: stamp}, nil
}
func (g *githubProvider) Tree(ctx context.Context, a provider.Access) ([]provider.Entry, error) {
entries, _, err := g.TreeWithTruncation(ctx, a)
return entries, err
}
func (g *githubProvider) TreeWithTruncation(ctx context.Context, a provider.Access) ([]provider.Entry, []string, error) {
truncated := []string{}
name, commit := a.Ref.Name, a.Ref.Commit
if !safeRepositoryName(name) || !commitID.MatchString(commit) {
return nil, nil, fail("invalid_source", 400)
}
token, e := g.token(ctx, a)
if e != nil {
return nil, nil, e
}
tree, e := g.server.githubObject(ctx, "GET", "/repos/"+name+"/git/trees/"+segment(commit)+"?recursive=1", token, nil)
if e != nil {
return nil, nil, e
}
entries := arr(tree["tree"])
if boolean(tree["truncated"]) {
entries = []any{}
type dir struct{ sha, prefix string }
queue := []dir{{commit, ""}}
for len(queue) > 0 {
next := queue[0]
queue = queue[1:]
sub, e := g.server.githubObject(ctx, "GET", "/repos/"+name+"/git/trees/"+segment(next.sha), token, nil)
if e != nil {
return nil, nil, e
}
if boolean(sub["truncated"]) {
truncated = append(truncated, next.prefix)
}
for _, v := range arr(sub["tree"]) {
item := obj(v)
relative, e := security.RelativePath(str(item["path"]))
if e != nil {
return nil, nil, fail("invalid_file_path", 422)
}
p := path.Join(next.prefix, relative)
item["path"] = p
entries = append(entries, item)
if str(item["type"]) == "tree" {
queue = append(queue, dir{str(item["sha"]), p})
}
}
}
}
out := make([]provider.Entry, 0, len(entries))
for _, v := range entries {
row := obj(v)
kind := str(row["type"])
if kind != "blob" && kind != "tree" && kind != "commit" {
continue
}
out = append(out, provider.Entry{Path: str(row["path"]), Revision: str(row["sha"]), Size: number(row["size"]), Directory: kind != "blob"})
}
return out, truncated, nil
}
func (g *githubProvider) Open(ctx context.Context, a provider.Access, f provider.Entry) (io.ReadCloser, error) {
dir, name := path.Split(f.Path)
return g.server.githubBlob(ctx, providerRecord(a), M{"path": strings.TrimSuffix(dir, "/"), "name": name, "sha": f.Revision, "size": f.Size})
}
func (g *githubProvider) PublicURL(r provider.Ref) string {
if !safeRepositoryName(r.Name) {
return ""
}
return "https://github.com/" + r.Name
}
func (g *githubProvider) SelfLinks(r provider.Ref) []string {
if r.Name == "" || r.Branch == "" {
return []string{}
}
return []string{"https://raw.githubusercontent.com/" + r.Name + "/" + r.Branch, "https://github.com/" + r.Name + "/blob/" + r.Branch, "https://github.com/" + r.Name + "/tree/" + r.Branch, "https://github.com/" + r.Name}
}
func (s *Server) githubBlob(ctx context.Context, r, row M) (io.ReadCloser, error) {
if number(row["size"]) > anonymize.MaxInput {
return nil, fail("file_too_big", 413)
}
source := obj(r["source"])
name := str(source["repositoryName"])
if !safeRepositoryName(name) {
return nil, fail("repo_not_found", 404)
}
sha := str(row["sha"])
if len(sha) != 40 || !commitID.MatchString(sha) {
return nil, fail("file_not_found", 404)
}
token, err := s.boundToken(ctx, r["owner"], obj(r["githubAccess"]), name)
if err != nil {
return nil, err
}
req, _ := http.NewRequestWithContext(ctx, "GET", "https://api.github.com/repos/"+name+"/git/blobs/"+sha, nil)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Accept", "application/vnd.github.raw+json")
req.Header.Set("User-Agent", "Anonymous-GitHub")
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
if err := s.checkGitHubQuota(ctx, token); err != nil {
return nil, err
}
response, err := s.HTTP.Do(req)
if err != nil {
return nil, fail("github_unavailable", 502)
}
if githubLimited(response) {
response.Body.Close()
return nil, s.recordGitHubQuota(ctx, token, response.Header)
}
if response.StatusCode != 200 {
response.Body.Close()
if response.StatusCode == 403 || response.StatusCode == 404 || response.StatusCode == 422 {
return s.rawBlob(ctx, r, row, token)
}
return nil, fail("file_not_available", 502)
}
if response.ContentLength > anonymize.MaxInput {
response.Body.Close()
return nil, fail("file_too_big", 413)
}
return s.inspectBlob(ctx, response.Body, r, row, token)
}
func providerRecord(a provider.Access) M {
owner, _ := oid(a.OwnerID)
return M{"repoId": a.Ref.AnonymousID, "owner": owner, "source": M{"repositoryName": a.Ref.Name, "branch": a.Ref.Branch, "commit": a.Ref.Commit}, "githubAccess": M(a.Binding)}
}
func (g *githubProvider) Branch(ctx context.Context, a provider.Access, name string) (provider.Commit, error) {
branches, e := g.Branches(ctx, a)
if e != nil {
return provider.Commit{}, e
}
for _, b := range branches {
if b.Name == name {
return g.Commit(ctx, a, b.Commit)
}
}
return provider.Commit{}, provider.ErrBranchNotFound
}
func (g *githubProvider) FileMetadata(ctx context.Context, a provider.Access, requested string) (*provider.Entry, error) {
relative, err := security.RelativePath(requested)
if err != nil || relative != requested || !safeRepositoryName(a.Ref.Name) || !commitID.MatchString(a.Ref.Commit) {
return nil, fail("invalid_source", 400)
}
token, err := g.token(ctx, a)
if err != nil {
return nil, err
}
parts := strings.Split(requested, "/")
for i := range parts {
parts[i] = segment(parts[i])
}
raw, err := g.server.githubObject(ctx, "GET", "/repos/"+a.Ref.Name+"/contents/"+strings.Join(parts, "/")+"?ref="+url.QueryEscape(a.Ref.Commit), token, nil)
if err != nil {
var upstream *githubHTTPError
if errors.As(err, &upstream) && upstream.status == 404 {
return nil, nil
}
return nil, err
}
if str(raw["type"]) != "file" || str(raw["sha"]) == "" {
return nil, nil
}
return &provider.Entry{Path: requested, Revision: str(raw["sha"]), Size: number(raw["size"])}, nil
}