2026-01-20 21:52:44 +00:00
|
|
|
package handler
|
|
|
|
|
|
|
|
|
|
import (
|
2026-10-10 08:31:37 +02:00
|
|
|
"bytes"
|
2026-06-26 13:51:08 +02:00
|
|
|
"context"
|
2026-01-20 21:52:44 +00:00
|
|
|
"encoding/json"
|
Fix metadata caching, 404 propagation, mirror progress, and registry stubs
- ProxyCached now stores upstream Last-Modified in the cache and uses it
(along with ETag) for conditional request handling, returning 304 when
client validators match. Adds Content-Length to cached responses.
- Handlers calling FetchOrCacheMetadata (pypi, composer, pub, nuget) now
check for ErrUpstreamNotFound and return 404 instead of 502, matching
the existing npm and cargo behavior.
- Mirror jobs report live progress via a periodic callback while running,
so API polls return real counts instead of zeroed progress.
- Registry mirroring removed from CLI flags, API acceptance, README, and
docs since every enumerator was a stub returning "not yet implemented".
- Added tests for the conditional metadata path (ETag/If-None-Match,
Last-Modified/If-Modified-Since, 304 responses, header omission).
2026-04-01 20:14:11 +01:00
|
|
|
"errors"
|
2026-01-20 21:52:44 +00:00
|
|
|
"fmt"
|
|
|
|
|
"net/http"
|
2026-10-12 09:56:57 +02:00
|
|
|
"net/url"
|
2026-04-06 19:06:48 +01:00
|
|
|
"path"
|
2026-10-11 17:57:37 +02:00
|
|
|
"regexp"
|
2026-01-20 21:52:44 +00:00
|
|
|
"strings"
|
2026-03-04 19:00:31 +00:00
|
|
|
"time"
|
2026-10-11 00:25:33 +02:00
|
|
|
|
2026-10-18 17:59:14 +01:00
|
|
|
"github.com/git-pkgs/proxy/internal/jsonscan"
|
2026-10-11 00:25:33 +02:00
|
|
|
"github.com/git-pkgs/proxy/internal/retention"
|
|
|
|
|
"github.com/git-pkgs/purl"
|
2026-01-20 21:52:44 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const (
|
2026-03-18 10:59:29 +00:00
|
|
|
composerUpstream = "https://packagist.org"
|
|
|
|
|
composerRepo = "https://repo.packagist.org"
|
2026-10-10 08:31:37 +02:00
|
|
|
composerMinified = "composer/2.0"
|
2026-05-19 06:54:33 -05:00
|
|
|
composerUnset = "__unset"
|
2026-10-10 08:31:37 +02:00
|
|
|
composerDevReset = "~dev"
|
Add package routes for private Composer and npm registries (#426)
* Add Composer package routes for private repositories
- Add upstream.composer_routes mapping package name patterns to repositories
- Serve matching packages only from their route, with no fallback to the default repository
- Qualify metadata and artifact cache keys with the route's upstream
- Give routed versions an empty notification-url so installs are not reported to notify-batch
- Document routes in configuration docs, example config and README
* Add npm package routes for private registries
- Add upstream.npm_routes mapping package name patterns to registries
- Serve matching packages only from their route, with no fallback to upstream.npm
- Resolve and validate tarball URLs against the route's registry
- Qualify metadata and artifact cache keys with the route's upstream
- Document npm routes and the audit endpoint limitation
* Share routed artifact filename qualification between handlers
* Match Composer routes without the ~dev suffix
Packagist serves a package's dev versions from {name}~dev.json, and the
handler matched routes against the name with ~dev still attached. An exact
route for example/library therefore missed /p2/example/library~dev.json, and
the private package's dev metadata came from the default repository.
- Match routes against the package name without ~dev, keeping it in the
upstream URL and the cache key so the two files stay apart
- Add a regression test with an exact route
* Keep routed npm publish times out of the shared versions row
The download cooldown check stores publish times under the plain version
PURL, which does not say which registry a version came from. A time read
from the default registry was trusted for a private version of the same
name, so a private release published an hour ago passed a seven-day
cooldown when the public one was a month old.
- Skip the stored row for routed packages, both reading and writing, and
take the publish time from the route's metadata, which is cached under
the route
- This also stops a private time from holding back the public version, and
a route's previous upstream from lending its times to the new one
- Cover adding a route after a public download, the reverse order, and
moving a route to another upstream
2026-10-11 19:06:18 +02:00
|
|
|
composerDevFile = "~dev" // suffix of the metadata file holding dev versions
|
2026-03-18 10:59:29 +00:00
|
|
|
vendorPackageParts = 2
|
2026-01-20 21:52:44 +00:00
|
|
|
)
|
|
|
|
|
|
2026-10-10 08:31:37 +02:00
|
|
|
var composerUnsetJSON = []byte(`"` + composerUnset + `"`)
|
|
|
|
|
|
2026-10-11 00:25:33 +02:00
|
|
|
// Archives are stored under pkg:composer/{vendor}/{package}, which purl
|
|
|
|
|
// lowercases, so a package rule matches whatever case it is written in. The
|
|
|
|
|
// spec also claims the "packagist" spelling git-pkgs writes into
|
|
|
|
|
// packages.ecosystem.
|
|
|
|
|
func init() {
|
|
|
|
|
retention.Default.Register(retention.Spec{Key: "composer", CanonicalPackage: composerRetentionKey})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// composerRetentionKey maps a package rule to the stored package PURL. A
|
|
|
|
|
// key without a vendor parses fine but can never match, since every stored
|
|
|
|
|
// package has one, so it is refused.
|
|
|
|
|
func composerRetentionKey(p *purl.PURL) (string, bool) {
|
|
|
|
|
if p == nil || p.Namespace == "" {
|
|
|
|
|
return "", false
|
|
|
|
|
}
|
|
|
|
|
return retention.DefaultCanonical("composer")(p)
|
|
|
|
|
}
|
|
|
|
|
|
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
|
Add package routes for private Composer and npm registries (#426)
* Add Composer package routes for private repositories
- Add upstream.composer_routes mapping package name patterns to repositories
- Serve matching packages only from their route, with no fallback to the default repository
- Qualify metadata and artifact cache keys with the route's upstream
- Give routed versions an empty notification-url so installs are not reported to notify-batch
- Document routes in configuration docs, example config and README
* Add npm package routes for private registries
- Add upstream.npm_routes mapping package name patterns to registries
- Serve matching packages only from their route, with no fallback to upstream.npm
- Resolve and validate tarball URLs against the route's registry
- Qualify metadata and artifact cache keys with the route's upstream
- Document npm routes and the audit endpoint limitation
* Share routed artifact filename qualification between handlers
* Match Composer routes without the ~dev suffix
Packagist serves a package's dev versions from {name}~dev.json, and the
handler matched routes against the name with ~dev still attached. An exact
route for example/library therefore missed /p2/example/library~dev.json, and
the private package's dev metadata came from the default repository.
- Match routes against the package name without ~dev, keeping it in the
upstream URL and the cache key so the two files stay apart
- Add a regression test with an exact route
* Keep routed npm publish times out of the shared versions row
The download cooldown check stores publish times under the plain version
PURL, which does not say which registry a version came from. A time read
from the default registry was trusted for a private version of the same
name, so a private release published an hour ago passed a seven-day
cooldown when the public one was a month old.
- Skip the stored row for routed packages, both reading and writing, and
take the publish time from the route's metadata, which is cached under
the route
- This also stops a private time from holding back the public version, and
a route's previous upstream from lending its times to the new one
- Cover adding a route after a public download, the reverse order, and
moving a route to another upstream
2026-10-11 19:06:18 +02:00
|
|
|
routes packageRoutes
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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, "/"),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-29 11:19:54 +01:00
|
|
|
// NewComposerHandlerWithUpstreams creates a Composer handler with custom API
|
|
|
|
|
// and repository upstreams.
|
|
|
|
|
func NewComposerHandlerWithUpstreams(proxy *Proxy, proxyURL, upstreamURL, repoURL string) *ComposerHandler {
|
|
|
|
|
h := NewComposerHandler(proxy, proxyURL)
|
|
|
|
|
h.upstreamURL = configuredUpstreamURL(upstreamURL, composerUpstream)
|
|
|
|
|
h.repoURL = configuredUpstreamURL(repoURL, composerRepo)
|
|
|
|
|
return h
|
|
|
|
|
}
|
|
|
|
|
|
Add package routes for private Composer and npm registries (#426)
* Add Composer package routes for private repositories
- Add upstream.composer_routes mapping package name patterns to repositories
- Serve matching packages only from their route, with no fallback to the default repository
- Qualify metadata and artifact cache keys with the route's upstream
- Give routed versions an empty notification-url so installs are not reported to notify-batch
- Document routes in configuration docs, example config and README
* Add npm package routes for private registries
- Add upstream.npm_routes mapping package name patterns to registries
- Serve matching packages only from their route, with no fallback to upstream.npm
- Resolve and validate tarball URLs against the route's registry
- Qualify metadata and artifact cache keys with the route's upstream
- Document npm routes and the audit endpoint limitation
* Share routed artifact filename qualification between handlers
* Match Composer routes without the ~dev suffix
Packagist serves a package's dev versions from {name}~dev.json, and the
handler matched routes against the name with ~dev still attached. An exact
route for example/library therefore missed /p2/example/library~dev.json, and
the private package's dev metadata came from the default repository.
- Match routes against the package name without ~dev, keeping it in the
upstream URL and the cache key so the two files stay apart
- Add a regression test with an exact route
* Keep routed npm publish times out of the shared versions row
The download cooldown check stores publish times under the plain version
PURL, which does not say which registry a version came from. A time read
from the default registry was trusted for a private version of the same
name, so a private release published an hour ago passed a seven-day
cooldown when the public one was a month old.
- Skip the stored row for routed packages, both reading and writing, and
take the publish time from the route's metadata, which is cached under
the route
- This also stops a private time from holding back the public version, and
a route's previous upstream from lending its times to the new one
- Cover adding a route after a public download, the reverse order, and
moving a route to another upstream
2026-10-11 19:06:18 +02:00
|
|
|
// WithPackageRoutes sends packages matching a pattern to a dedicated Composer
|
|
|
|
|
// repository instead of the default one. See packageRoutes.
|
|
|
|
|
func (h *ComposerHandler) WithPackageRoutes(routes map[string]string) *ComposerHandler {
|
|
|
|
|
h.routes = newPackageRoutes(routes)
|
|
|
|
|
return h
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
// 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{
|
2026-04-18 07:43:22 -04:00
|
|
|
"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",
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-02 13:22:02 +01:00
|
|
|
w.Header().Set(headerContentType, "application/json")
|
2026-01-20 21:52:44 +00:00
|
|
|
_ = json.NewEncoder(w).Encode(index)
|
|
|
|
|
}
|
|
|
|
|
|
Add package routes for private Composer and npm registries (#426)
* Add Composer package routes for private repositories
- Add upstream.composer_routes mapping package name patterns to repositories
- Serve matching packages only from their route, with no fallback to the default repository
- Qualify metadata and artifact cache keys with the route's upstream
- Give routed versions an empty notification-url so installs are not reported to notify-batch
- Document routes in configuration docs, example config and README
* Add npm package routes for private registries
- Add upstream.npm_routes mapping package name patterns to registries
- Serve matching packages only from their route, with no fallback to upstream.npm
- Resolve and validate tarball URLs against the route's registry
- Qualify metadata and artifact cache keys with the route's upstream
- Document npm routes and the audit endpoint limitation
* Share routed artifact filename qualification between handlers
* Match Composer routes without the ~dev suffix
Packagist serves a package's dev versions from {name}~dev.json, and the
handler matched routes against the name with ~dev still attached. An exact
route for example/library therefore missed /p2/example/library~dev.json, and
the private package's dev metadata came from the default repository.
- Match routes against the package name without ~dev, keeping it in the
upstream URL and the cache key so the two files stay apart
- Add a regression test with an exact route
* Keep routed npm publish times out of the shared versions row
The download cooldown check stores publish times under the plain version
PURL, which does not say which registry a version came from. A time read
from the default registry was trusted for a private version of the same
name, so a private release published an hour ago passed a seven-day
cooldown when the public one was a month old.
- Skip the stored row for routed packages, both reading and writing, and
take the publish time from the route's metadata, which is cached under
the route
- This also stops a private time from holding back the public version, and
a route's previous upstream from lending its times to the new one
- Cover adding a route after a public download, the reverse order, and
moving a route to another upstream
2026-10-11 19:06:18 +02:00
|
|
|
// sourceFor returns the repository serving a package and the key its metadata
|
|
|
|
|
// is cached under. packageName may carry the ~dev suffix of the file holding
|
|
|
|
|
// the package's dev versions: routes match the package without it, so its dev
|
|
|
|
|
// versions come from the same repository, while the cache key keeps it so the
|
|
|
|
|
// two files stay apart.
|
|
|
|
|
func (h *ComposerHandler) sourceFor(packageName string) (repoURL, cacheKey string) {
|
|
|
|
|
if route, routed := h.routes.match(strings.TrimSuffix(packageName, composerDevFile)); routed {
|
|
|
|
|
return route.url, route.cacheKey(packageName)
|
|
|
|
|
}
|
|
|
|
|
return h.repoURL, packageName
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
// 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")
|
2026-03-18 10:59:29 +00:00
|
|
|
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)
|
|
|
|
|
|
Add package routes for private Composer and npm registries (#426)
* Add Composer package routes for private repositories
- Add upstream.composer_routes mapping package name patterns to repositories
- Serve matching packages only from their route, with no fallback to the default repository
- Qualify metadata and artifact cache keys with the route's upstream
- Give routed versions an empty notification-url so installs are not reported to notify-batch
- Document routes in configuration docs, example config and README
* Add npm package routes for private registries
- Add upstream.npm_routes mapping package name patterns to registries
- Serve matching packages only from their route, with no fallback to upstream.npm
- Resolve and validate tarball URLs against the route's registry
- Qualify metadata and artifact cache keys with the route's upstream
- Document npm routes and the audit endpoint limitation
* Share routed artifact filename qualification between handlers
* Match Composer routes without the ~dev suffix
Packagist serves a package's dev versions from {name}~dev.json, and the
handler matched routes against the name with ~dev still attached. An exact
route for example/library therefore missed /p2/example/library~dev.json, and
the private package's dev metadata came from the default repository.
- Match routes against the package name without ~dev, keeping it in the
upstream URL and the cache key so the two files stay apart
- Add a regression test with an exact route
* Keep routed npm publish times out of the shared versions row
The download cooldown check stores publish times under the plain version
PURL, which does not say which registry a version came from. A time read
from the default registry was trusted for a private version of the same
name, so a private release published an hour ago passed a seven-day
cooldown when the public one was a month old.
- Skip the stored row for routed packages, both reading and writing, and
take the publish time from the route's metadata, which is cached under
the route
- This also stops a private time from holding back the public version, and
a route's previous upstream from lending its times to the new one
- Cover adding a route after a public download, the reverse order, and
moving a route to another upstream
2026-10-11 19:06:18 +02:00
|
|
|
repoURL, cacheKey := h.sourceFor(packageName)
|
|
|
|
|
upstreamURL := fmt.Sprintf("%s/p2/%s/%s.json", repoURL, vendor, pkg)
|
2026-01-20 21:52:44 +00:00
|
|
|
|
2026-10-13 13:27:13 +02:00
|
|
|
basePackage := strings.TrimSuffix(packageName, composerDevFile)
|
|
|
|
|
local, err := h.composerLocalEntries(basePackage, basePackage != packageName)
|
|
|
|
|
if err != nil {
|
|
|
|
|
h.proxy.Logger.Error("failed to read pushed composer versions", "package", basePackage, "error", err)
|
|
|
|
|
http.Error(w, "failed to read pushed versions", http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-10-13 13:42:34 +02:00
|
|
|
if h.proxy.hostedOnly("composer", basePackage) {
|
|
|
|
|
h.serveHostedOnlyMetadata(w, r, basePackage, cacheKey, local)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-10-13 13:27:13 +02:00
|
|
|
if len(local) > 0 {
|
|
|
|
|
h.serveMetadataWithLocal(w, r, basePackage, upstreamURL, cacheKey, local)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-08 13:26:55 +02:00
|
|
|
if stored, ok := h.proxy.storedRewrite("composer", cacheKey, h.proxyURL, cacheKey); ok {
|
|
|
|
|
serveRewrittenMetadata(w, r, stored)
|
2026-10-08 07:36:33 +02:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-09 08:10:41 +02:00
|
|
|
upstream := h.proxy.fetchMetadataDocument(r.Context(), "composer", cacheKey, upstreamURL, contentTypeJSON)
|
|
|
|
|
if err := upstream.err; err != nil {
|
Fix metadata caching, 404 propagation, mirror progress, and registry stubs
- ProxyCached now stores upstream Last-Modified in the cache and uses it
(along with ETag) for conditional request handling, returning 304 when
client validators match. Adds Content-Length to cached responses.
- Handlers calling FetchOrCacheMetadata (pypi, composer, pub, nuget) now
check for ErrUpstreamNotFound and return 404 instead of 502, matching
the existing npm and cargo behavior.
- Mirror jobs report live progress via a periodic callback while running,
so API polls return real counts instead of zeroed progress.
- Registry mirroring removed from CLI flags, API acceptance, README, and
docs since every enumerator was a stub returning "not yet implemented".
- Added tests for the conditional metadata path (ETag/If-None-Match,
Last-Modified/If-Modified-Since, 304 responses, header omission).
2026-04-01 20:14:11 +01:00
|
|
|
if errors.Is(err, ErrUpstreamNotFound) {
|
|
|
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-10-15 13:35:12 +01:00
|
|
|
if writeUpstreamHeld(w, err) {
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-10 13:47:39 +02:00
|
|
|
rewritten, err := h.proxy.cachedRewrite(r.Context(), "composer", h.proxyURL, cacheKey, upstream.body, upstream.digest, h.rewriteMetadataKeeping)
|
2026-01-20 21:52:44 +00:00
|
|
|
if err != nil {
|
Cache rewritten npm and Composer metadata (#402)
The npm and Composer handlers rewrite every metadata document they serve
so that download URLs point at the proxy: decode the whole document into
generic maps, change the URLs, encode it again, and for Composer expand
the minified format first. That ran on every request, cached metadata
included. With metadata caching on and upstream out of the picture, a
cached request still cost 7 ms for @babel/core, 17 ms for
symfony/console and 155 ms and 134 MB of allocations for typescript, and
an 8 vCPU VM serving cached npm packuments to 20 clients ran the proxy
at 450% CPU for about 1,100 requests a second.
Rewritten documents are now kept in memory, keyed by the ecosystem,
proxy URL, package and a SHA-256 of the raw document, so new bytes from
upstream are rewritten again and nothing is served stale. Requests that
arrive while a document is being rewritten wait for that rewrite rather
than running their own; a waiter leaves when its client does, and the
rewrite still completes and is cached. The denylist is fixed at startup,
so it needs no place in the key. Cooldown filtering depends on the
current time, so with cooldown on the cache is bypassed.
metadata_rewrite_cache_size bounds the cache (default "256MB", least
recently used out first, "0" to rewrite on every request). NewProxy
callers keep the old behaviour unless they set it.
Whole cached requests, measured locally:
@babel/core 7.1 ms -> 0.38 ms, 54,489 -> 119 allocations
typescript 155 ms -> 10.5 ms, 1.37M -> 133 allocations
symfony/console 17.4 ms -> 0.48 ms, 165,909 -> 123 allocations
What remains is reading the raw document from storage and hashing it.
2026-10-03 04:09:15 -07:00
|
|
|
if r.Context().Err() != nil {
|
|
|
|
|
return // the client left while waiting on a shared rewrite
|
|
|
|
|
}
|
2026-01-20 21:52:44 +00:00
|
|
|
h.proxy.Logger.Warn("failed to rewrite metadata, proxying original", "error", err)
|
2026-09-02 13:22:02 +01:00
|
|
|
w.Header().Set(headerContentType, "application/json")
|
2026-10-09 08:10:41 +02:00
|
|
|
_, _ = w.Write(upstream.body)
|
2026-01-20 21:52:44 +00:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-10 13:47:39 +02:00
|
|
|
serveRewrittenMetadata(w, r, rewritten)
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// rewriteMetadata rewrites dist URLs in Composer metadata to point at this proxy.
|
2026-10-10 08:31:37 +02:00
|
|
|
// If cooldown is enabled, versions published too recently are filtered out.
|
|
|
|
|
//
|
|
|
|
|
// The document is never decoded as a whole. Packagist serves large packages
|
|
|
|
|
// as minified lists of thousands of versions, and expanding them into generic
|
|
|
|
|
// maps took many times the document's size for every rewrite. Instead each
|
|
|
|
|
// version list is walked in place and the response is assembled from slices
|
|
|
|
|
// of the original bytes: only dist values are written fresh, and minified
|
|
|
|
|
// metadata stays minified.
|
2026-01-20 21:52:44 +00:00
|
|
|
func (h *ComposerHandler) rewriteMetadata(body []byte) ([]byte, error) {
|
2026-10-09 08:10:41 +02:00
|
|
|
out, _, err := h.rewriteMetadataKeeping(body)
|
|
|
|
|
return out, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// rewriteMetadataKeeping is rewriteMetadata that also reports the versions
|
|
|
|
|
// it kept, each as its package name and version.
|
|
|
|
|
func (h *ComposerHandler) rewriteMetadataKeeping(body []byte) ([]byte, []string, error) {
|
2026-10-13 13:27:13 +02:00
|
|
|
return h.rewriteMetadataWithLocal(body, "", nil)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// rewriteMetadataWithLocal is rewriteMetadataKeeping that also lists the
|
|
|
|
|
// pushed versions in local under localPackage, in place of any upstream entry
|
|
|
|
|
// with the same version. Cooldown only filters upstream versions. A document
|
|
|
|
|
// that does not list localPackage at all is replaced by one holding only the
|
|
|
|
|
// pushed versions.
|
|
|
|
|
func (h *ComposerHandler) rewriteMetadataWithLocal(body []byte, localPackage string, local []composerLocalEntry) ([]byte, []string, error) {
|
2026-10-10 08:31:37 +02:00
|
|
|
if !json.Valid(body) {
|
2026-10-09 08:10:41 +02:00
|
|
|
return nil, nil, errors.New("composer metadata is not valid JSON")
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
2026-10-18 17:59:14 +01:00
|
|
|
format, _, err := jsonscan.LookupString(body, "minified")
|
2026-10-10 08:31:37 +02:00
|
|
|
if err != nil {
|
2026-10-09 08:10:41 +02:00
|
|
|
return nil, nil, err
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
2026-10-10 08:31:37 +02:00
|
|
|
minified := format == composerMinified
|
2026-01-20 21:52:44 +00:00
|
|
|
|
2026-10-10 08:31:37 +02:00
|
|
|
var out bytes.Buffer
|
2026-10-09 08:10:41 +02:00
|
|
|
var kept []string
|
2026-10-13 13:27:13 +02:00
|
|
|
listed := false
|
2026-10-10 08:31:37 +02:00
|
|
|
out.Grow(len(body) + len(body)/8)
|
2026-10-18 17:59:14 +01:00
|
|
|
err = jsonscan.RewriteMembers(&out, body, func(out *bytes.Buffer, m jsonscan.Member) (bool, error) {
|
|
|
|
|
packages := m.Value(body)
|
|
|
|
|
if !jsonscan.KeyIs(m.Key(body), "packages") || packages[0] != '{' {
|
2026-10-10 08:31:37 +02:00
|
|
|
return false, 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
|
|
|
}
|
2026-10-18 17:59:14 +01:00
|
|
|
return true, jsonscan.RewriteMembers(out, packages, func(out *bytes.Buffer, p jsonscan.Member) (bool, error) {
|
|
|
|
|
versions := p.Value(packages)
|
2026-10-10 08:31:37 +02:00
|
|
|
if versions[0] != '[' {
|
|
|
|
|
return false, nil
|
|
|
|
|
}
|
2026-10-18 17:59:14 +01:00
|
|
|
packageName, err := jsonscan.Key(p.Key(packages))
|
2026-10-10 08:31:37 +02:00
|
|
|
if err != nil {
|
|
|
|
|
return false, err
|
|
|
|
|
}
|
2026-10-13 13:27:13 +02:00
|
|
|
var pushed []composerLocalEntry
|
|
|
|
|
if packageName == localPackage {
|
|
|
|
|
pushed, listed = local, true
|
|
|
|
|
}
|
|
|
|
|
return true, h.writeVersions(out, packageName, versions, minified, &kept, pushed)
|
2026-10-10 08:31:37 +02:00
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
2026-10-09 08:10:41 +02:00
|
|
|
return nil, nil, err
|
2026-03-18 10:59:29 +00:00
|
|
|
}
|
2026-10-13 13:27:13 +02:00
|
|
|
if len(local) > 0 && !listed {
|
|
|
|
|
document, err := localComposerDocument(localPackage, local)
|
|
|
|
|
return document, nil, err
|
|
|
|
|
}
|
2026-10-09 08:10:41 +02:00
|
|
|
return out.Bytes(), kept, nil
|
2026-03-18 10:59:29 +00:00
|
|
|
}
|
|
|
|
|
|
2026-10-10 08:31:37 +02:00
|
|
|
// writeVersions writes one package's version list without the versions in
|
2026-10-09 08:10:41 +02:00
|
|
|
// cooldown and with each dist pointing at this proxy, and appends each version
|
|
|
|
|
// it writes to kept as its package name and version.
|
2026-10-10 08:31:37 +02:00
|
|
|
//
|
|
|
|
|
// Minified lists stay minified. Each version written carries the fields that
|
|
|
|
|
// differ from what the client has expanded so far, which is the upstream
|
|
|
|
|
// entry itself unless filtered versions were dropped before it; then it also
|
|
|
|
|
// carries the changes those versions made. Every version gets its own dist,
|
|
|
|
|
// since the proxied URL names the version.
|
2026-10-13 13:27:13 +02:00
|
|
|
//
|
|
|
|
|
// The pushed versions in local follow the upstream ones and take the place of
|
|
|
|
|
// upstream entries with the same version.
|
|
|
|
|
func (h *ComposerHandler) writeVersions(out *bytes.Buffer, packageName string, versions []byte, minified bool, kept *[]string, local []composerLocalEntry) error {
|
2026-10-10 08:31:37 +02:00
|
|
|
packagePURL := canonicalPackagePURL("composer", packageName)
|
|
|
|
|
upstream, written := newComposerFields(), newComposerFields()
|
|
|
|
|
devReset, first := false, true
|
Add package routes for private Composer and npm registries (#426)
* Add Composer package routes for private repositories
- Add upstream.composer_routes mapping package name patterns to repositories
- Serve matching packages only from their route, with no fallback to the default repository
- Qualify metadata and artifact cache keys with the route's upstream
- Give routed versions an empty notification-url so installs are not reported to notify-batch
- Document routes in configuration docs, example config and README
* Add npm package routes for private registries
- Add upstream.npm_routes mapping package name patterns to registries
- Serve matching packages only from their route, with no fallback to upstream.npm
- Resolve and validate tarball URLs against the route's registry
- Qualify metadata and artifact cache keys with the route's upstream
- Document npm routes and the audit endpoint limitation
* Share routed artifact filename qualification between handlers
* Match Composer routes without the ~dev suffix
Packagist serves a package's dev versions from {name}~dev.json, and the
handler matched routes against the name with ~dev still attached. An exact
route for example/library therefore missed /p2/example/library~dev.json, and
the private package's dev metadata came from the default repository.
- Match routes against the package name without ~dev, keeping it in the
upstream URL and the cache key so the two files stay apart
- Add a regression test with an exact route
* Keep routed npm publish times out of the shared versions row
The download cooldown check stores publish times under the plain version
PURL, which does not say which registry a version came from. A time read
from the default registry was trusted for a private version of the same
name, so a private release published an hour ago passed a seven-day
cooldown when the public one was a month old.
- Skip the stored row for routed packages, both reading and writing, and
take the publish time from the route's metadata, which is cached under
the route
- This also stops a private time from holding back the public version, and
a route's previous upstream from lending its times to the new one
- Cover adding a route after a public download, the reverse order, and
moving a route to another upstream
2026-10-11 19:06:18 +02:00
|
|
|
_, routed := h.routes.match(packageName)
|
2026-10-10 08:31:37 +02:00
|
|
|
|
|
|
|
|
out.WriteByte('[')
|
2026-10-18 17:59:14 +01:00
|
|
|
err := jsonscan.EachElement(versions, func(entry []byte) error {
|
|
|
|
|
if minified && jsonscan.StringIs(entry, composerDevReset) {
|
2026-10-10 08:31:37 +02:00
|
|
|
upstream.reset()
|
|
|
|
|
written.reset()
|
|
|
|
|
devReset = true
|
|
|
|
|
return 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
|
|
|
}
|
2026-10-10 08:31:37 +02:00
|
|
|
if entry[0] != '{' {
|
|
|
|
|
return 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
|
|
|
}
|
2026-10-10 08:31:37 +02:00
|
|
|
if !minified {
|
|
|
|
|
upstream.reset()
|
|
|
|
|
written.reset()
|
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
|
|
|
}
|
2026-10-10 08:31:37 +02:00
|
|
|
if err := upstream.apply(entry, minified); err != nil {
|
|
|
|
|
return err
|
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
|
|
|
}
|
|
|
|
|
|
2026-10-10 08:31:37 +02:00
|
|
|
version := upstream.string("version")
|
2026-10-13 13:27:13 +02:00
|
|
|
if pushedComposerVersion(local, version) || h.shouldFilterVersion(packagePURL, packageName, version, upstream.get("time")) {
|
2026-10-10 08:31:37 +02:00
|
|
|
return 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
|
|
|
|
2026-10-10 08:31:37 +02:00
|
|
|
if !first {
|
|
|
|
|
out.WriteByte(',')
|
|
|
|
|
}
|
|
|
|
|
first = false
|
|
|
|
|
if devReset {
|
|
|
|
|
out.WriteString(`"` + composerDevReset + `",`)
|
|
|
|
|
devReset = false
|
|
|
|
|
}
|
Add package routes for private Composer and npm registries (#426)
* Add Composer package routes for private repositories
- Add upstream.composer_routes mapping package name patterns to repositories
- Serve matching packages only from their route, with no fallback to the default repository
- Qualify metadata and artifact cache keys with the route's upstream
- Give routed versions an empty notification-url so installs are not reported to notify-batch
- Document routes in configuration docs, example config and README
* Add npm package routes for private registries
- Add upstream.npm_routes mapping package name patterns to registries
- Serve matching packages only from their route, with no fallback to upstream.npm
- Resolve and validate tarball URLs against the route's registry
- Qualify metadata and artifact cache keys with the route's upstream
- Document npm routes and the audit endpoint limitation
* Share routed artifact filename qualification between handlers
* Match Composer routes without the ~dev suffix
Packagist serves a package's dev versions from {name}~dev.json, and the
handler matched routes against the name with ~dev still attached. An exact
route for example/library therefore missed /p2/example/library~dev.json, and
the private package's dev metadata came from the default repository.
- Match routes against the package name without ~dev, keeping it in the
upstream URL and the cache key so the two files stay apart
- Add a regression test with an exact route
* Keep routed npm publish times out of the shared versions row
The download cooldown check stores publish times under the plain version
PURL, which does not say which registry a version came from. A time read
from the default registry was trusted for a private version of the same
name, so a private release published an hour ago passed a seven-day
cooldown when the public one was a month old.
- Skip the stored row for routed packages, both reading and writing, and
take the publish time from the route's metadata, which is cached under
the route
- This also stops a private time from holding back the public version, and
a route's previous upstream from lending its times to the new one
- Cover adding a route after a public download, the reverse order, and
moving a route to another upstream
2026-10-11 19:06:18 +02:00
|
|
|
if routed {
|
|
|
|
|
disableDefaultNotification(upstream)
|
|
|
|
|
}
|
2026-10-10 08:31:37 +02:00
|
|
|
h.writeVersion(out, packageName, version, upstream, written)
|
2026-10-09 08:10:41 +02:00
|
|
|
*kept = append(*kept, packageName+"\x00"+version)
|
2026-10-10 08:31:37 +02:00
|
|
|
return nil
|
|
|
|
|
})
|
2026-10-13 13:27:13 +02:00
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
for _, e := range local {
|
|
|
|
|
pushed := newComposerFields()
|
|
|
|
|
if err := pushed.apply(e.entry, false); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if !minified {
|
|
|
|
|
written.reset()
|
|
|
|
|
}
|
|
|
|
|
if !first {
|
|
|
|
|
out.WriteByte(',')
|
|
|
|
|
}
|
|
|
|
|
first = false
|
|
|
|
|
if devReset {
|
|
|
|
|
out.WriteString(`"` + composerDevReset + `",`)
|
|
|
|
|
devReset = false
|
|
|
|
|
}
|
|
|
|
|
h.writeVersion(out, packageName, e.version, pushed, written)
|
|
|
|
|
*kept = append(*kept, packageName+"\x00"+e.version)
|
|
|
|
|
}
|
2026-10-10 08:31:37 +02:00
|
|
|
out.WriteByte(']')
|
2026-10-13 13:27:13 +02:00
|
|
|
return 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
|
|
|
}
|
|
|
|
|
|
2026-10-10 08:31:37 +02:00
|
|
|
// writeVersion writes the fields of upstream that differ from written, with
|
|
|
|
|
// dist pointing at this proxy, and "__unset" for those written has and
|
|
|
|
|
// upstream lacks. written is then what the client expands this version to.
|
|
|
|
|
func (h *ComposerHandler) writeVersion(out *bytes.Buffer, packageName, version string, upstream, written *composerFields) {
|
|
|
|
|
out.WriteByte('{')
|
|
|
|
|
first := true
|
|
|
|
|
writeMember := func(key, value []byte) {
|
|
|
|
|
if !first {
|
|
|
|
|
out.WriteByte(',')
|
|
|
|
|
}
|
|
|
|
|
first = false
|
|
|
|
|
out.Write(key)
|
|
|
|
|
out.WriteByte(':')
|
|
|
|
|
out.Write(value)
|
|
|
|
|
}
|
2026-03-18 10:59:29 +00:00
|
|
|
|
2026-10-10 08:31:37 +02:00
|
|
|
for _, f := range upstream.fields {
|
|
|
|
|
value := f.value
|
|
|
|
|
if value == nil {
|
2026-03-18 10:59:29 +00:00
|
|
|
continue
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
2026-10-10 08:31:37 +02:00
|
|
|
if f.name == "dist" {
|
|
|
|
|
value = h.rewriteDist(packageName, version, value)
|
|
|
|
|
}
|
|
|
|
|
if bytes.Equal(value, written.get(f.name)) {
|
2026-03-18 10:59:29 +00:00
|
|
|
continue
|
|
|
|
|
}
|
2026-10-10 08:31:37 +02:00
|
|
|
writeMember(f.key, value)
|
|
|
|
|
written.set(f.name, f.key, value)
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
2026-10-10 08:31:37 +02:00
|
|
|
for _, f := range written.fields {
|
|
|
|
|
if f.value != nil && upstream.get(f.name) == nil {
|
|
|
|
|
writeMember(f.key, composerUnsetJSON)
|
|
|
|
|
written.set(f.name, f.key, nil)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
out.WriteByte('}')
|
2026-03-18 10:59:29 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// shouldFilterVersion returns true if the version should be excluded due to cooldown.
|
2026-10-10 08:31:37 +02:00
|
|
|
func (h *ComposerHandler) shouldFilterVersion(packagePURL, packageName, version string, published []byte) bool {
|
2026-03-18 10:59:29 +00:00
|
|
|
if h.proxy.Cooldown == nil || !h.proxy.Cooldown.Enabled() {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-10 08:31:37 +02:00
|
|
|
var timeStr string
|
|
|
|
|
if err := json.Unmarshal(published, &timeStr); err != nil {
|
2026-03-18 10:59:29 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
Add package routes for private Composer and npm registries (#426)
* Add Composer package routes for private repositories
- Add upstream.composer_routes mapping package name patterns to repositories
- Serve matching packages only from their route, with no fallback to the default repository
- Qualify metadata and artifact cache keys with the route's upstream
- Give routed versions an empty notification-url so installs are not reported to notify-batch
- Document routes in configuration docs, example config and README
* Add npm package routes for private registries
- Add upstream.npm_routes mapping package name patterns to registries
- Serve matching packages only from their route, with no fallback to upstream.npm
- Resolve and validate tarball URLs against the route's registry
- Qualify metadata and artifact cache keys with the route's upstream
- Document npm routes and the audit endpoint limitation
* Share routed artifact filename qualification between handlers
* Match Composer routes without the ~dev suffix
Packagist serves a package's dev versions from {name}~dev.json, and the
handler matched routes against the name with ~dev still attached. An exact
route for example/library therefore missed /p2/example/library~dev.json, and
the private package's dev metadata came from the default repository.
- Match routes against the package name without ~dev, keeping it in the
upstream URL and the cache key so the two files stay apart
- Add a regression test with an exact route
* Keep routed npm publish times out of the shared versions row
The download cooldown check stores publish times under the plain version
PURL, which does not say which registry a version came from. A time read
from the default registry was trusted for a private version of the same
name, so a private release published an hour ago passed a seven-day
cooldown when the public one was a month old.
- Skip the stored row for routed packages, both reading and writing, and
take the publish time from the route's metadata, which is cached under
the route
- This also stops a private time from holding back the public version, and
a route's previous upstream from lending its times to the new one
- Cover adding a route after a public download, the reverse order, and
moving a route to another upstream
2026-10-11 19:06:18 +02:00
|
|
|
// disableDefaultNotification stops Composer from reporting installs of a routed
|
|
|
|
|
// package to the default repository's notify-batch URL, which would disclose
|
|
|
|
|
// the package name to it. Composer only falls back to notify-batch when a
|
|
|
|
|
// version has no notification-url of its own, and skips an empty one. Setting
|
|
|
|
|
// it on the expanded fields keeps it set for the versions that inherit them.
|
|
|
|
|
func disableDefaultNotification(fields *composerFields) {
|
|
|
|
|
if fields.get("notification-url") == nil {
|
|
|
|
|
fields.set("notification-url", []byte(`"notification-url"`), []byte(`""`))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-10 08:31:37 +02:00
|
|
|
// rewriteDist returns a version's dist object with its url pointing at this
|
|
|
|
|
// proxy. A dist without a string url is returned unchanged.
|
|
|
|
|
func (h *ComposerHandler) rewriteDist(packageName, version string, dist []byte) []byte {
|
2026-10-18 17:59:14 +01:00
|
|
|
urlMember, ok, err := jsonscan.FindMember(dist, "url")
|
|
|
|
|
if err != nil || !ok || dist[urlMember.ValStart] != '"' {
|
2026-10-10 08:31:37 +02:00
|
|
|
return dist
|
|
|
|
|
}
|
|
|
|
|
var upstreamURL string
|
2026-10-18 17:59:14 +01:00
|
|
|
if err := json.Unmarshal(urlMember.Value(dist), &upstreamURL); err != nil {
|
2026-10-10 08:31:37 +02:00
|
|
|
return dist
|
|
|
|
|
}
|
2026-10-18 17:59:14 +01:00
|
|
|
distType, _, _ := jsonscan.LookupString(dist, "type")
|
2026-10-10 08:31:37 +02:00
|
|
|
|
|
|
|
|
proxyURL, ok := h.proxyDistURL(packageName, version, upstreamURL, distType)
|
2026-03-18 10:59:29 +00:00
|
|
|
if !ok {
|
2026-10-10 08:31:37 +02:00
|
|
|
return dist
|
2026-03-18 10:59:29 +00:00
|
|
|
}
|
2026-10-10 08:31:37 +02:00
|
|
|
encoded, err := json.Marshal(proxyURL)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return dist
|
|
|
|
|
}
|
2026-10-18 17:59:14 +01:00
|
|
|
rewritten := make([]byte, 0, len(dist)-len(urlMember.Value(dist))+len(encoded))
|
|
|
|
|
rewritten = append(rewritten, dist[:urlMember.ValStart]...)
|
2026-10-10 08:31:37 +02:00
|
|
|
rewritten = append(rewritten, encoded...)
|
2026-10-18 17:59:14 +01:00
|
|
|
return append(rewritten, dist[urlMember.ValEnd:]...)
|
2026-10-10 08:31:37 +02:00
|
|
|
}
|
2026-03-18 10:59:29 +00:00
|
|
|
|
2026-10-10 08:31:37 +02:00
|
|
|
// proxyDistURL returns the URL this proxy serves a version's archive at, or
|
|
|
|
|
// false when the upstream URL or package name gives nothing to build it from.
|
|
|
|
|
func (h *ComposerHandler) proxyDistURL(packageName, version, upstreamURL, distType string) (string, bool) {
|
|
|
|
|
if upstreamURL == "" {
|
|
|
|
|
return "", false
|
2026-03-18 10:59:29 +00:00
|
|
|
}
|
2026-10-11 17:57:37 +02:00
|
|
|
parts := strings.SplitN(packageName, "/", vendorPackageParts)
|
|
|
|
|
if len(parts) != vendorPackageParts {
|
|
|
|
|
return "", false
|
|
|
|
|
}
|
|
|
|
|
return fmt.Sprintf("%s/composer/files/%s/%s/%s/%s",
|
2026-10-12 09:56:57 +02:00
|
|
|
h.proxyURL, parts[0], parts[1], url.PathEscape(version), distFilename(upstreamURL, distType)), true
|
2026-10-11 17:57:37 +02:00
|
|
|
}
|
2026-03-18 10:59:29 +00:00
|
|
|
|
2026-10-11 17:57:37 +02:00
|
|
|
// distFilename returns the file name this proxy serves an archive under: the
|
|
|
|
|
// last segment of its upstream URL.
|
|
|
|
|
func distFilename(upstreamURL, distType string) string {
|
2026-03-18 10:59:29 +00:00
|
|
|
filename := "package.zip"
|
2026-10-10 08:31:37 +02:00
|
|
|
if idx := strings.LastIndex(upstreamURL, "/"); idx >= 0 {
|
|
|
|
|
filename = upstreamURL[idx+1:]
|
2026-03-18 10:59:29 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-06 16:43:20 +01:00
|
|
|
// GitHub zipball URLs end with a bare commit hash (no extension).
|
|
|
|
|
// Append .zip so the archives library can detect the format.
|
2026-10-10 08:31:37 +02:00
|
|
|
if path.Ext(filename) == "" && distType == "zip" {
|
|
|
|
|
filename += ".zip"
|
2026-04-06 16:43:20 +01:00
|
|
|
}
|
2026-10-11 17:57:37 +02:00
|
|
|
return filename
|
|
|
|
|
}
|
2026-04-06 16:43:20 +01:00
|
|
|
|
2026-10-11 17:57:37 +02:00
|
|
|
// composerCommitPattern matches a full git commit hash, SHA-1 or SHA-256.
|
|
|
|
|
var composerCommitPattern = regexp.MustCompile(`^([0-9a-f]{40}|[0-9a-f]{64})$`)
|
|
|
|
|
|
|
|
|
|
// upstreamDistURL returns the upstream URL of the archive served as filename,
|
|
|
|
|
// given the dist URL the version's metadata lists now. A branch version only
|
|
|
|
|
// lists its current head while lock files pin older commits, so for a branch
|
|
|
|
|
// a dist URL ending in a commit hash is rebuilt for the requested commit. A
|
|
|
|
|
// tagged release has to ask for the listed name. Any other name gives false:
|
|
|
|
|
// fetching the listed archive would cache it under a name it does not have.
|
|
|
|
|
func upstreamDistURL(listedURL, distType, filename string, branch bool) (string, bool) {
|
|
|
|
|
listed := distFilename(listedURL, distType)
|
|
|
|
|
// Proxy versions before the .zip suffix handed out bare commit names,
|
|
|
|
|
// and lock files written back then still ask for them.
|
|
|
|
|
if bare := distFilename(listedURL, ""); listed != bare && path.Ext(filename) == "" {
|
|
|
|
|
filename += listed[len(bare):]
|
|
|
|
|
}
|
|
|
|
|
if filename == listed {
|
|
|
|
|
return listedURL, true
|
|
|
|
|
}
|
2026-10-12 09:56:57 +02:00
|
|
|
if commit, ok := shaQueryCommit(listed); ok {
|
|
|
|
|
requested, ok := shaQueryCommit(filename)
|
|
|
|
|
listedSha, requestedSha := "sha="+commit, "sha="+requested
|
|
|
|
|
idx := strings.LastIndex(listed, listedSha)
|
|
|
|
|
if !branch || !ok || idx < 0 || listed[:idx]+requestedSha+listed[idx+len(listedSha):] != filename {
|
|
|
|
|
return "", false
|
|
|
|
|
}
|
|
|
|
|
idx = strings.LastIndex(listedURL, listedSha)
|
|
|
|
|
return listedURL[:idx] + requestedSha + listedURL[idx+len(listedSha):], true
|
|
|
|
|
}
|
2026-10-11 17:57:37 +02:00
|
|
|
ext := path.Ext(listed)
|
|
|
|
|
commit := strings.TrimSuffix(listed, ext)
|
|
|
|
|
requested, ok := strings.CutSuffix(filename, ext)
|
|
|
|
|
if !branch || !ok || !composerCommitPattern.MatchString(commit) || !composerCommitPattern.MatchString(requested) {
|
2026-10-10 08:31:37 +02:00
|
|
|
return "", false
|
2026-03-18 10:59:29 +00:00
|
|
|
}
|
2026-10-11 17:57:37 +02:00
|
|
|
idx := strings.LastIndex(listedURL, commit)
|
|
|
|
|
return listedURL[:idx] + requested + listedURL[idx+len(commit):], true
|
2026-10-10 08:31:37 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-12 09:56:57 +02:00
|
|
|
// shaQueryCommit returns the commit an archive name passes in a sha query
|
|
|
|
|
// parameter. GitLab names the archive of every commit archive.zip and picks
|
|
|
|
|
// the commit that way.
|
|
|
|
|
func shaQueryCommit(filename string) (string, bool) {
|
|
|
|
|
_, query, ok := strings.Cut(filename, "?")
|
|
|
|
|
if !ok {
|
|
|
|
|
return "", false
|
|
|
|
|
}
|
|
|
|
|
values, err := url.ParseQuery(query)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", false
|
|
|
|
|
}
|
|
|
|
|
commit := values.Get("sha")
|
|
|
|
|
return commit, composerCommitPattern.MatchString(commit)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-10 08:31:37 +02:00
|
|
|
// composerFields holds one version's fields as Composer sees them after
|
|
|
|
|
// expanding the minified format: raw values under their decoded names, in the
|
|
|
|
|
// order the names first appeared. A removed field keeps its slot with a nil
|
|
|
|
|
// value, so the order stays stable when it comes back.
|
|
|
|
|
type composerFields struct {
|
|
|
|
|
fields []composerField
|
|
|
|
|
index map[string]int
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type composerField struct {
|
|
|
|
|
name string
|
|
|
|
|
key, value []byte
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func newComposerFields() *composerFields {
|
|
|
|
|
return &composerFields{index: map[string]int{}}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (f *composerFields) reset() {
|
|
|
|
|
f.fields = f.fields[:0]
|
|
|
|
|
clear(f.index)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// get returns the raw value of the named field, or nil when it is not set.
|
|
|
|
|
func (f *composerFields) get(name string) []byte {
|
|
|
|
|
if i, ok := f.index[name]; ok {
|
|
|
|
|
return f.fields[i].value
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// string returns the named field when it is a string, or "".
|
|
|
|
|
func (f *composerFields) string(name string) string {
|
|
|
|
|
raw := f.get(name)
|
|
|
|
|
if len(raw) == 0 || raw[0] != '"' {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
var s string
|
|
|
|
|
_ = json.Unmarshal(raw, &s)
|
|
|
|
|
return s
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// set stores value under name, written with the quoted key. A nil value
|
|
|
|
|
// removes the field.
|
|
|
|
|
func (f *composerFields) set(name string, key, value []byte) {
|
|
|
|
|
if i, ok := f.index[name]; ok {
|
|
|
|
|
f.fields[i].key, f.fields[i].value = key, value
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
f.index[name] = len(f.fields)
|
|
|
|
|
f.fields = append(f.fields, composerField{name: name, key: key, value: value})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// apply overlays the members of a version entry. In minified metadata the
|
|
|
|
|
// value "__unset" removes the field instead.
|
|
|
|
|
func (f *composerFields) apply(entry []byte, minified bool) error {
|
2026-10-18 17:59:14 +01:00
|
|
|
return jsonscan.EachMember(entry, func(m jsonscan.Member) error {
|
|
|
|
|
key, value := m.Key(entry), m.Value(entry)
|
|
|
|
|
if minified && jsonscan.StringIs(value, composerUnset) {
|
2026-10-10 08:31:37 +02:00
|
|
|
value = nil
|
|
|
|
|
}
|
|
|
|
|
if bytes.IndexByte(key, '\\') < 0 {
|
|
|
|
|
if i, ok := f.index[string(key[1:len(key)-1])]; ok {
|
|
|
|
|
f.fields[i].key, f.fields[i].value = key, value
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-18 17:59:14 +01:00
|
|
|
name, err := jsonscan.Key(key)
|
2026-10-10 08:31:37 +02:00
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
f.set(name, key, value)
|
|
|
|
|
return nil
|
|
|
|
|
})
|
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)
|
|
|
|
|
|
Push, list and delete npm and Composer archives through /api/hosted
With hosted.enabled, PUT /api/hosted/{ecosystem}/{name}/{version} stores the request body as the archive for that version, marked local. It replaces any cached upstream copy and is served from the cache without contacting the upstream. GET /api/hosted/{ecosystem}/{name} lists what was pushed and DELETE removes a version again, after which clients get the upstream archive if there is one. Pushing and deleting need the bearer token; listing is open like the rest of the API.
Download handlers look archives up by the filename clients request, so a push is stored under the name its ecosystem derives from name and version: {name}-{version}.tgz for npm, without the scope, and {package}-{version}.zip for Composer. Other ecosystems are refused for now.
The Composer download handler resolves the upstream metadata before it looks at the cache and answers 404 for versions the upstream doesn't list. It now serves a pushed archive first. Only artifacts marked local take that path, so cached upstream archives still go through the metadata and commit checks.
The version's integrity is set to the pushed bytes, so the cache's integrity check verifies them rather than an upstream digest. Names and versions with path separators, dot segments or control characters are refused before anything is stored.
Refs #133
2026-10-13 13:11:49 +02:00
|
|
|
local, err := h.proxy.GetLocalArtifact(r.Context(), "composer", packageName, version, filename)
|
|
|
|
|
if err != nil {
|
|
|
|
|
h.proxy.serveArtifactError(w, err, "failed to read pushed package")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if local != nil {
|
|
|
|
|
ServeArtifactRequest(w, r, local)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-10-13 13:42:34 +02:00
|
|
|
if h.proxy.hostedOnly("composer", packageName) {
|
|
|
|
|
http.Error(w, "version not found", http.StatusNotFound)
|
|
|
|
|
return
|
|
|
|
|
}
|
Push, list and delete npm and Composer archives through /api/hosted
With hosted.enabled, PUT /api/hosted/{ecosystem}/{name}/{version} stores the request body as the archive for that version, marked local. It replaces any cached upstream copy and is served from the cache without contacting the upstream. GET /api/hosted/{ecosystem}/{name} lists what was pushed and DELETE removes a version again, after which clients get the upstream archive if there is one. Pushing and deleting need the bearer token; listing is open like the rest of the API.
Download handlers look archives up by the filename clients request, so a push is stored under the name its ecosystem derives from name and version: {name}-{version}.tgz for npm, without the scope, and {package}-{version}.zip for Composer. Other ecosystems are refused for now.
The Composer download handler resolves the upstream metadata before it looks at the cache and answers 404 for versions the upstream doesn't list. It now serves a pushed archive first. Only artifacts marked local take that path, so cached upstream archives still go through the metadata and commit checks.
The version's integrity is set to the pushed bytes, so the cache's integrity check verifies them rather than an upstream digest. Names and versions with path separators, dot segments or control characters are refused before anything is stored.
Refs #133
2026-10-13 13:11:49 +02:00
|
|
|
|
2026-06-26 13:51:08 +02:00
|
|
|
// 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)
|
|
|
|
|
|
2026-06-30 13:25:49 +02:00
|
|
|
h.proxy.Logger.Debug("resolving download URL",
|
|
|
|
|
"package", packageName, "version", version,
|
|
|
|
|
"metadata_urls", metaURLs)
|
|
|
|
|
|
2026-10-14 08:37:39 +02:00
|
|
|
var dist composerDist
|
2026-06-26 13:51:08 +02:00
|
|
|
for _, metaURL := range metaURLs {
|
2026-10-14 08:37:39 +02:00
|
|
|
found, err := h.findDownloadURLFromMetadata(r.Context(), metaURL, packageName, version)
|
2026-06-26 13:51:08 +02:00
|
|
|
if err != nil {
|
2026-10-15 13:35:12 +01:00
|
|
|
if writeUpstreamHeld(w, err) {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-06-26 13:51:08 +02:00
|
|
|
h.proxy.Logger.Error("failed to fetch metadata", "error", err, "url", metaURL)
|
|
|
|
|
http.Error(w, "failed to fetch metadata", http.StatusBadGateway)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-10-14 08:37:39 +02:00
|
|
|
if found.url != "" {
|
|
|
|
|
dist = found
|
2026-06-26 13:51:08 +02:00
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-14 08:37:39 +02:00
|
|
|
if dist.url == "" {
|
2026-06-30 13:25:49 +02:00
|
|
|
h.proxy.Logger.Debug("version not found in any metadata source",
|
|
|
|
|
"package", packageName, "version", version,
|
|
|
|
|
"tried_urls", metaURLs)
|
2026-06-26 13:51:08 +02:00
|
|
|
http.Error(w, "version not found", http.StatusNotFound)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-01-20 21:52:44 +00:00
|
|
|
|
2026-10-14 08:37:39 +02:00
|
|
|
// Lock files name archive URLs directly, so a version filtered out of the
|
|
|
|
|
// metadata still has to be withheld here.
|
|
|
|
|
if h.shouldFilterVersion(canonicalPackagePURL("composer", packageName), packageName, version, dist.published) {
|
|
|
|
|
http.Error(w, "version not found", http.StatusNotFound)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 17:57:37 +02:00
|
|
|
// The archive is cached under the file name from the path, so fetch the
|
|
|
|
|
// one that name stands for. Compare it as sent, the way rewriteDist handed
|
2026-10-12 09:56:57 +02:00
|
|
|
// it out, query included.
|
2026-10-11 17:57:37 +02:00
|
|
|
requested := path.Base(r.URL.EscapedPath())
|
|
|
|
|
if r.URL.RawQuery != "" {
|
|
|
|
|
requested += "?" + r.URL.RawQuery
|
|
|
|
|
}
|
2026-10-14 08:37:39 +02:00
|
|
|
listedURL := dist.url
|
|
|
|
|
downloadURL, ok := upstreamDistURL(listedURL, dist.distType, requested, isDevVersion(version))
|
2026-10-11 17:57:37 +02:00
|
|
|
if !ok {
|
|
|
|
|
h.proxy.Logger.Info("composer file not in upstream metadata",
|
|
|
|
|
"package", packageName, "version", version,
|
|
|
|
|
"filename", requested, "listed_url", listedURL)
|
|
|
|
|
http.Error(w, "file not found", http.StatusNotFound)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-30 13:25:49 +02:00
|
|
|
h.proxy.Logger.Debug("resolved download URL",
|
|
|
|
|
"package", packageName, "version", version,
|
|
|
|
|
"download_url", downloadURL)
|
|
|
|
|
|
2026-10-12 09:56:57 +02:00
|
|
|
// Archives picked by ?sha= share one file name, so the commit goes into
|
|
|
|
|
// the name they are cached under. Proxy versions before that cached every
|
|
|
|
|
// commit under the shared name, which is dropped once a commit is fetched.
|
|
|
|
|
var sharedFilename string
|
|
|
|
|
if commit, ok := shaQueryCommit(requested); ok {
|
|
|
|
|
sharedFilename = h.routes.cacheFilename(packageName, filename)
|
|
|
|
|
ext := path.Ext(filename)
|
|
|
|
|
filename = strings.TrimSuffix(filename, ext) + "-" + commit + ext
|
|
|
|
|
}
|
Add package routes for private Composer and npm registries (#426)
* Add Composer package routes for private repositories
- Add upstream.composer_routes mapping package name patterns to repositories
- Serve matching packages only from their route, with no fallback to the default repository
- Qualify metadata and artifact cache keys with the route's upstream
- Give routed versions an empty notification-url so installs are not reported to notify-batch
- Document routes in configuration docs, example config and README
* Add npm package routes for private registries
- Add upstream.npm_routes mapping package name patterns to registries
- Serve matching packages only from their route, with no fallback to upstream.npm
- Resolve and validate tarball URLs against the route's registry
- Qualify metadata and artifact cache keys with the route's upstream
- Document npm routes and the audit endpoint limitation
* Share routed artifact filename qualification between handlers
* Match Composer routes without the ~dev suffix
Packagist serves a package's dev versions from {name}~dev.json, and the
handler matched routes against the name with ~dev still attached. An exact
route for example/library therefore missed /p2/example/library~dev.json, and
the private package's dev metadata came from the default repository.
- Match routes against the package name without ~dev, keeping it in the
upstream URL and the cache key so the two files stay apart
- Add a regression test with an exact route
* Keep routed npm publish times out of the shared versions row
The download cooldown check stores publish times under the plain version
PURL, which does not say which registry a version came from. A time read
from the default registry was trusted for a private version of the same
name, so a private release published an hour ago passed a seven-day
cooldown when the public one was a month old.
- Skip the stored row for routed packages, both reading and writing, and
take the publish time from the route's metadata, which is cached under
the route
- This also stops a private time from holding back the public version, and
a route's previous upstream from lending its times to the new one
- Cover adding a route after a public download, the reverse order, and
moving a route to another upstream
2026-10-11 19:06:18 +02:00
|
|
|
cacheFilename := h.routes.cacheFilename(packageName, filename)
|
2026-10-12 09:56:57 +02:00
|
|
|
if commit := strings.TrimSuffix(filename, path.Ext(filename)); isDevVersion(version) && composerCommitPattern.MatchString(commit) {
|
|
|
|
|
h.discardOtherCommit(packageName, version, cacheFilename, commit)
|
|
|
|
|
}
|
2026-10-14 15:43:39 +02:00
|
|
|
result, err := h.proxy.GetOrFetchArtifactFromURLWithIntegrity(r.Context(), "composer", packageName, version, cacheFilename, downloadURL,
|
2026-10-14 18:06:01 +02:00
|
|
|
composerIntegrity(dist.shasum, version, downloadURL == listedURL))
|
2026-01-20 21:52:44 +00:00
|
|
|
if err != nil {
|
2026-07-26 20:07:46 +02:00
|
|
|
h.proxy.serveArtifactError(w, err, "failed to fetch package")
|
2026-01-20 21:52:44 +00:00
|
|
|
return
|
|
|
|
|
}
|
2026-10-12 09:56:57 +02:00
|
|
|
if sharedFilename != "" && !result.Cached {
|
|
|
|
|
if err := h.proxy.ClearCachedArtifact("composer", packageName, version, sharedFilename); err != nil {
|
|
|
|
|
h.proxy.Logger.Warn("failed to discard composer archive cached under a shared name",
|
|
|
|
|
"package", packageName, "version", version, "filename", sharedFilename, "error", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-20 21:52:44 +00:00
|
|
|
|
Support HTTP byte-range requests for local file cached artifact downloads (#404)
* feat(storage): expose efficient seek for local files
Open file-backed artifacts with os.Open so local readers support
efficient seeking. Leave cloud storage readers unchanged.
Keep the legacy sidecar mapping test focused on fileblob's reader:
Blob.Open now reads local files directly and no longer consults the
sidecar. Continue checking sidecar cleanup and post-store reads.
* feat(handler): preserve efficient seek through integrity checks
Keep the cache result's single Reader field and preserve io.Seeker
when the underlying storage reader supports efficient seeking.
Sequential reads continue through whole-object integrity verification.
A successful seek switches subsequent reads to the source for range
responses, where full-object verification is not possible.
Test both modes and verify the source is closed once.
* feat(handler): add request-aware byte range responses
Add a request-aware artifact serving helper for bounded, open-ended,
and suffix byte ranges, including If-Range handling, 206 responses,
and 416 responses for valid unsatisfiable ranges.
Ignore malformed and multi-range requests by serving the full artifact.
Advertise byte-range support only for seekable readers, preserving HEAD,
redirect, and non-seekable behavior. Clarify that net/http recovers
ErrAbortHandler per request and keeps the server running.
* feat(handler): enable byte ranges across artifact downloads
Forward each artifact request to the shared range-aware response helper,
including cached generic release assets and OCI blobs. This lets the
handler honor ranges only when its reader supports efficient seeking,
without changing direct-storage redirect behavior.
Add warm-cache endpoint tests that assert partial response headers and
bytes without an upstream refetch. Document supported range behavior,
unsupported reader fallback, and the integrity limitation of partial reads.
2026-10-04 09:48:41 -07:00
|
|
|
ServeArtifactRequest(w, r, result)
|
2026-06-26 13:51:08 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-12 09:56:57 +02:00
|
|
|
// discardOtherCommit clears a cached branch archive named for commit that was
|
|
|
|
|
// fetched from another commit's URL. Proxy versions before the pinned-commit
|
|
|
|
|
// fix cached the branch head under whatever commit a lock file asked for. The
|
|
|
|
|
// record is only cleared while it still points at the object checked here.
|
|
|
|
|
func (h *ComposerHandler) discardOtherCommit(packageName, version, cacheFilename, commit string) {
|
|
|
|
|
_, versionPURL, err := packagePURLStrings("composer", packageName, version)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
record, err := h.proxy.DB.GetArtifact(versionPURL, cacheFilename)
|
|
|
|
|
if err != nil {
|
|
|
|
|
h.proxy.Logger.Warn("failed to read composer cache record",
|
|
|
|
|
"purl", versionPURL, "filename", cacheFilename, "error", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if record == nil || !record.StoragePath.Valid || strings.Contains(record.UpstreamURL, commit) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
h.proxy.Logger.Warn("cached composer archive is from another commit, discarding",
|
|
|
|
|
"purl", versionPURL, "filename", cacheFilename, "upstream_url", record.UpstreamURL)
|
|
|
|
|
if err := h.proxy.DB.DiscardArtifact(versionPURL, cacheFilename, record.StoragePath.String); err != nil {
|
|
|
|
|
h.proxy.Logger.Warn("failed to discard composer cache record",
|
|
|
|
|
"purl", versionPURL, "filename", cacheFilename, "error", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-26 13:51:08 +02:00
|
|
|
// 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 {
|
Add package routes for private Composer and npm registries (#426)
* Add Composer package routes for private repositories
- Add upstream.composer_routes mapping package name patterns to repositories
- Serve matching packages only from their route, with no fallback to the default repository
- Qualify metadata and artifact cache keys with the route's upstream
- Give routed versions an empty notification-url so installs are not reported to notify-batch
- Document routes in configuration docs, example config and README
* Add npm package routes for private registries
- Add upstream.npm_routes mapping package name patterns to registries
- Serve matching packages only from their route, with no fallback to upstream.npm
- Resolve and validate tarball URLs against the route's registry
- Qualify metadata and artifact cache keys with the route's upstream
- Document npm routes and the audit endpoint limitation
* Share routed artifact filename qualification between handlers
* Match Composer routes without the ~dev suffix
Packagist serves a package's dev versions from {name}~dev.json, and the
handler matched routes against the name with ~dev still attached. An exact
route for example/library therefore missed /p2/example/library~dev.json, and
the private package's dev metadata came from the default repository.
- Match routes against the package name without ~dev, keeping it in the
upstream URL and the cache key so the two files stay apart
- Add a regression test with an exact route
* Keep routed npm publish times out of the shared versions row
The download cooldown check stores publish times under the plain version
PURL, which does not say which registry a version came from. A time read
from the default registry was trusted for a private version of the same
name, so a private release published an hour ago passed a seven-day
cooldown when the public one was a month old.
- Skip the stored row for routed packages, both reading and writing, and
take the publish time from the route's metadata, which is cached under
the route
- This also stops a private time from holding back the public version, and
a route's previous upstream from lending its times to the new one
- Cover adding a route after a public download, the reverse order, and
moving a route to another upstream
2026-10-11 19:06:18 +02:00
|
|
|
repoURL, _ := h.sourceFor(vendor + "/" + pkg)
|
|
|
|
|
stable := fmt.Sprintf("%s/p2/%s/%s.json", repoURL, vendor, pkg)
|
|
|
|
|
dev := fmt.Sprintf("%s/p2/%s/%s~dev.json", repoURL, vendor, pkg)
|
2026-06-26 13:51:08 +02:00
|
|
|
|
|
|
|
|
if isDevVersion(version) {
|
|
|
|
|
return []string{dev, stable}
|
|
|
|
|
}
|
|
|
|
|
return []string{stable, dev}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-14 08:37:39 +02:00
|
|
|
// composerDist is what a version's metadata says about its archive: the dist
|
2026-10-14 15:43:39 +02:00
|
|
|
// URL, type and shasum, and the raw publish time the cooldown is checked
|
|
|
|
|
// against.
|
2026-10-14 08:37:39 +02:00
|
|
|
type composerDist struct {
|
2026-10-14 15:43:39 +02:00
|
|
|
url, distType, shasum string
|
|
|
|
|
published []byte
|
2026-10-14 08:37:39 +02:00
|
|
|
}
|
|
|
|
|
|
2026-06-26 13:51:08 +02:00
|
|
|
// findDownloadURLFromMetadata fetches a metadata document and returns the dist
|
2026-10-14 08:37:39 +02:00
|
|
|
// of the given version, with an empty URL 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 URL so the caller can fall back.
|
|
|
|
|
func (h *ComposerHandler) findDownloadURLFromMetadata(ctx context.Context, metaURL, packageName, version string) (composerDist, error) {
|
2026-06-30 13:25:49 +02:00
|
|
|
h.proxy.Logger.Debug("fetching upstream metadata for download lookup",
|
|
|
|
|
"url", metaURL, "package", packageName, "version", version)
|
|
|
|
|
|
2026-06-26 13:51:08 +02:00
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, metaURL, nil)
|
|
|
|
|
if err != nil {
|
2026-10-14 08:37:39 +02:00
|
|
|
return composerDist{}, err
|
2026-06-26 13:51:08 +02:00
|
|
|
}
|
|
|
|
|
|
2026-03-13 07:44:11 +00:00
|
|
|
resp, err := h.proxy.HTTPClient.Do(req)
|
2026-01-20 21:52:44 +00:00
|
|
|
if err != nil {
|
2026-10-14 08:37:39 +02:00
|
|
|
return composerDist{}, err
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
|
|
2026-06-30 13:25:49 +02:00
|
|
|
h.proxy.Logger.Debug("upstream metadata response",
|
|
|
|
|
"url", metaURL, "status", resp.StatusCode)
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
if resp.StatusCode != http.StatusOK {
|
2026-10-14 08:37:39 +02:00
|
|
|
return composerDist{}, nil
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
|
|
|
|
|
2026-10-14 08:37:39 +02:00
|
|
|
body, err := h.proxy.ReadMetadata(resp.Body)
|
2026-10-10 08:31:37 +02:00
|
|
|
if err != nil {
|
2026-10-14 08:37:39 +02:00
|
|
|
return composerDist{}, err
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
|
|
|
|
|
2026-10-14 08:37:39 +02:00
|
|
|
dist, err := composerDistURL(body, packageName, version)
|
2026-10-10 08:31:37 +02:00
|
|
|
if err != nil {
|
2026-10-14 08:37:39 +02:00
|
|
|
return composerDist{}, err
|
2026-06-30 13:25:49 +02:00
|
|
|
}
|
|
|
|
|
h.proxy.Logger.Debug("download URL lookup result",
|
|
|
|
|
"url", metaURL, "package", packageName, "version", version,
|
2026-10-14 08:37:39 +02:00
|
|
|
"download_url", dist.url)
|
|
|
|
|
return dist, nil
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
|
|
|
|
|
2026-10-14 08:37:39 +02:00
|
|
|
// composerDistURL returns the upstream dist of version from Composer
|
|
|
|
|
// metadata, with an empty URL when the package has no such version or it has
|
|
|
|
|
// no dist URL. It walks the version list in place, expanding only as much of
|
|
|
|
|
// the minified format as it needs, since it runs on each archive the proxy
|
|
|
|
|
// has not cached yet.
|
|
|
|
|
func composerDistURL(body []byte, packageName, version string) (composerDist, error) {
|
2026-10-18 17:59:14 +01:00
|
|
|
format, _, err := jsonscan.LookupString(body, "minified")
|
2026-10-10 08:31:37 +02:00
|
|
|
if err != nil {
|
2026-10-14 08:37:39 +02:00
|
|
|
return composerDist{}, err
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
2026-10-18 17:59:14 +01:00
|
|
|
versions, err := jsonscan.Lookup(body, "packages", packageName)
|
2026-10-10 08:31:37 +02:00
|
|
|
if err != nil || len(versions) == 0 || versions[0] != '[' {
|
2026-10-14 08:37:39 +02:00
|
|
|
return composerDist{}, err
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
2026-10-10 08:31:37 +02:00
|
|
|
minified := format == composerMinified
|
2026-01-20 21:52:44 +00:00
|
|
|
|
2026-10-10 08:31:37 +02:00
|
|
|
fields := newComposerFields()
|
2026-10-14 08:37:39 +02:00
|
|
|
var dist composerDist
|
2026-10-18 17:59:14 +01:00
|
|
|
err = jsonscan.EachElement(versions, func(entry []byte) error {
|
|
|
|
|
if minified && jsonscan.StringIs(entry, composerDevReset) {
|
2026-10-10 08:31:37 +02:00
|
|
|
fields.reset()
|
|
|
|
|
return nil
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
2026-10-10 08:31:37 +02:00
|
|
|
if entry[0] != '{' {
|
|
|
|
|
return nil
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
2026-10-10 08:31:37 +02:00
|
|
|
if !minified {
|
|
|
|
|
fields.reset()
|
|
|
|
|
}
|
|
|
|
|
if err := fields.apply(entry, minified); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
2026-10-18 17:59:14 +01:00
|
|
|
if !jsonscan.StringIs(fields.get("version"), version) {
|
2026-10-10 08:31:37 +02:00
|
|
|
return nil
|
|
|
|
|
}
|
2026-10-14 08:37:39 +02:00
|
|
|
distRaw := fields.get("dist")
|
2026-10-18 17:59:14 +01:00
|
|
|
dist.url, _, _ = jsonscan.LookupString(distRaw, "url")
|
2026-10-14 08:37:39 +02:00
|
|
|
if dist.url != "" {
|
2026-10-18 17:59:14 +01:00
|
|
|
dist.distType, _, _ = jsonscan.LookupString(distRaw, "type")
|
|
|
|
|
dist.shasum, _, _ = jsonscan.LookupString(distRaw, "shasum")
|
2026-10-14 08:37:39 +02:00
|
|
|
dist.published = fields.get("time")
|
2026-10-18 17:59:14 +01:00
|
|
|
return jsonscan.ErrStop
|
2026-10-10 08:31:37 +02:00
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
})
|
2026-10-18 17:59:14 +01:00
|
|
|
if errors.Is(err, jsonscan.ErrStop) {
|
2026-10-10 08:31:37 +02:00
|
|
|
err = nil
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
2026-10-14 08:37:39 +02:00
|
|
|
return dist, err
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
|
|
|
|
|
2026-10-14 18:06:01 +02:00
|
|
|
// composerIntegrity returns the integrity to verify an archive of version
|
|
|
|
|
// against: the SHA-1 its dist lists as shasum. A dev branch is left
|
|
|
|
|
// unverified: its listed shasum describes only the current head, while every
|
|
|
|
|
// commit of the branch is cached under the same version, which holds a single
|
|
|
|
|
// recorded integrity, so a cache hit for an older pinned commit would be
|
|
|
|
|
// checked against the head's shasum. Composer checks dist.shasum itself
|
|
|
|
|
// anyway. A shasum also only describes the listed archive, and Packagist
|
|
|
|
|
// lists none for GitHub archives; an unusable one is ignored. Those archives
|
|
|
|
|
// are served unverified as before.
|
|
|
|
|
func composerIntegrity(shasum, version string, listed bool) string {
|
|
|
|
|
if !listed || isDevVersion(version) {
|
2026-10-14 15:43:39 +02:00
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
return sha1Checksum(shasum)
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-13 07:44:11 +00:00
|
|
|
resp, err := h.proxy.HTTPClient.Do(req)
|
2026-01-20 21:52:44 +00:00
|
|
|
if err != nil {
|
2026-10-15 13:35:12 +01:00
|
|
|
if writeUpstreamHeld(w, err) {
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
|
|
2026-09-26 15:44:28 +05:30
|
|
|
h.proxy.relayResponse(w, r, resp, nil)
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|