mirror of
https://github.com/git-pkgs/proxy.git
synced 2026-07-07 14:53:20 -04:00
459 lines
13 KiB
Go
459 lines
13 KiB
Go
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)
|
|
|
|
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 == "" {
|
|
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)
|
|
}
|
|
|
|
// 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) {
|
|
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() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", nil
|
|
}
|
|
|
|
var metadata map[string]any
|
|
if err := json.NewDecoder(resp.Body).Decode(&metadata); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return h.findDownloadURL(metadata, packageName, version), 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)
|
|
}
|