From 8235615feef02f66ed74bb50eb70e09870bb2cb2 Mon Sep 17 00:00:00 2001 From: Rogee Date: Mon, 17 Nov 2025 16:47:59 +0800 Subject: [PATCH] feat: fix composer --- internal/hubmodule/composer/hooks.go | 292 ++++++++++++++++++---- internal/hubmodule/composer/hooks_test.go | 65 ++++- tests/integration/composer_proxy_test.go | 31 ++- 3 files changed, 332 insertions(+), 56 deletions(-) diff --git a/internal/hubmodule/composer/hooks.go b/internal/hubmodule/composer/hooks.go index 331fd4c..1fb539d 100644 --- a/internal/hubmodule/composer/hooks.go +++ b/internal/hubmodule/composer/hooks.go @@ -3,11 +3,15 @@ package composer import ( "encoding/json" "net/url" + "path" "strings" + "sync" "github.com/any-hub/any-hub/internal/proxy/hooks" ) +var composerDistRegistry sync.Map + func init() { hooks.MustRegister("composer", hooks.Hooks{ NormalizePath: normalizePath, @@ -19,13 +23,23 @@ func init() { } func normalizePath(_ *hooks.RequestContext, clean string, rawQuery []byte) (string, []byte) { + if trimmed := trimComposerNamespace(clean); trimmed != clean { + clean = trimmed + } if isComposerDistPath(clean) { return clean, nil } return clean, rawQuery } -func resolveDistUpstream(_ *hooks.RequestContext, _ string, clean string, rawQuery []byte) string { +func resolveDistUpstream(ctx *hooks.RequestContext, _ string, clean string, rawQuery []byte) string { + domain := "" + if ctx != nil { + domain = ctx.Domain + } + if target := resolveComposerMirrorDist(domain, clean); target != "" { + return target + } if !isComposerDistPath(clean) { return "" } @@ -43,9 +57,10 @@ func rewriteResponse( body []byte, path string, ) (int, map[string]string, []byte, error) { + cleanPath := trimComposerNamespace(path) switch { - case path == "/packages.json": - data, changed, err := rewriteComposerRootBody(body) + case cleanPath == "/packages.json": + data, changed, err := rewriteComposerRootBody(body, ctx.Domain) if err != nil { return status, headers, body, err } @@ -54,7 +69,7 @@ func rewriteResponse( } outHeaders := ensureJSONHeaders(headers) return status, outHeaders, data, nil - case isComposerMetadataPath(path): + case isComposerMetadataPath(cleanPath): data, changed, err := rewriteComposerMetadata(body, ctx.Domain) if err != nil { return status, headers, body, err @@ -104,26 +119,21 @@ func contentType(_ *hooks.RequestContext, locatorPath string) string { return "" } -func rewriteComposerRootBody(body []byte) ([]byte, bool, error) { - // packages.json from Packagist may contain "packages" as array or object; we only care about URL-like fields. +func rewriteComposerRootBody(body []byte, domain string) ([]byte, bool, error) { var root map[string]any if err := json.Unmarshal(body, &root); err != nil { return nil, false, err } changed := false - for key, val := range root { - str, ok := val.(string) - if !ok { - continue - } - switch strings.ToLower(key) { - // case "metadata-url", "providers-url", "providers-lazy-url", "notify", "notify-batch", "search": - case "metadata-url": - str = strings.ReplaceAll(str, "https://repo.packagist.org", "") - root[key] = str - changed = true - } + if rewriteComposerRootURLField(root, "metadata-url", domain) { + changed = true + } + if rewriteComposerRootURLField(root, "providers-url", domain) { + changed = true + } + if ensureComposerMirrors(root, domain) { + changed = true } if !changed { @@ -136,6 +146,43 @@ func rewriteComposerRootBody(body []byte) ([]byte, bool, error) { return data, true, nil } +func rewriteComposerRootURLField(root map[string]any, key string, domain string) bool { + value, ok := root[key].(string) + if !ok || value == "" { + return false + } + proxied := buildComposerProxyURL(value, domain) + if proxied == value { + return false + } + root[key] = proxied + return true +} + +func ensureComposerMirrors(root map[string]any, domain string) bool { + domain = strings.TrimSpace(domain) + if domain == "" { + return false + } + target := "https://" + domain + "/dists/%package%/%reference%.%type%" + if existing, ok := root["mirrors"].([]any); ok && len(existing) == 1 { + if entry, ok := existing[0].(map[string]any); ok { + distURL, _ := entry["dist-url"].(string) + preferred, _ := entry["preferred"].(bool) + if distURL == target && preferred { + return false + } + } + } + root["mirrors"] = []map[string]any{ + { + "dist-url": target, + "preferred": true, + }, + } + return true +} + func rewriteComposerMetadata(body []byte, domain string) ([]byte, bool, error) { type packagesRoot struct { Packages map[string]json.RawMessage `json:"packages"` @@ -236,7 +283,12 @@ func rewriteComposerVersion(entry map[string]any, domain string, packageName str if !ok || urlValue == "" { return changed } - rewritten := rewriteComposerDistURL(urlValue) + reference, _ := distVal["reference"].(string) + distType, _ := distVal["type"].(string) + if packageName != "" && domain != "" && reference != "" && distType != "" { + registerComposerDist(domain, packageName, reference, distType, urlValue) + } + rewritten := rewriteComposerLegacyDistURL(urlValue, domain) if rewritten == urlValue { return changed } @@ -244,38 +296,58 @@ func rewriteComposerVersion(entry map[string]any, domain string, packageName str return true } -func rewriteComposerDistURL(original string) string { - parsed, err := url.Parse(original) - if err != nil || parsed.Scheme == "" || parsed.Host == "" { +func rewriteComposerLegacyDistURL(original string, domain string) string { + trimmed := strings.TrimSpace(original) + if trimmed == "" { return original } - if isPackagistHost(parsed.Host) { - pathVal := parsed.Path - if raw := parsed.RawPath; raw != "" { - pathVal = raw - } - if !strings.HasPrefix(pathVal, "/") { - pathVal = "/" + pathVal - } - if parsed.RawQuery != "" { - return pathVal + "?" + parsed.RawQuery - } - return pathVal + parsed, err := url.Parse(trimmed) + if err != nil { + return original } - return original + if domain != "" && strings.EqualFold(parsed.Host, domain) && strings.HasPrefix(parsed.Path, "/dist/") { + // Already rewritten. + return original + } + if parsed.Scheme == "" || parsed.Host == "" { + return original + } + pathVal := parsed.Path + if raw := parsed.RawPath; raw != "" { + pathVal = raw + } + if !strings.HasPrefix(pathVal, "/") { + pathVal = "/" + pathVal + } + var builder strings.Builder + builder.WriteString("/dist/") + builder.WriteString(parsed.Scheme) + builder.WriteString("/") + builder.WriteString(parsed.Host) + builder.WriteString(pathVal) + if parsed.RawQuery != "" { + builder.WriteString("?") + builder.WriteString(parsed.RawQuery) + } + proxiedPath := builder.String() + if domain == "" { + return proxiedPath + } + return buildComposerProxyURL(proxiedPath, domain) } func isComposerMetadataPath(path string) bool { + clean := trimComposerNamespace(path) switch { - case path == "/packages.json": + case clean == "/packages.json": return true - case strings.HasPrefix(path, "/p2/"): + case strings.HasPrefix(clean, "/p2/"): return true - case strings.HasPrefix(path, "/p/"): + case strings.HasPrefix(clean, "/p/"): return true - case strings.HasPrefix(path, "/provider-"): + case strings.HasPrefix(clean, "/provider-"): return true - case strings.HasPrefix(path, "/providers/"): + case strings.HasPrefix(clean, "/providers/"): return true default: return false @@ -283,14 +355,19 @@ func isComposerMetadataPath(path string) bool { } func isComposerDistPath(path string) bool { - return strings.HasPrefix(path, "/dist/") + clean := trimComposerNamespace(path) + if strings.HasPrefix(clean, "/dist/") { + return true + } + return strings.HasPrefix(clean, "/dists/") } func parseComposerDistURL(path string, rawQuery string) (*url.URL, bool) { - if !strings.HasPrefix(path, "/dist/") { + clean := trimComposerNamespace(path) + if !strings.HasPrefix(clean, "/dist/") { return nil, false } - trimmed := strings.TrimPrefix(path, "/dist/") + trimmed := strings.TrimPrefix(clean, "/dist/") parts := strings.SplitN(trimmed, "/", 3) if len(parts) < 3 { return nil, false @@ -328,3 +405,132 @@ func stripPackagistHost(raw string) string { func isPackagistHost(host string) bool { return strings.EqualFold(host, "repo.packagist.org") } + +func buildComposerProxyURL(raw string, domain string) string { + trimmed := stripPackagistHost(strings.TrimSpace(raw)) + if trimmed == "" { + return trimmed + } + if parsed, err := url.Parse(trimmed); err == nil && parsed.Host != "" { + if domain != "" && strings.EqualFold(parsed.Host, domain) { + return trimmed + } + if !isPackagistHost(parsed.Host) { + return trimmed + } + if path := parsed.EscapedPath(); path != "" { + trimmed = path + if parsed.RawQuery != "" { + trimmed = trimmed + "?" + parsed.RawQuery + } + } + } + + if !strings.HasPrefix(trimmed, "/") { + trimmed = "/" + trimmed + } + + if domain == "" { + return trimmed + } + return "https://" + domain + trimmed +} + +func resolveComposerMirrorDist(domain string, locator string) string { + domain = strings.TrimSpace(domain) + if domain == "" { + return "" + } + pkg, reference, distType, ok := parseComposerMirrorDistLocator(locator) + if !ok { + return "" + } + target, ok := lookupComposerDist(domain, pkg, reference, distType) + if !ok { + return "" + } + return target +} + +func parseComposerMirrorDistLocator(locator string) (string, string, string, bool) { + clean := trimComposerNamespace(locator) + if !strings.HasPrefix(clean, "/dists/") { + return "", "", "", false + } + trimmed := strings.TrimPrefix(clean, "/dists/") + lastSlash := strings.LastIndex(trimmed, "/") + if lastSlash <= 0 || lastSlash >= len(trimmed)-1 { + return "", "", "", false + } + packagePart := trimmed[:lastSlash] + file := trimmed[lastSlash+1:] + if packagePart == "" || file == "" { + return "", "", "", false + } + ext := path.Ext(file) + if ext == "" { + return "", "", "", false + } + reference := strings.TrimSuffix(file, ext) + distType := strings.TrimPrefix(ext, ".") + if reference == "" || distType == "" { + return "", "", "", false + } + packageName := strings.ToLower(strings.Trim(packagePart, "/")) + if packageName == "" { + return "", "", "", false + } + return packageName, reference, distType, true +} + +func registerComposerDist(domain string, packageName string, reference string, distType string, upstream string) { + key := composerDistKey(domain, packageName, reference, distType) + if key == "" || strings.TrimSpace(upstream) == "" { + return + } + composerDistRegistry.Store(key, upstream) +} + +func lookupComposerDist(domain string, packageName string, reference string, distType string) (string, bool) { + key := composerDistKey(domain, packageName, reference, distType) + if key == "" { + return "", false + } + value, ok := composerDistRegistry.Load(key) + if !ok { + return "", false + } + str, _ := value.(string) + if strings.TrimSpace(str) == "" { + return "", false + } + return str, true +} + +func composerDistKey(domain string, packageName string, reference string, distType string) string { + domain = strings.ToLower(strings.TrimSpace(domain)) + pkg := strings.ToLower(strings.TrimSpace(packageName)) + ref := strings.TrimSpace(reference) + typ := strings.ToLower(strings.TrimSpace(distType)) + if domain == "" || pkg == "" || ref == "" || typ == "" { + return "" + } + return domain + "|" + pkg + "|" + ref + "|" + typ +} + +func trimComposerNamespace(p string) string { + if strings.HasPrefix(p, "/composer/") { + return strings.TrimPrefix(p, "/composer") + } + if p == "/composer" { + return "/" + } + return p +} + +func resetComposerDistRegistry() { + composerDistRegistry.Range(func(key, _ any) bool { + composerDistRegistry.Delete(key) + return true + }) +} diff --git a/internal/hubmodule/composer/hooks_test.go b/internal/hubmodule/composer/hooks_test.go index 7ba2fa5..56124a1 100644 --- a/internal/hubmodule/composer/hooks_test.go +++ b/internal/hubmodule/composer/hooks_test.go @@ -1,7 +1,7 @@ package composer import ( - "strings" + "encoding/json" "testing" "github.com/any-hub/any-hub/internal/proxy/hooks" @@ -24,9 +24,20 @@ func TestResolveDistUpstream(t *testing.T) { } } -func TestRewriteResponseUpdatesURLs(t *testing.T) { +func TestResolveMirrorDistUpstream(t *testing.T) { + resetComposerDistRegistry() + registerComposerDist("cache.example", "vendor/pkg", "abc123", "zip", "https://github.com/org/repo.zip") ctx := &hooks.RequestContext{Domain: "cache.example"} - body := []byte(`{"packages":{"a/b":{"1.0.0":{"dist":{"url":"https://repo.packagist.org/dist/package.zip"}}}}}`) + url := resolveDistUpstream(ctx, "", "/composer/dists/vendor/pkg/abc123.zip", nil) + if url != "https://github.com/org/repo.zip" { + t.Fatalf("unexpected upstream %s", url) + } +} + +func TestRewriteResponseUpdatesURLs(t *testing.T) { + resetComposerDistRegistry() + ctx := &hooks.RequestContext{Domain: "cache.example"} + body := []byte(`{"packages":{"a/b":{"1.0.0":{"dist":{"url":"https://api.github.com/repos/org/repo/zipball/ref","reference":"abc123","type":"zip"}}}}}`) _, headers, rewritten, err := rewriteResponse(ctx, 200, map[string]string{}, body, "/p2/a/b.json") if err != nil { t.Fatalf("rewrite failed: %v", err) @@ -37,7 +48,51 @@ func TestRewriteResponseUpdatesURLs(t *testing.T) { if headers["Content-Type"] != "application/json" { t.Fatalf("expected json content type") } - if !strings.Contains(string(rewritten), "/dist/package.zip") { - t.Fatalf("expected stripped packagist host, got %s", string(rewritten)) + var payload map[string]any + if err := json.Unmarshal(rewritten, &payload); err != nil { + t.Fatalf("unmarshal: %v", err) + } + pkgs := payload["packages"].(map[string]any) + versions := pkgs["a/b"].(map[string]any) + version := versions["1.0.0"].(map[string]any) + dist := version["dist"].(map[string]any) + distURL := dist["url"].(string) + expected := "https://cache.example/dist/https/api.github.com/repos/org/repo/zipball/ref" + if distURL != expected { + t.Fatalf("expected dist url %s, got %s", expected, distURL) + } +} + +func TestRewritePackagesRoot(t *testing.T) { + resetComposerDistRegistry() + ctx := &hooks.RequestContext{Domain: "cache.example"} + body := []byte(`{"metadata-url":"https://repo.packagist.org/p2/%package%.json","providers-url":"/p/%package%$%hash%.json"}`) + _, headers, rewritten, err := rewriteResponse(ctx, 200, map[string]string{}, body, "/packages.json") + if err != nil { + t.Fatalf("rewrite failed: %v", err) + } + if headers["Content-Type"] != "application/json" { + t.Fatalf("expected json content type") + } + var payload map[string]any + if err := json.Unmarshal(rewritten, &payload); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if payload["metadata-url"] != "https://cache.example/composer/p2/%package%.json" { + t.Fatalf("metadata URL not rewritten: %v", payload["metadata-url"]) + } + if payload["providers-url"] != "https://cache.example/composer/p/%package%$%hash%.json" { + t.Fatalf("providers URL not rewritten: %v", payload["providers-url"]) + } + mirrors, _ := payload["mirrors"].([]any) + if len(mirrors) == 0 { + t.Fatalf("mirrors missing") + } + entry, _ := mirrors[0].(map[string]any) + if entry["dist-url"] != "https://cache.example/composer/dists/%package%/%reference%.%type%" { + t.Fatalf("unexpected mirror dist-url: %v", entry["dist-url"]) + } + if pref, _ := entry["preferred"].(bool); !pref { + t.Fatalf("mirror preferred flag missing") } } diff --git a/tests/integration/composer_proxy_test.go b/tests/integration/composer_proxy_test.go index 752a90c..3fd14f5 100644 --- a/tests/integration/composer_proxy_test.go +++ b/tests/integration/composer_proxy_test.go @@ -91,11 +91,25 @@ func TestComposerProxyCachesMetadataAndDists(t *testing.T) { if providersURL, _ := root["providers-url"].(string); providersURL != "" { assertProxyURL(t, "providers-url", providersURL) } - if notifyURL, _ := root["notify-batch"].(string); notifyURL != "" { - assertProxyURL(t, "notify-batch", notifyURL) + if notifyURL, _ := root["notify-batch"].(string); notifyURL != "https://packagist.org/downloads/" { + t.Fatalf("notify-batch should remain packagist, got %s", notifyURL) + } + if mirrors, _ := root["mirrors"].([]any); len(mirrors) == 0 { + t.Fatalf("expected mirrors entry in packages root") + } else { + if entry, ok := mirrors[0].(map[string]any); ok { + if distURL, _ := entry["dist-url"].(string); distURL != "https://composer.hub.local/composer/dists/%package%/%reference%.%type%" { + t.Fatalf("unexpected mirrors dist-url: %s", distURL) + } + if preferred, _ := entry["preferred"].(bool); !preferred { + t.Fatalf("mirrors entry should be preferred") + } + } else { + t.Fatalf("unexpected mirrors payload: %#v", mirrors[0]) + } } - metaPath := "/p2/example/package.json" + metaPath := "/composer/p2/example/package.json" resp := doRequest(metaPath) if resp.StatusCode != fiber.StatusOK { t.Fatalf("expected 200 for composer metadata, got %d", resp.StatusCode) @@ -229,8 +243,9 @@ func (s *composerStub) buildMetadata() []byte { "name": "example/package", "version": "1.0.0", "dist": map[string]any{ - "type": "zip", - "url": s.URL + s.distPath, + "type": "zip", + "url": s.URL + s.distPath, + "reference": "abc123", }, }, }, @@ -244,9 +259,9 @@ func (s *composerStub) handlePackages(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") payload := map[string]any{ "packages": map[string]any{}, - "metadata-url": "p2/%package%.json", - "providers-url": "p/%package%$%hash%.json", - "notify-batch": "/downloads/", + "metadata-url": "https://repo.packagist.org/p2/%package%.json", + "providers-url": "https://repo.packagist.org/p/%package%$%hash%.json", + "notify-batch": "https://packagist.org/downloads/", "provider-includes": map[string]any{ "p/provider-latest$%hash%.json": map[string]any{"sha256": "dummy"}, },