pkg-proxy/internal/handler/composer.go

411 lines
11 KiB
Go
Raw Permalink Normal View History

2026-01-20 21:52:44 +00:00
package handler
import (
"encoding/json"
"errors"
2026-01-20 21:52:44 +00:00
"fmt"
"io"
"net/http"
"path"
2026-01-20 21:52:44 +00:00
"strings"
"time"
"github.com/git-pkgs/purl"
2026-01-20 21:52:44 +00:00
)
const (
composerUpstream = "https://packagist.org"
composerRepo = "https://repo.packagist.org"
vendorPackageParts = 2
2026-01-20 21:52:44 +00:00
)
// 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%",
2026-01-20 21:52:44 +00:00
"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] == "" {
2026-01-20 21:52:44 +00:00
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)
2026-01-20 21:52:44 +00:00
if err != nil {
if errors.Is(err, ErrUpstreamNotFound) {
http.Error(w, "not found", http.StatusNotFound)
return
}
2026-01-20 21:52:44 +00:00
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.
Fix Composer minified metadata expansion and namespaced package routing (#63) * Fix Composer minified metadata expansion and namespaced package routing Packagist serves metadata in a minified format where only the first version entry has all fields and subsequent entries inherit from the previous one. The proxy was passing this through without expanding it, which meant cooldown filtering could break the inheritance chain (losing fields like `name`) and `~dev` sentinel markers were silently dropped. The proxy now expands the minified format before filtering and rewriting, ensuring every version entry is self-contained. Web UI and API routes used single-segment chi URL params for package names, which broke for Composer's `vendor/name` format. `/package/composer/monolog/monolog` would match the version show route instead of the package show route. All `/package/` and related API routes now use wildcard paths with a `resolvePackageName` helper that tries increasingly longer path prefixes as package names via DB lookup, correctly handling namespaced packages across all endpoints (show, version, browse, compare, vulns). Fixes #61, fixes #62 * Add namespaced package routing tests for all affected ecosystems Verifies the wildcard routing handles slashes in package names for npm (@babel/core), Go modules (github.com/stretchr/testify), OCI images (library/nginx), Conda (conda-forge/numpy), and Conan (zlib/1.2.13@demo/stable). * Regenerate swagger docs after route refactor The swagger annotations for the old per-endpoint handlers were removed during the wildcard routing refactor. Regenerate to match current state.
2026-04-06 13:07:02 +01:00
// 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.
2026-01-20 21:52:44 +00:00
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
}
Fix Composer minified metadata expansion and namespaced package routing (#63) * Fix Composer minified metadata expansion and namespaced package routing Packagist serves metadata in a minified format where only the first version entry has all fields and subsequent entries inherit from the previous one. The proxy was passing this through without expanding it, which meant cooldown filtering could break the inheritance chain (losing fields like `name`) and `~dev` sentinel markers were silently dropped. The proxy now expands the minified format before filtering and rewriting, ensuring every version entry is self-contained. Web UI and API routes used single-segment chi URL params for package names, which broke for Composer's `vendor/name` format. `/package/composer/monolog/monolog` would match the version show route instead of the package show route. All `/package/` and related API routes now use wildcard paths with a `resolvePackageName` helper that tries increasingly longer path prefixes as package names via DB lookup, correctly handling namespaced packages across all endpoints (show, version, browse, compare, vulns). Fixes #61, fixes #62 * Add namespaced package routing tests for all affected ecosystems Verifies the wildcard routing handles slashes in package names for npm (@babel/core), Go modules (github.com/stretchr/testify), OCI images (library/nginx), Conda (conda-forge/numpy), and Conan (zlib/1.2.13@demo/stable). * Regenerate swagger docs after route refactor The swagger annotations for the old per-endpoint handlers were removed during the wildcard routing refactor. Regenerate to match current state.
2026-04-06 13:07:02 +01:00
minified := metadata["minified"] == "composer/2.0"
2026-01-20 21:52:44 +00:00
for packageName, versions := range packages {
versionList, ok := versions.([]any)
if !ok {
continue
}
Fix Composer minified metadata expansion and namespaced package routing (#63) * Fix Composer minified metadata expansion and namespaced package routing Packagist serves metadata in a minified format where only the first version entry has all fields and subsequent entries inherit from the previous one. The proxy was passing this through without expanding it, which meant cooldown filtering could break the inheritance chain (losing fields like `name`) and `~dev` sentinel markers were silently dropped. The proxy now expands the minified format before filtering and rewriting, ensuring every version entry is self-contained. Web UI and API routes used single-segment chi URL params for package names, which broke for Composer's `vendor/name` format. `/package/composer/monolog/monolog` would match the version show route instead of the package show route. All `/package/` and related API routes now use wildcard paths with a `resolvePackageName` helper that tries increasingly longer path prefixes as package names via DB lookup, correctly handling namespaced packages across all endpoints (show, version, browse, compare, vulns). Fixes #61, fixes #62 * Add namespaced package routing tests for all affected ecosystems Verifies the wildcard routing handles slashes in package names for npm (@babel/core), Go modules (github.com/stretchr/testify), OCI images (library/nginx), Conda (conda-forge/numpy), and Conan (zlib/1.2.13@demo/stable). * Regenerate swagger docs after route refactor The swagger annotations for the old per-endpoint handlers were removed during the wildcard routing refactor. Regenerate to match current state.
2026-04-06 13:07:02 +01:00
if minified {
versionList = expandMinifiedVersions(versionList)
}
packages[packageName] = h.filterAndRewriteVersions(packageName, versionList)
}
Fix Composer minified metadata expansion and namespaced package routing (#63) * Fix Composer minified metadata expansion and namespaced package routing Packagist serves metadata in a minified format where only the first version entry has all fields and subsequent entries inherit from the previous one. The proxy was passing this through without expanding it, which meant cooldown filtering could break the inheritance chain (losing fields like `name`) and `~dev` sentinel markers were silently dropped. The proxy now expands the minified format before filtering and rewriting, ensuring every version entry is self-contained. Web UI and API routes used single-segment chi URL params for package names, which broke for Composer's `vendor/name` format. `/package/composer/monolog/monolog` would match the version show route instead of the package show route. All `/package/` and related API routes now use wildcard paths with a `resolvePackageName` helper that tries increasingly longer path prefixes as package names via DB lookup, correctly handling namespaced packages across all endpoints (show, version, browse, compare, vulns). Fixes #61, fixes #62 * Add namespaced package routing tests for all affected ecosystems Verifies the wildcard routing handles slashes in package names for npm (@babel/core), Go modules (github.com/stretchr/testify), OCI images (library/nginx), Conda (conda-forge/numpy), and Conan (zlib/1.2.13@demo/stable). * Regenerate swagger docs after route refactor The swagger annotations for the old per-endpoint handlers were removed during the wildcard routing refactor. Regenerate to match current state.
2026-04-06 13:07:02 +01:00
delete(metadata, "minified")
return json.Marshal(metadata)
}
Fix Composer minified metadata expansion and namespaced package routing (#63) * Fix Composer minified metadata expansion and namespaced package routing Packagist serves metadata in a minified format where only the first version entry has all fields and subsequent entries inherit from the previous one. The proxy was passing this through without expanding it, which meant cooldown filtering could break the inheritance chain (losing fields like `name`) and `~dev` sentinel markers were silently dropped. The proxy now expands the minified format before filtering and rewriting, ensuring every version entry is self-contained. Web UI and API routes used single-segment chi URL params for package names, which broke for Composer's `vendor/name` format. `/package/composer/monolog/monolog` would match the version show route instead of the package show route. All `/package/` and related API routes now use wildcard paths with a `resolvePackageName` helper that tries increasingly longer path prefixes as package names via DB lookup, correctly handling namespaced packages across all endpoints (show, version, browse, compare, vulns). Fixes #61, fixes #62 * Add namespaced package routing tests for all affected ecosystems Verifies the wildcard routing handles slashes in package names for npm (@babel/core), Go modules (github.com/stretchr/testify), OCI images (library/nginx), Conda (conda-forge/numpy), and Conan (zlib/1.2.13@demo/stable). * Regenerate swagger docs after route refactor The swagger annotations for the old per-endpoint handlers were removed during the wildcard routing refactor. Regenerate to match current state.
2026-04-06 13:07:02 +01:00
// 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.
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.
Fix Composer minified metadata expansion and namespaced package routing (#63) * Fix Composer minified metadata expansion and namespaced package routing Packagist serves metadata in a minified format where only the first version entry has all fields and subsequent entries inherit from the previous one. The proxy was passing this through without expanding it, which meant cooldown filtering could break the inheritance chain (losing fields like `name`) and `~dev` sentinel markers were silently dropped. The proxy now expands the minified format before filtering and rewriting, ensuring every version entry is self-contained. Web UI and API routes used single-segment chi URL params for package names, which broke for Composer's `vendor/name` format. `/package/composer/monolog/monolog` would match the version show route instead of the package show route. All `/package/` and related API routes now use wildcard paths with a `resolvePackageName` helper that tries increasingly longer path prefixes as package names via DB lookup, correctly handling namespaced packages across all endpoints (show, version, browse, compare, vulns). Fixes #61, fixes #62 * Add namespaced package routing tests for all affected ecosystems Verifies the wildcard routing handles slashes in package names for npm (@babel/core), Go modules (github.com/stretchr/testify), OCI images (library/nginx), Conda (conda-forge/numpy), and Conan (zlib/1.2.13@demo/stable). * Regenerate swagger docs after route refactor The swagger annotations for the old per-endpoint handlers were removed during the wildcard routing refactor. Regenerate to match current state.
2026-04-06 13:07:02 +01:00
merged := make(map[string]any, len(inherited)+len(vmap))
for k, val := range inherited {
merged[k] = deepCopyValue(val)
Fix Composer minified metadata expansion and namespaced package routing (#63) * Fix Composer minified metadata expansion and namespaced package routing Packagist serves metadata in a minified format where only the first version entry has all fields and subsequent entries inherit from the previous one. The proxy was passing this through without expanding it, which meant cooldown filtering could break the inheritance chain (losing fields like `name`) and `~dev` sentinel markers were silently dropped. The proxy now expands the minified format before filtering and rewriting, ensuring every version entry is self-contained. Web UI and API routes used single-segment chi URL params for package names, which broke for Composer's `vendor/name` format. `/package/composer/monolog/monolog` would match the version show route instead of the package show route. All `/package/` and related API routes now use wildcard paths with a `resolvePackageName` helper that tries increasingly longer path prefixes as package names via DB lookup, correctly handling namespaced packages across all endpoints (show, version, browse, compare, vulns). Fixes #61, fixes #62 * Add namespaced package routing tests for all affected ecosystems Verifies the wildcard routing handles slashes in package names for npm (@babel/core), Go modules (github.com/stretchr/testify), OCI images (library/nginx), Conda (conda-forge/numpy), and Conan (zlib/1.2.13@demo/stable). * Regenerate swagger docs after route refactor The swagger annotations for the old per-endpoint handlers were removed during the wildcard routing refactor. Regenerate to match current state.
2026-04-06 13:07:02 +01:00
}
for k, val := range vmap {
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
2026-01-20 21:52:44 +00:00
}
version, _ := vmap["version"].(string)
if h.shouldFilterVersion(packagePURL, packageName, version, vmap) {
continue
}
h.rewriteDistURL(vmap, packageName, version)
filtered = append(filtered, v)
2026-01-20 21:52:44 +00:00
}
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
}
2026-01-20 21:52:44 +00:00
}
// 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
metaURL := fmt.Sprintf("%s/p2/%s/%s.json", h.repoURL, vendor, pkg)
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, metaURL, nil)
if err != nil {
http.Error(w, "failed to create request", http.StatusInternalServerError)
return
}
resp, err := h.proxy.HTTPClient.Do(req)
2026-01-20 21:52:44 +00:00
if err != nil {
h.proxy.Logger.Error("failed to fetch metadata", "error", err)
http.Error(w, "failed to fetch metadata", http.StatusBadGateway)
return
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
http.Error(w, "package not found", http.StatusNotFound)
return
}
var metadata map[string]any
if err := json.NewDecoder(resp.Body).Decode(&metadata); err != nil {
http.Error(w, "failed to parse metadata", http.StatusInternalServerError)
return
}
// Find the download URL for this version
downloadURL := h.findDownloadURL(metadata, packageName, version)
if downloadURL == "" {
http.Error(w, "version not found", http.StatusNotFound)
return
}
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)
}
// 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)
2026-01-20 21:52:44 +00:00
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)
}