From 760e4a9b03efe38de0e51da52f01c0e579ec4a0b Mon Sep 17 00:00:00 2001 From: Rogee Date: Mon, 17 Nov 2025 10:38:06 +0800 Subject: [PATCH] add composer --- internal/proxy/composer_rewrite.go | 132 ++++++++++++++++++++--- internal/proxy/handler.go | 36 +++++++ tests/integration/composer_proxy_test.go | 42 +++++++- 3 files changed, 197 insertions(+), 13 deletions(-) diff --git a/internal/proxy/composer_rewrite.go b/internal/proxy/composer_rewrite.go index 85e0502..db35b56 100644 --- a/internal/proxy/composer_rewrite.go +++ b/internal/proxy/composer_rewrite.go @@ -17,6 +17,9 @@ func (h *Handler) rewriteComposerResponse(route *server.HubRoute, resp *http.Res if resp == nil || route == nil || route.Config.Type != "composer" { return resp, nil } + if path == "/packages.json" { + return rewriteComposerRoot(resp, route.Config.Domain) + } if !isComposerMetadataPath(path) { return resp, nil } @@ -45,6 +48,32 @@ func (h *Handler) rewriteComposerResponse(route *server.HubRoute, resp *http.Res return resp, nil } +func rewriteComposerRoot(resp *http.Response, domain string) (*http.Response, error) { + body, err := io.ReadAll(resp.Body) + if err != nil { + return resp, err + } + resp.Body.Close() + + data, changed, err := rewriteComposerRootBody(body, domain) + if err != nil { + resp.Body = io.NopCloser(bytes.NewReader(body)) + return resp, err + } + if !changed { + resp.Body = io.NopCloser(bytes.NewReader(body)) + return resp, nil + } + + resp.Body = io.NopCloser(bytes.NewReader(data)) + resp.ContentLength = int64(len(data)) + resp.Header.Set("Content-Length", strconv.Itoa(len(data))) + resp.Header.Set("Content-Type", "application/json") + resp.Header.Del("Content-Encoding") + resp.Header.Del("Etag") + return resp, nil +} + func rewriteComposerMetadata(body []byte, domain string) ([]byte, bool, error) { type packagesRoot struct { Packages map[string]json.RawMessage `json:"packages"` @@ -59,7 +88,7 @@ func rewriteComposerMetadata(body []byte, domain string) ([]byte, bool, error) { changed := false for name, raw := range root.Packages { - updated, rewritten, err := rewriteComposerPackagesPayload(raw, domain) + updated, rewritten, err := rewriteComposerPackagesPayload(raw, domain, name) if err != nil { return nil, false, err } @@ -78,10 +107,10 @@ func rewriteComposerMetadata(body []byte, domain string) ([]byte, bool, error) { return data, true, nil } -func rewriteComposerPackagesPayload(raw json.RawMessage, domain string) (json.RawMessage, bool, error) { +func rewriteComposerPackagesPayload(raw json.RawMessage, domain string, packageName string) (json.RawMessage, bool, error) { var asArray []map[string]any if err := json.Unmarshal(raw, &asArray); err == nil { - rewrote := rewriteComposerVersionSlice(asArray, domain) + rewrote := rewriteComposerVersionSlice(asArray, domain, packageName) if !rewrote { return raw, false, nil } @@ -91,7 +120,7 @@ func rewriteComposerPackagesPayload(raw json.RawMessage, domain string) (json.Ra var asMap map[string]map[string]any if err := json.Unmarshal(raw, &asMap); err == nil { - rewrote := rewriteComposerVersionMap(asMap, domain) + rewrote := rewriteComposerVersionMap(asMap, domain, packageName) if !rewrote { return raw, false, nil } @@ -102,41 +131,48 @@ func rewriteComposerPackagesPayload(raw json.RawMessage, domain string) (json.Ra return raw, false, nil } -func rewriteComposerVersionSlice(items []map[string]any, domain string) bool { +func rewriteComposerVersionSlice(items []map[string]any, domain string, packageName string) bool { changed := false for _, entry := range items { - if rewriteComposerVersion(entry, domain) { + if rewriteComposerVersion(entry, domain, packageName) { changed = true } } return changed } -func rewriteComposerVersionMap(items map[string]map[string]any, domain string) bool { +func rewriteComposerVersionMap(items map[string]map[string]any, domain string, packageName string) bool { changed := false for _, entry := range items { - if rewriteComposerVersion(entry, domain) { + if rewriteComposerVersion(entry, domain, packageName) { changed = true } } return changed } -func rewriteComposerVersion(entry map[string]any, domain string) bool { +func rewriteComposerVersion(entry map[string]any, domain string, packageName string) bool { if entry == nil { return false } + changed := false + if packageName != "" { + if name, _ := entry["name"].(string); strings.TrimSpace(name) == "" { + entry["name"] = packageName + changed = true + } + } distVal, ok := entry["dist"].(map[string]any) if !ok { - return false + return changed } urlValue, ok := distVal["url"].(string) if !ok || urlValue == "" { - return false + return changed } rewritten := rewriteComposerDistURL(domain, urlValue) if rewritten == urlValue { - return false + return changed } distVal["url"] = rewritten return true @@ -181,3 +217,75 @@ func isComposerMetadataPath(path string) bool { func isComposerDistPath(path string) bool { return strings.HasPrefix(path, "/dist/") } + +func rewriteComposerAbsolute(domain, raw string) string { + if raw == "" { + return raw + } + if strings.HasPrefix(raw, "//") { + return "https://" + domain + strings.TrimPrefix(raw, "//") + } + if strings.HasPrefix(raw, "http://") || strings.HasPrefix(raw, "https://") { + parsed, err := url.Parse(raw) + if err != nil { + return raw + } + parsed.Host = domain + parsed.Scheme = "https" + return parsed.String() + } + pathVal := raw + if !strings.HasPrefix(pathVal, "/") { + pathVal = "/" + pathVal + } + return fmt.Sprintf("https://%s%s", domain, pathVal) +} + +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 := range []string{"metadata-url", "providers-api", "providers-url", "notify-batch"} { + if raw, ok := root[key].(string); ok && raw != "" { + newVal := rewriteComposerAbsolute(domain, raw) + if newVal != raw { + root[key] = newVal + changed = true + } + } + } + + if includes, ok := root["provider-includes"].(map[string]any); ok { + for file, hashVal := range includes { + pathVal := file + if rawPath, ok := hashVal.(map[string]any); ok { + if urlValue, ok := rawPath["url"].(string); ok { + pathVal = urlValue + } + } + newPath := rewriteComposerAbsolute(domain, pathVal) + if newPath != pathVal { + changed = true + } + if rawPath, ok := hashVal.(map[string]any); ok { + rawPath["url"] = newPath + includes[file] = rawPath + } else { + includes[file] = newPath + } + } + } + + if !changed { + return body, false, nil + } + + data, err := json.Marshal(root) + if err != nil { + return nil, false, err + } + return data, true, nil +} diff --git a/internal/proxy/handler.go b/internal/proxy/handler.go index 6df5404..ad17bb9 100644 --- a/internal/proxy/handler.go +++ b/internal/proxy/handler.go @@ -133,6 +133,38 @@ func (h *Handler) serveCache( contentType = sniffed } } + if route != nil && route.Config.Type == "composer" && isComposerMetadataPath(stripQueryMarker(result.Entry.Locator.Path)) { + body, err := io.ReadAll(result.Reader) + result.Reader.Close() + if err != nil { + return fiber.NewError(fiber.StatusBadGateway, fmt.Sprintf("read cache failed: %v", err)) + } + rewritten := body + if stripQueryMarker(result.Entry.Locator.Path) == "/packages.json" { + if data, changed, err := rewriteComposerRootBody(body, route.Config.Domain); err == nil && changed { + rewritten = data + } + } else { + if data, changed, err := rewriteComposerMetadata(body, route.Config.Domain); err == nil && changed { + rewritten = data + } + } + + c.Set("Content-Type", "application/json") + c.Set("X-Any-Hub-Upstream", route.UpstreamURL.String()) + c.Set("X-Any-Hub-Cache-Hit", "true") + if requestID != "" { + c.Set("X-Request-ID", requestID) + } + c.Status(fiber.StatusOK) + c.Response().Header.SetContentLength(len(rewritten)) + _, err = c.Response().BodyWriter().Write(rewritten) + h.logResult(route, route.UpstreamURL.String(), requestID, fiber.StatusOK, true, started, err) + if err != nil { + return fiber.NewError(fiber.StatusBadGateway, fmt.Sprintf("read cache failed: %v", err)) + } + return nil + } if contentType != "" { c.Set("Content-Type", contentType) } else { @@ -508,6 +540,10 @@ func buildLocator(route *server.HubRoute, c fiber.Ctx) cache.Locator { clean = newPath } query := uri.QueryString() + if route != nil && route.Config.Type == "composer" && isComposerDistPath(clean) { + // composer dist URLs often embed per-request tokens; ignore query for cache key + query = nil + } if len(query) > 0 { sum := sha1.Sum(query) clean = fmt.Sprintf("%s/__qs/%s", clean, hex.EncodeToString(sum[:])) diff --git a/tests/integration/composer_proxy_test.go b/tests/integration/composer_proxy_test.go index 2c2d8c1..752a90c 100644 --- a/tests/integration/composer_proxy_test.go +++ b/tests/integration/composer_proxy_test.go @@ -8,6 +8,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "strings" "sync" "testing" "time" @@ -75,6 +76,25 @@ func TestComposerProxyCachesMetadataAndDists(t *testing.T) { return resp } + rootResp := doRequest("/packages.json") + if rootResp.StatusCode != fiber.StatusOK { + t.Fatalf("expected 200 for packages.json, got %d", rootResp.StatusCode) + } + rootBody, _ := io.ReadAll(rootResp.Body) + rootResp.Body.Close() + var root map[string]any + if err := json.Unmarshal(rootBody, &root); err != nil { + t.Fatalf("parse packages.json: %v", err) + } + metaURL, _ := root["metadata-url"].(string) + assertProxyURL(t, "metadata-url", metaURL) + if providersURL, _ := root["providers-url"].(string); providersURL != "" { + assertProxyURL(t, "providers-url", providersURL) + } + if notifyURL, _ := root["notify-batch"].(string); notifyURL != "" { + assertProxyURL(t, "notify-batch", notifyURL) + } + metaPath := "/p2/example/package.json" resp := doRequest(metaPath) if resp.StatusCode != fiber.StatusOK { @@ -222,7 +242,17 @@ func (s *composerStub) buildMetadata() []byte { func (s *composerStub) handlePackages(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"packages":{}}`)) + payload := map[string]any{ + "packages": map[string]any{}, + "metadata-url": "p2/%package%.json", + "providers-url": "p/%package%$%hash%.json", + "notify-batch": "/downloads/", + "provider-includes": map[string]any{ + "p/provider-latest$%hash%.json": map[string]any{"sha256": "dummy"}, + }, + } + data, _ := json.Marshal(payload) + _, _ = w.Write(data) } func (s *composerStub) handleMetadata(w http.ResponseWriter, r *http.Request) { @@ -261,6 +291,16 @@ func (s *composerStub) DistContent() string { return s.distBody } +func assertProxyURL(t *testing.T, field, val string) { + t.Helper() + if val == "" { + t.Fatalf("%s should not be empty", field) + } + if !strings.HasPrefix(val, "https://composer.hub.local/") { + t.Fatalf("%s should point to proxy host, got %s", field, val) + } +} + func (s *composerStub) Close() { if s == nil { return