package handler import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "path" "strings" "time" "github.com/git-pkgs/purl" ) const ( composerUpstream = "https://packagist.org" composerRepo = "https://repo.packagist.org" composerUnset = "__unset" vendorPackageParts = 2 ) // ComposerHandler handles Composer/Packagist registry protocol requests. type ComposerHandler struct { proxy *Proxy upstreamURL string repoURL string proxyURL string } // NewComposerHandler creates a new Composer protocol handler. func NewComposerHandler(proxy *Proxy, proxyURL string) *ComposerHandler { return &ComposerHandler{ proxy: proxy, upstreamURL: composerUpstream, repoURL: composerRepo, proxyURL: strings.TrimSuffix(proxyURL, "/"), } } // Routes returns the HTTP handler for Composer requests. func (h *ComposerHandler) Routes() http.Handler { mux := http.NewServeMux() // Service index mux.HandleFunc("GET /packages.json", h.handleServiceIndex) // Package metadata (Composer v2 format) - use prefix since {package}.json isn't allowed mux.HandleFunc("GET /p2/", h.handlePackageMetadata) // Package downloads mux.HandleFunc("GET /files/{vendor}/{package}/{version}/{filename}", h.handleDownload) // Search and list (proxy without modification) mux.HandleFunc("GET /search.json", h.proxyUpstream) mux.HandleFunc("GET /packages/list.json", h.proxyUpstream) return mux } // handleServiceIndex returns the Composer repository service index. func (h *ComposerHandler) handleServiceIndex(w http.ResponseWriter, r *http.Request) { // Return a minimal service index pointing to our proxy index := map[string]any{ "packages": map[string]any{}, "metadata-url": h.proxyURL + "/composer/p2/%package%.json", "notify-batch": h.upstreamURL + "/downloads/", "search": h.proxyURL + "/composer/search.json?q=%query%&type=%type%", "providers-lazy-url": h.proxyURL + "/composer/p2/%package%.json", } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(index) } // handlePackageMetadata proxies and rewrites package metadata. func (h *ComposerHandler) handlePackageMetadata(w http.ResponseWriter, r *http.Request) { // Parse path: /p2/{vendor}/{package}.json path := strings.TrimPrefix(r.URL.Path, "/p2/") path = strings.TrimSuffix(path, ".json") parts := strings.SplitN(path, "/", vendorPackageParts) if len(parts) != vendorPackageParts || parts[0] == "" || parts[1] == "" { http.Error(w, "invalid package path", http.StatusBadRequest) return } vendor := parts[0] pkg := parts[1] packageName := vendor + "/" + pkg h.proxy.Logger.Info("composer metadata request", "package", packageName) upstreamURL := fmt.Sprintf("%s/p2/%s/%s.json", h.repoURL, vendor, pkg) body, _, err := h.proxy.FetchOrCacheMetadata(r.Context(), "composer", packageName, upstreamURL) if err != nil { if errors.Is(err, ErrUpstreamNotFound) { http.Error(w, "not found", http.StatusNotFound) return } h.proxy.Logger.Error("upstream request failed", "error", err) http.Error(w, "upstream request failed", http.StatusBadGateway) return } rewritten, err := h.rewriteMetadata(body) if err != nil { h.proxy.Logger.Warn("failed to rewrite metadata, proxying original", "error", err) w.Header().Set("Content-Type", "application/json") _, _ = w.Write(body) return } w.Header().Set("Content-Type", "application/json") _, _ = w.Write(rewritten) } // rewriteMetadata rewrites dist URLs in Composer metadata to point at this proxy. // If the metadata uses the minified Composer v2 format, it is expanded first so // that every version entry contains all fields. If cooldown is enabled, versions // published too recently are filtered out. func (h *ComposerHandler) rewriteMetadata(body []byte) ([]byte, error) { var metadata map[string]any if err := json.Unmarshal(body, &metadata); err != nil { return nil, err } packages, ok := metadata["packages"].(map[string]any) if !ok { return body, nil } minified := metadata["minified"] == "composer/2.0" for packageName, versions := range packages { versionList, ok := versions.([]any) if !ok { continue } if minified { versionList = expandMinifiedVersions(versionList) } packages[packageName] = h.filterAndRewriteVersions(packageName, versionList) } delete(metadata, "minified") return json.Marshal(metadata) } // expandMinifiedVersions expands the Composer v2 minified format where each // version entry only contains fields that differ from the previous entry. // The "~dev" sentinel string resets the inheritance chain, and the "__unset" // value removes a field from the inherited state. func expandMinifiedVersions(versionList []any) []any { expanded := make([]any, 0, len(versionList)) inherited := map[string]any{} for _, v := range versionList { // The "~dev" sentinel resets the inheritance chain for dev versions. if s, ok := v.(string); ok && s == "~dev" { inherited = map[string]any{} continue } vmap, ok := v.(map[string]any) if !ok { continue } // Merge inherited fields into a new map, then overlay current fields. // Deep copy values to avoid shared references between versions. merged := make(map[string]any, len(inherited)+len(vmap)) for k, val := range inherited { merged[k] = deepCopyValue(val) } for k, val := range vmap { if val == composerUnset { delete(merged, k) continue } merged[k] = val } // Update inherited state for next iteration. inherited = merged expanded = append(expanded, merged) } return expanded } // deepCopyValue returns a deep copy of JSON-like values (maps, slices, scalars). func deepCopyValue(v any) any { switch val := v.(type) { case map[string]any: m := make(map[string]any, len(val)) for k, v := range val { m[k] = deepCopyValue(v) } return m case []any: s := make([]any, len(val)) for i, v := range val { s[i] = deepCopyValue(v) } return s default: return v } } // filterAndRewriteVersions applies cooldown filtering and rewrites dist URLs // for a single package's version list. func (h *ComposerHandler) filterAndRewriteVersions(packageName string, versionList []any) []any { packagePURL := purl.MakePURLString("composer", packageName, "") filtered := versionList[:0] for _, v := range versionList { vmap, ok := v.(map[string]any) if !ok { continue } version, _ := vmap["version"].(string) if h.shouldFilterVersion(packagePURL, packageName, version, vmap) { continue } h.rewriteDistURL(vmap, packageName, version) filtered = append(filtered, v) } return filtered } // shouldFilterVersion returns true if the version should be excluded due to cooldown. func (h *ComposerHandler) shouldFilterVersion(packagePURL, packageName, version string, vmap map[string]any) bool { if h.proxy.Cooldown == nil || !h.proxy.Cooldown.Enabled() { return false } timeStr, ok := vmap["time"].(string) if !ok { return false } publishedAt, err := time.Parse(time.RFC3339, timeStr) if err != nil { return false } if !h.proxy.Cooldown.IsAllowed("composer", packagePURL, publishedAt) { h.proxy.Logger.Info("cooldown: filtering composer version", "package", packageName, "version", version) return true } return false } // rewriteDistURL rewrites the dist URL in a version entry to point at this proxy. func (h *ComposerHandler) rewriteDistURL(vmap map[string]any, packageName, version string) { dist, ok := vmap["dist"].(map[string]any) if !ok { return } url, ok := dist["url"].(string) if !ok || url == "" { return } filename := "package.zip" if idx := strings.LastIndex(url, "/"); idx >= 0 { filename = url[idx+1:] } // GitHub zipball URLs end with a bare commit hash (no extension). // Append .zip so the archives library can detect the format. if path.Ext(filename) == "" { if distType, _ := dist["type"].(string); distType == "zip" { filename += ".zip" } } parts := strings.SplitN(packageName, "/", vendorPackageParts) if len(parts) == vendorPackageParts { newURL := fmt.Sprintf("%s/composer/files/%s/%s/%s/%s", h.proxyURL, parts[0], parts[1], version, filename) dist["url"] = newURL } } // handleDownload serves a package file, fetching and caching from upstream if needed. func (h *ComposerHandler) handleDownload(w http.ResponseWriter, r *http.Request) { vendor := r.PathValue("vendor") pkg := r.PathValue("package") version := r.PathValue("version") filename := r.PathValue("filename") packageName := vendor + "/" + pkg h.proxy.Logger.Info("composer download request", "package", packageName, "version", version, "filename", filename) // We need to fetch the metadata to get the actual download URL since // Packagist URLs include a hash. Packagist serves dev versions (e.g. // "3.x-dev", "dev-master") from a separate "~dev" metadata file, while // tagged releases live in the regular file. Try the file most likely to // contain this version first, then fall back to the other so that both // stable and dev versions resolve correctly. metaURLs := h.metadataURLsForVersion(vendor, pkg, version) h.proxy.Logger.Debug("resolving download URL", "package", packageName, "version", version, "metadata_urls", metaURLs) var downloadURL string for _, metaURL := range metaURLs { url, err := h.findDownloadURLFromMetadata(r.Context(), metaURL, packageName, version) if err != nil { h.proxy.Logger.Error("failed to fetch metadata", "error", err, "url", metaURL) http.Error(w, "failed to fetch metadata", http.StatusBadGateway) return } if url != "" { downloadURL = url break } } if downloadURL == "" { h.proxy.Logger.Debug("version not found in any metadata source", "package", packageName, "version", version, "tried_urls", metaURLs) http.Error(w, "version not found", http.StatusNotFound) return } h.proxy.Logger.Debug("resolved download URL", "package", packageName, "version", version, "download_url", downloadURL) result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "composer", packageName, version, filename, downloadURL) if err != nil { h.proxy.Logger.Error("failed to get artifact", "error", err) http.Error(w, "failed to fetch package", http.StatusBadGateway) return } ServeArtifact(w, result) } // isDevVersion reports whether a Composer version string refers to a // development (unstable, branch) version rather than a tagged release. // Composer formats these as either "dev-" (e.g. "dev-master") or // "-dev" (e.g. "3.x-dev"). func isDevVersion(version string) bool { return strings.HasPrefix(version, "dev-") || strings.HasSuffix(version, "-dev") } // metadataURLsForVersion returns the upstream metadata URLs to consult for a // given version, in priority order. Dev versions are served from the "~dev" // file, tagged releases from the regular file; the other file is included as a // fallback so an unexpected classification still resolves. func (h *ComposerHandler) metadataURLsForVersion(vendor, pkg, version string) []string { stable := fmt.Sprintf("%s/p2/%s/%s.json", h.repoURL, vendor, pkg) dev := fmt.Sprintf("%s/p2/%s/%s~dev.json", h.repoURL, vendor, pkg) if isDevVersion(version) { return []string{dev, stable} } return []string{stable, dev} } // findDownloadURLFromMetadata fetches a metadata document and returns the dist // URL for the given version, or an empty string if the version is not present. // An error is returned only on transport failure; a missing document (non-200) // or a missing version both yield an empty string so the caller can fall back. func (h *ComposerHandler) findDownloadURLFromMetadata(ctx context.Context, metaURL, packageName, version string) (string, error) { h.proxy.Logger.Debug("fetching upstream metadata for download lookup", "url", metaURL, "package", packageName, "version", version) req, err := http.NewRequestWithContext(ctx, http.MethodGet, metaURL, nil) if err != nil { return "", err } resp, err := h.proxy.HTTPClient.Do(req) if err != nil { return "", err } defer func() { _ = resp.Body.Close() }() h.proxy.Logger.Debug("upstream metadata response", "url", metaURL, "status", resp.StatusCode) if resp.StatusCode != http.StatusOK { return "", nil } var metadata map[string]any if err := json.NewDecoder(resp.Body).Decode(&metadata); err != nil { return "", err } // Expand minified Composer v2 format so that inherited fields (including // dist) are present on every version entry. Without this, versions that // inherit dist from a previous entry will appear to have no download URL. if metadata["minified"] == "composer/2.0" { h.proxy.Logger.Debug("expanding minified metadata", "url", metaURL) if packages, ok := metadata["packages"].(map[string]any); ok { for pkgName, versions := range packages { versionList, ok := versions.([]any) if !ok { continue } packages[pkgName] = expandMinifiedVersions(versionList) } } } url := h.findDownloadURL(metadata, packageName, version) h.proxy.Logger.Debug("download URL lookup result", "url", metaURL, "package", packageName, "version", version, "download_url", url) return url, nil } // findDownloadURL finds the dist URL for a specific version in metadata. func (h *ComposerHandler) findDownloadURL(metadata map[string]any, packageName, version string) string { packages, ok := metadata["packages"].(map[string]any) if !ok { return "" } versions, ok := packages[packageName].([]any) if !ok { return "" } for _, v := range versions { vmap, ok := v.(map[string]any) if !ok { continue } if vmap["version"] == version { if dist, ok := vmap["dist"].(map[string]any); ok { if url, ok := dist["url"].(string); ok { return url } } } } return "" } // proxyUpstream forwards a request to packagist.org without caching. func (h *ComposerHandler) proxyUpstream(w http.ResponseWriter, r *http.Request) { upstreamURL := h.upstreamURL + r.URL.Path if r.URL.RawQuery != "" { upstreamURL += "?" + r.URL.RawQuery } h.proxy.Logger.Debug("proxying to upstream", "url", upstreamURL) req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, upstreamURL, nil) if err != nil { http.Error(w, "failed to create request", http.StatusInternalServerError) return } resp, err := h.proxy.HTTPClient.Do(req) if err != nil { h.proxy.Logger.Error("upstream request failed", "error", err) http.Error(w, "upstream request failed", http.StatusBadGateway) return } defer func() { _ = resp.Body.Close() }() for k, vv := range resp.Header { for _, v := range vv { w.Header().Add(k, v) } } w.WriteHeader(resp.StatusCode) _, _ = io.Copy(w, resp.Body) }