mirror of
https://github.com/git-pkgs/proxy.git
synced 2026-08-01 10:08:08 -04:00
NormalizedPackages runs config keys through purl.Parse().String(), which applies per-type rules like lowercasing pypi names. The handler side was building lookup keys with MakePURLString, which does not, so a config entry for pkg:pypi/Django would be normalized to pkg:pypi/django and never match the runtime key pkg:pypi/Django. Route both sides through the same canonical form: a new canonicalPackagePURL helper calls Normalize() on the constructed PURL before stringifying, and all cooldown IsAllowed call sites use it.
494 lines
14 KiB
Go
494 lines
14 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"path"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
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 := canonicalPackagePURL("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-<branch>" (e.g. "dev-master") or
|
|
// "<alias>-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)
|
|
}
|