package composer import ( "encoding/json" "net/url" "path" "strings" "sync" "github.com/any-hub/any-hub/internal/proxy/hooks" ) var composerDists = newDistRegistry() type distRegistry struct { sync.RWMutex items map[string]string } func newDistRegistry() *distRegistry { return &distRegistry{items: map[string]string{}} } func (r *distRegistry) remember(domain, pkg, reference, distType, upstream string) { key := composerDistKey(domain, pkg, reference, distType) if key == "" || strings.TrimSpace(upstream) == "" { return } r.Lock() r.items[key] = upstream r.Unlock() } func (r *distRegistry) lookup(domain, pkg, reference, distType string) (string, bool) { key := composerDistKey(domain, pkg, reference, distType) if key == "" { return "", false } r.RLock() val, ok := r.items[key] r.RUnlock() if !ok || strings.TrimSpace(val) == "" { return "", false } return val, true } func (r *distRegistry) reset() { r.Lock() r.items = map[string]string{} r.Unlock() } func init() { hooks.MustRegister("composer", hooks.Hooks{ NormalizePath: normalizePath, ResolveUpstream: resolveDistUpstream, RewriteResponse: rewriteResponse, CachePolicy: cachePolicy, ContentType: contentType, }) } 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(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 "" } target, ok := parseComposerDistURL(clean, string(rawQuery)) if !ok { return "" } return target.String() } func rewriteResponse( ctx *hooks.RequestContext, status int, headers map[string]string, body []byte, path string, ) (int, map[string]string, []byte, error) { cleanPath := trimComposerNamespace(path) switch { case cleanPath == "/packages.json": data, changed, err := rewriteComposerRootBody(body, ctx.Domain) if err != nil { return status, headers, body, err } if !changed { return status, headers, body, nil } outHeaders := ensureJSONHeaders(headers) return status, outHeaders, data, nil case isComposerMetadataPath(cleanPath): data, changed, err := rewriteComposerMetadata(body, ctx.Domain) if err != nil { return status, headers, body, err } if !changed { return status, headers, body, nil } outHeaders := ensureJSONHeaders(headers) return status, outHeaders, data, nil default: return status, headers, body, nil } } func ensureJSONHeaders(headers map[string]string) map[string]string { if headers == nil { headers = map[string]string{} } headers["Content-Type"] = "application/json" delete(headers, "Content-Encoding") delete(headers, "Etag") return headers } func cachePolicy(_ *hooks.RequestContext, locatorPath string, current hooks.CachePolicy) hooks.CachePolicy { switch { case isComposerDistPath(locatorPath): current.AllowCache = true current.AllowStore = true current.RequireRevalidate = false case isComposerMetadataPath(locatorPath): current.AllowCache = true current.AllowStore = true current.RequireRevalidate = true default: current.AllowCache = false current.AllowStore = false current.RequireRevalidate = false } return current } func contentType(_ *hooks.RequestContext, locatorPath string) string { if isComposerMetadataPath(locatorPath) { return "application/json" } return "" } func rewriteComposerRootBody(body []byte, domain string) ([]byte, bool, error) { domain = strings.TrimSpace(domain) var root map[string]any if err := json.Unmarshal(body, &root); err != nil { return nil, false, err } changed := false changed = rewriteComposerRootURLField(root, "metadata-url", domain) || changed changed = rewriteComposerRootURLField(root, "providers-url", domain) || changed changed = ensureComposerMirrors(root, domain) || changed if !changed { return body, false, nil } data, err := json.Marshal(root) if err != nil { return nil, false, err } 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 == "" { return false } changed := proxied != value root[key] = proxied return changed } 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) { domain = strings.TrimSpace(domain) if domain == "" { return body, false, nil } type packagesRoot struct { Packages map[string]json.RawMessage `json:"packages"` } var root packagesRoot if err := json.Unmarshal(body, &root); err != nil { return nil, false, err } if len(root.Packages) == 0 { return body, false, nil } changed := false for name, raw := range root.Packages { updated, rewritten, err := rewriteComposerPackagesPayload(raw, domain, name) if err != nil { return nil, false, err } if rewritten { root.Packages[name] = updated changed = true } } if !changed { return body, false, nil } data, err := json.Marshal(root) if err != nil { return nil, false, err } return data, true, nil } 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, packageName) if !rewrote { return raw, false, nil } data, err := json.Marshal(asArray) return data, true, err } var asMap map[string]map[string]any if err := json.Unmarshal(raw, &asMap); err == nil { rewrote := rewriteComposerVersionMap(asMap, domain, packageName) if !rewrote { return raw, false, nil } data, err := json.Marshal(asMap) return data, true, err } return raw, false, nil } func rewriteComposerVersionSlice(items []map[string]any, domain string, packageName string) bool { changed := false for _, entry := range items { if rewriteComposerVersion(entry, domain, packageName) { changed = true } } return changed } func rewriteComposerVersionMap(items map[string]map[string]any, domain string, packageName string) bool { changed := false for _, entry := range items { if rewriteComposerVersion(entry, domain, packageName) { changed = true } } return changed } func rewriteComposerVersion(entry map[string]any, domain string, packageName string) bool { if entry == nil { return false } domain = strings.TrimSpace(domain) 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 changed } urlValue, ok := distVal["url"].(string) if !ok || urlValue == "" { return changed } reference, _ := distVal["reference"].(string) distType, _ := distVal["type"].(string) if packageName != "" && domain != "" && reference != "" && distType != "" { composerDists.remember(domain, packageName, reference, distType, urlValue) } rewritten := rewriteComposerLegacyDistURL(urlValue, domain) if rewritten == urlValue { return changed } distVal["url"] = rewritten return true } func rewriteComposerLegacyDistURL(original string, domain string) string { trimmed := strings.TrimSpace(original) if trimmed == "" { return original } parsed, err := url.Parse(trimmed) if err != nil { 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 clean == "/packages.json": return true case strings.HasPrefix(clean, "/p2/"): return true case strings.HasPrefix(clean, "/p/"): return true case strings.HasPrefix(clean, "/provider-"): return true case strings.HasPrefix(clean, "/providers/"): return true default: return false } } func isComposerDistPath(path string) bool { clean := trimComposerNamespace(path) if strings.HasPrefix(clean, "/dist/") { return true } return strings.HasPrefix(clean, "/dists/") } func parseComposerDistURL(path string, rawQuery string) (*url.URL, bool) { clean := trimComposerNamespace(path) if !strings.HasPrefix(clean, "/dist/") { return nil, false } trimmed := strings.TrimPrefix(clean, "/dist/") parts := strings.SplitN(trimmed, "/", 3) if len(parts) < 3 { return nil, false } scheme := parts[0] host := parts[1] rest := parts[2] if scheme == "" || host == "" { return nil, false } if rest == "" { rest = "/" } else { rest = "/" + rest } target := &url.URL{ Scheme: scheme, Host: host, Path: rest, RawPath: rest, } if rawQuery != "" { target.RawQuery = rawQuery } return target, true } func isPackagistHost(host string) bool { return strings.EqualFold(host, "repo.packagist.org") } func stripPackagistPrefix(raw string) (string, bool) { for _, prefix := range []string{ "https://repo.packagist.org", "http://repo.packagist.org", } { if strings.HasPrefix(raw, prefix) { trimmed := strings.TrimPrefix(raw, prefix) if trimmed == "" { return "/", true } if !strings.HasPrefix(trimmed, "/") { trimmed = "/" + trimmed } return trimmed, true } } return "", false } func buildComposerProxyURL(raw string, domain string) string { value := strings.TrimSpace(raw) if value == "" { return "" } if strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") { parsed, err := url.Parse(value) switch { case err != nil: if trimmed, ok := stripPackagistPrefix(value); ok { value = trimmed } else { return value } case domain != "" && strings.EqualFold(parsed.Host, domain): return value case !isPackagistHost(parsed.Host): return value default: value = parsed.EscapedPath() if value == "" { value = "/" } if parsed.RawQuery != "" { value += "?" + parsed.RawQuery } } } pathOnly, query := splitPathAndQuery(value) pathOnly = ensureLeadingSlash(pathOnly) if domain == "" { return pathOnly + query } return "https://" + domain + pathOnly + query } 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 := composerDists.lookup(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 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 { return ensureLeadingSlash(p) } func resetComposerDistRegistry() { composerDists.reset() } func splitPathAndQuery(raw string) (string, string) { if idx := strings.IndexByte(raw, '?'); idx >= 0 { return raw[:idx], raw[idx:] } return raw, "" } func ensureLeadingSlash(p string) string { p = strings.TrimSpace(p) if p == "" { return "/" } if !strings.HasPrefix(p, "/") { p = "/" + p } return path.Clean(p) }