2026-01-20 21:52:44 +00:00
|
|
|
package handler
|
|
|
|
|
|
|
|
|
|
import (
|
2026-09-30 18:45:08 +02:00
|
|
|
"bytes"
|
2026-03-19 21:06:02 +00:00
|
|
|
"errors"
|
2026-01-20 21:52:44 +00:00
|
|
|
"fmt"
|
2026-09-30 18:45:08 +02:00
|
|
|
"io"
|
2026-01-20 21:52:44 +00:00
|
|
|
"net/http"
|
|
|
|
|
"net/url"
|
|
|
|
|
"strings"
|
2026-10-04 18:05:42 +01:00
|
|
|
"sync"
|
2026-03-04 19:00:31 +00:00
|
|
|
"time"
|
2026-10-12 14:21:38 +02:00
|
|
|
|
2026-10-14 15:43:39 +02:00
|
|
|
"github.com/git-pkgs/integrity"
|
2026-10-18 17:59:14 +01:00
|
|
|
"github.com/git-pkgs/proxy/internal/jsonscan"
|
|
|
|
|
"github.com/git-pkgs/proxy/internal/metadata/npm"
|
2026-10-12 14:21:38 +02:00
|
|
|
"github.com/git-pkgs/proxy/internal/retention"
|
2026-01-20 21:52:44 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const (
|
2026-03-19 21:06:02 +00:00
|
|
|
npmUpstream = "https://registry.npmjs.org"
|
2026-08-10 10:21:49 +02:00
|
|
|
npmAcceptDefault = "application/vnd.npm.install-v1+json;q=1.0, application/json;q=0.8"
|
2026-03-19 21:06:02 +00:00
|
|
|
scopedParts = 2 // scope + name in scoped packages
|
2026-09-30 18:45:08 +02:00
|
|
|
|
2026-10-01 10:09:33 +02:00
|
|
|
// npmSecurityPrefix covers the audit endpoints: /-/npm/v1/security/audits,
|
|
|
|
|
// .../audits/quick and .../advisories/bulk.
|
2026-09-30 18:45:08 +02:00
|
|
|
npmSecurityPrefix = "/-/npm/v1/security/"
|
|
|
|
|
|
2026-10-01 10:09:33 +02:00
|
|
|
// npmKeysPath serves the registry signing keys `npm audit signatures` reads.
|
|
|
|
|
npmKeysPath = "/-/npm/v1/keys"
|
|
|
|
|
|
|
|
|
|
// npmSecurityMaxBody caps the audit payload. Buffering it lets an oversized
|
|
|
|
|
// body be refused before the upstream request starts.
|
2026-09-30 18:45:08 +02:00
|
|
|
npmSecurityMaxBody = 16 << 20
|
2026-01-20 21:52:44 +00:00
|
|
|
)
|
|
|
|
|
|
2026-10-12 14:21:38 +02:00
|
|
|
// npm tarballs are stored under pkg:npm/{name} as purl builds it, so package
|
|
|
|
|
// rules use the default key mapping.
|
|
|
|
|
func init() {
|
|
|
|
|
retention.Default.Register(retention.Spec{Key: "npm", CanonicalPackage: retention.DefaultCanonical("npm")})
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
// NPMHandler handles npm registry protocol requests.
|
|
|
|
|
type NPMHandler struct {
|
|
|
|
|
proxy *Proxy
|
|
|
|
|
upstreamURL string
|
|
|
|
|
proxyURL string // URL where this proxy is hosted
|
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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// NewNPMHandler creates a new npm protocol handler.
|
2026-07-26 19:11:24 +01:00
|
|
|
func NewNPMHandler(proxy *Proxy, proxyURL, upstreamURL string) *NPMHandler {
|
|
|
|
|
if strings.TrimSpace(upstreamURL) == "" {
|
|
|
|
|
upstreamURL = npmUpstream
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
return &NPMHandler{
|
|
|
|
|
proxy: proxy,
|
2026-07-26 19:11:24 +01:00
|
|
|
upstreamURL: strings.TrimSuffix(upstreamURL, "/"),
|
2026-01-20 21:52:44 +00:00
|
|
|
proxyURL: strings.TrimSuffix(proxyURL, "/"),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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 npm
|
|
|
|
|
// registry instead of the default one. See packageRoutes.
|
|
|
|
|
func (h *NPMHandler) WithPackageRoutes(routes map[string]string) *NPMHandler {
|
|
|
|
|
h.routes = newPackageRoutes(routes)
|
|
|
|
|
return h
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
// Routes returns the HTTP handler for npm requests.
|
|
|
|
|
// Mount this at /npm on your router.
|
|
|
|
|
func (h *NPMHandler) Routes() http.Handler {
|
|
|
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
2026-10-01 10:09:33 +02:00
|
|
|
// The /-/npm/v1 endpoints share the /-/ prefix with tarball paths, so
|
|
|
|
|
// they are routed before the tarball dispatch below. Audits are POSTs,
|
|
|
|
|
// so they also precede the GET-only gate.
|
2026-09-30 18:45:08 +02:00
|
|
|
if strings.HasPrefix(r.URL.Path, npmSecurityPrefix) {
|
|
|
|
|
h.handleSecurity(w, r)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
if r.Method != http.MethodGet {
|
|
|
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-01 10:09:33 +02:00
|
|
|
if r.URL.Path == npmKeysPath {
|
|
|
|
|
h.proxy.ProxyUpstream(w, r, h.upstreamURL+npmKeysPath,
|
|
|
|
|
[]string{headerAccept, headerAcceptEncoding})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
path := strings.TrimPrefix(r.URL.Path, "/")
|
|
|
|
|
|
|
|
|
|
// Check if this is a tarball download (contains /-/)
|
|
|
|
|
if strings.Contains(path, "/-/") {
|
|
|
|
|
h.handleDownload(w, r)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Otherwise it's a metadata request
|
|
|
|
|
h.handlePackageMetadata(w, r)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-01 10:09:33 +02:00
|
|
|
// handleSecurity relays the npm audit endpoints to upstream. The request body
|
|
|
|
|
// is the dependency tree being audited, so no two requests share a cache key,
|
|
|
|
|
// and the response carries no tarball URLs to rewrite.
|
2026-09-30 18:45:08 +02:00
|
|
|
//
|
2026-10-01 10:09:33 +02:00
|
|
|
// Advisories come from upstream's database, not the proxy's own vulnerability
|
|
|
|
|
// data, and versions withheld by cooldown are not excluded from the report.
|
2026-09-30 18:45:08 +02:00
|
|
|
func (h *NPMHandler) handleSecurity(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if r.Method != http.MethodPost {
|
|
|
|
|
JSONError(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if containsPathTraversal(r.URL.Path) {
|
|
|
|
|
JSONError(w, http.StatusBadRequest, "invalid path")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, npmSecurityMaxBody))
|
|
|
|
|
if err != nil {
|
|
|
|
|
var maxBytesErr *http.MaxBytesError
|
|
|
|
|
if errors.As(err, &maxBytesErr) {
|
|
|
|
|
JSONError(w, http.StatusRequestEntityTooLarge, "audit request too large")
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-10-01 10:09:33 +02:00
|
|
|
// A client aborting mid-upload must not be told its payload was too big.
|
2026-09-30 18:45:08 +02:00
|
|
|
h.proxy.Logger.Warn("npm audit request body unreadable", "path", r.URL.Path, "error", err)
|
|
|
|
|
JSONError(w, http.StatusBadRequest, "could not read audit request")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-01 10:09:33 +02:00
|
|
|
// EscapedPath keeps an encoded "?" or "#" from turning the rest of the path
|
|
|
|
|
// into a query or fragment upstream.
|
2026-09-30 18:45:08 +02:00
|
|
|
upstreamURL := h.upstreamURL + r.URL.EscapedPath()
|
|
|
|
|
if r.URL.RawQuery != "" {
|
|
|
|
|
upstreamURL += "?" + r.URL.RawQuery
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
h.proxy.Logger.Info("npm audit request", "path", r.URL.Path, "bytes", len(body))
|
|
|
|
|
|
|
|
|
|
req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, upstreamURL, bytes.NewReader(body))
|
|
|
|
|
if err != nil {
|
|
|
|
|
JSONError(w, http.StatusInternalServerError, "failed to create request")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-01 10:09:33 +02:00
|
|
|
// Clients may gzip the body, so the headers describing it travel with it.
|
|
|
|
|
for _, header := range []string{headerContentType, headerContentEncoding, headerAccept, headerAcceptEncoding} {
|
2026-09-30 18:45:08 +02:00
|
|
|
if v := r.Header.Get(header); v != "" {
|
|
|
|
|
req.Header.Set(header, v)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if req.Header.Get(headerContentType) == "" {
|
|
|
|
|
req.Header.Set(headerContentType, contentTypeJSON)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resp, err := h.proxy.HTTPClient.Do(req)
|
|
|
|
|
if err != nil {
|
2026-10-15 13:35:12 +01:00
|
|
|
if writeUpstreamHeld(w, err) {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-09-30 18:45:08 +02:00
|
|
|
h.proxy.Logger.Error("npm audit request failed", "path", r.URL.Path, "error", err)
|
|
|
|
|
JSONError(w, http.StatusBadGateway, "failed to reach upstream registry")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
|
|
2026-10-01 10:09:33 +02:00
|
|
|
h.proxy.relayResponse(w, r, resp, nil)
|
2026-09-30 18:45:08 +02:00
|
|
|
}
|
|
|
|
|
|
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 registry serving a package and the key its metadata is
|
|
|
|
|
// cached under.
|
|
|
|
|
func (h *NPMHandler) sourceFor(packageName string) (registryURL, cacheKey string) {
|
|
|
|
|
if route, routed := h.routes.match(packageName); routed {
|
|
|
|
|
return route.url, route.cacheKey(packageName)
|
|
|
|
|
}
|
|
|
|
|
return h.upstreamURL, packageName
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
// handlePackageMetadata proxies package metadata from upstream and rewrites tarball URLs.
|
|
|
|
|
func (h *NPMHandler) handlePackageMetadata(w http.ResponseWriter, r *http.Request) {
|
2026-10-12 14:45:34 +02:00
|
|
|
packagePath := h.extractPackageName(r)
|
|
|
|
|
if packagePath == "" || containsPathTraversal(packagePath) {
|
|
|
|
|
JSONError(w, http.StatusBadRequest, "invalid package name")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
packageName, registryPath := npmMetadataPath(packagePath)
|
2026-01-20 21:52:44 +00:00
|
|
|
if packageName == "" {
|
|
|
|
|
JSONError(w, http.StatusBadRequest, "invalid package name")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-12 14:45:34 +02:00
|
|
|
h.proxy.Logger.Info("npm metadata request", "package", packageName, "path", packagePath)
|
2026-01-20 21:52:44 +00:00
|
|
|
|
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
|
|
|
registryURL, cacheKey := h.sourceFor(packageName)
|
2026-10-12 14:45:34 +02:00
|
|
|
// A version or dist-tag is its own path segment. Escaping the whole path
|
|
|
|
|
// turns "lodash/4.17.21" into "lodash%2F4.17.21", which the registry
|
|
|
|
|
// rejects with 405.
|
|
|
|
|
upstreamURL := registryURL + "/" + registryPath
|
|
|
|
|
metadataCacheKey := npmMetadataCacheKey(cacheKey, packagePath, packageName)
|
2026-01-20 21:52:44 +00:00
|
|
|
|
2026-08-10 10:21:49 +02:00
|
|
|
// Prefer the smaller abbreviated packument format but include application/json
|
|
|
|
|
// as a fallback so upstreams that reject the abbreviated type (e.g. JFrog
|
|
|
|
|
// Artifactory, which returns 406) can still respond with full metadata.
|
|
|
|
|
// When cooldown is enabled we must use full metadata exclusively because the
|
|
|
|
|
// abbreviated format omits the "time" map required for version age filtering.
|
2026-09-02 07:13:12 -04:00
|
|
|
// Operators can also force full metadata so clients that gate on publish
|
|
|
|
|
// age (for example Yarn's npmMinimalAgeGate) keep working through the proxy.
|
2026-08-10 10:21:49 +02:00
|
|
|
accept := npmAcceptDefault
|
2026-09-02 07:13:12 -04:00
|
|
|
if h.proxy.NPMFullMetadata || (h.proxy.Cooldown != nil && h.proxy.Cooldown.Enabled()) {
|
2026-03-19 21:06:02 +00:00
|
|
|
accept = contentTypeJSON
|
2026-04-08 16:12:43 +01:00
|
|
|
}
|
2026-01-20 21:52:44 +00:00
|
|
|
|
2026-10-13 13:23:29 +02:00
|
|
|
specifier, versionLookup := npmVersionSpecifier(packagePath, packageName)
|
2026-10-13 13:42:34 +02:00
|
|
|
if h.serveLocalMetadata(w, r, npmMetadataRequest{packageName, specifier, versionLookup, upstreamURL, accept, metadataCacheKey}) {
|
2026-10-13 13:23:29 +02:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-08 13:26:55 +02:00
|
|
|
if stored, ok := h.proxy.storedRewrite("npm", metadataCacheKey, h.proxyURL, metadataCacheKey); ok {
|
|
|
|
|
serveRewrittenMetadata(w, r, stored)
|
2026-10-08 07:36:33 +02:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-12 14:45:34 +02:00
|
|
|
if versionLookup && h.npmVersionMetadataUsesFilteredPackument(packageName) {
|
|
|
|
|
h.handleFilteredNPMVersionMetadata(w, r, packageName, specifier, accept, registryURL, cacheKey, metadataCacheKey)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-09 08:10:41 +02:00
|
|
|
upstream := h.proxy.fetchMetadataDocument(r.Context(), "npm", metadataCacheKey, upstreamURL, accept)
|
|
|
|
|
if err := upstream.err; err != nil {
|
2026-03-19 21:06:02 +00:00
|
|
|
if errors.Is(err, ErrUpstreamNotFound) {
|
2026-10-12 14:45:34 +02:00
|
|
|
if versionLookup {
|
|
|
|
|
JSONError(w, http.StatusNotFound, ErrVersionNotFound.Error())
|
|
|
|
|
} else {
|
|
|
|
|
JSONError(w, http.StatusNotFound, ErrPackageNotFound.Error())
|
|
|
|
|
}
|
2026-03-19 21:06:02 +00:00
|
|
|
return
|
|
|
|
|
}
|
2026-10-15 13:35:12 +01:00
|
|
|
if writeUpstreamHeld(w, err) {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-03-19 21:06:02 +00:00
|
|
|
h.proxy.Logger.Error("failed to fetch npm metadata", "error", err)
|
2026-01-20 21:52:44 +00:00
|
|
|
JSONError(w, http.StatusBadGateway, "failed to fetch from upstream")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-10 13:47:39 +02:00
|
|
|
rewritten, err := h.proxy.cachedRewrite(r.Context(), "npm", h.proxyURL, metadataCacheKey, upstream.body, upstream.digest, func(b []byte) ([]byte, []string, error) {
|
2026-10-09 08:10:41 +02:00
|
|
|
return h.rewriteMetadataKeeping(packageName, b)
|
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
|
|
|
})
|
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-10-12 14:45:34 +02:00
|
|
|
if errors.Is(err, ErrVersionDenied) {
|
|
|
|
|
JSONError(w, http.StatusForbidden, err.Error())
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if errors.Is(err, ErrVersionNotFound) {
|
|
|
|
|
JSONError(w, http.StatusNotFound, ErrVersionNotFound.Error())
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-09-20 14:42:30 +05:30
|
|
|
if len(h.proxy.Denylist.Versions(canonicalPackagePURL("npm", packageName))) != 0 {
|
|
|
|
|
JSONError(w, http.StatusBadGateway, "failed to filter package metadata")
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-01-20 21:52:44 +00:00
|
|
|
// If rewriting fails, just proxy the original
|
|
|
|
|
h.proxy.Logger.Warn("failed to rewrite metadata, proxying original", "error", err)
|
2026-09-02 13:22:02 +01:00
|
|
|
w.Header().Set(headerContentType, contentTypeJSON)
|
2026-01-20 21:52:44 +00:00
|
|
|
w.WriteHeader(http.StatusOK)
|
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
|
|
|
}
|
|
|
|
|
|
2026-10-12 14:45:34 +02:00
|
|
|
// handleFilteredNPMVersionMetadata serves /pkg/<version> and /pkg/<tag> from the
|
|
|
|
|
// filtered packument so cooldown and dist-tags match the root metadata response.
|
|
|
|
|
func (h *NPMHandler) handleFilteredNPMVersionMetadata(w http.ResponseWriter, r *http.Request, packageName, specifier, accept, registryURL, cacheKey, metadataCacheKey string) {
|
|
|
|
|
_, packumentPath := npmMetadataPath(packageName)
|
|
|
|
|
packument, _, err := h.proxy.FetchOrCacheMetadata(r.Context(), "npm", cacheKey, registryURL+"/"+packumentPath, accept)
|
|
|
|
|
if err != nil {
|
|
|
|
|
if errors.Is(err, ErrUpstreamNotFound) {
|
|
|
|
|
JSONError(w, http.StatusNotFound, ErrPackageNotFound.Error())
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-10-15 13:35:12 +01:00
|
|
|
if writeUpstreamHeld(w, err) {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-10-12 14:45:34 +02:00
|
|
|
h.proxy.Logger.Error("failed to fetch npm metadata", "error", err)
|
|
|
|
|
JSONError(w, http.StatusBadGateway, "failed to fetch from upstream")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-10 13:47:39 +02:00
|
|
|
rewritten, err := h.proxy.cachedRewrite(r.Context(), "npm", h.proxyURL, metadataCacheKey, packument, "", func(b []byte) ([]byte, []string, error) {
|
2026-10-09 08:10:41 +02:00
|
|
|
out, err := h.rewriteNPMVersionFromPackument(packageName, specifier, b)
|
|
|
|
|
return out, nil, err
|
2026-10-12 14:45:34 +02:00
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
if r.Context().Err() != nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if errors.Is(err, ErrVersionDenied) {
|
|
|
|
|
JSONError(w, http.StatusForbidden, err.Error())
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if errors.Is(err, ErrVersionNotFound) {
|
|
|
|
|
JSONError(w, http.StatusNotFound, ErrVersionNotFound.Error())
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if len(h.proxy.Denylist.Versions(canonicalPackagePURL("npm", packageName))) != 0 {
|
|
|
|
|
JSONError(w, http.StatusBadGateway, "failed to filter package metadata")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
h.proxy.Logger.Warn("failed to rewrite npm version metadata", "error", err)
|
|
|
|
|
JSONError(w, http.StatusBadGateway, "failed to filter package metadata")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
w.Header().Set(headerContentType, contentTypeJSON)
|
|
|
|
|
w.WriteHeader(http.StatusOK)
|
2026-10-10 13:47:39 +02:00
|
|
|
_, _ = w.Write(rewritten.body)
|
2026-10-12 14:45:34 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// npmMetadataCacheKey qualifies routed-package metadata cache entries with the
|
|
|
|
|
// requested version or dist-tag when the client asks for a single version.
|
|
|
|
|
func npmMetadataCacheKey(cacheKey, packagePath, packageName string) string {
|
|
|
|
|
specifier, versionLookup := npmVersionSpecifier(packagePath, packageName)
|
|
|
|
|
if !versionLookup {
|
|
|
|
|
return cacheKey
|
|
|
|
|
}
|
|
|
|
|
return cacheKey + "/" + specifier
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// npmVersionSpecifier returns the version or dist-tag segment after the package
|
|
|
|
|
// name in a metadata path, for example "4.17.21" or "latest".
|
|
|
|
|
func npmVersionSpecifier(packagePath, packageName string) (string, bool) {
|
|
|
|
|
if packagePath == packageName {
|
|
|
|
|
return "", false
|
|
|
|
|
}
|
|
|
|
|
specifier, ok := strings.CutPrefix(packagePath, packageName+"/")
|
|
|
|
|
return specifier, ok
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *NPMHandler) npmVersionMetadataUsesFilteredPackument(packageName string) bool {
|
|
|
|
|
if h.proxy.Cooldown != nil && h.proxy.Cooldown.Enabled() {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
if h.proxy.Denylist != nil {
|
|
|
|
|
return len(h.proxy.Denylist.Versions(canonicalPackagePURL("npm", packageName))) != 0
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *NPMHandler) rewriteNPMVersionFromPackument(packageName, specifier string, packument []byte) ([]byte, error) {
|
2026-10-18 17:59:14 +01:00
|
|
|
out, err := npm.Version(packageName, specifier, packument, h.npmRewriteOptions(packageName, nil))
|
|
|
|
|
return out, npmRewriteError(err)
|
2026-10-09 08:10:41 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *NPMHandler) rewriteMetadataKeeping(packageName string, body []byte) ([]byte, []string, error) {
|
2026-10-13 13:23:29 +02:00
|
|
|
return h.rewriteMetadataWithLocal(packageName, body, nil)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *NPMHandler) rewriteMetadataWithLocal(packageName string, body []byte, local []npmLocalEntry) ([]byte, []string, error) {
|
2026-10-18 17:59:14 +01:00
|
|
|
out, kept, err := npm.Rewrite(packageName, body, h.npmRewriteOptions(packageName, local))
|
|
|
|
|
return out, kept, npmRewriteError(err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *NPMHandler) npmRewriteOptions(packageName string, local []npmLocalEntry) npm.Options {
|
|
|
|
|
return npm.Options{
|
|
|
|
|
ProxyURL: h.proxyURL,
|
|
|
|
|
Cooldown: h.proxy.Cooldown,
|
|
|
|
|
DeniedVersions: h.proxy.Denylist.Versions(canonicalPackagePURL("npm", packageName)),
|
|
|
|
|
EvaluatedAt: time.Now(),
|
|
|
|
|
LocalVersions: local,
|
|
|
|
|
OnTarballRewrite: func(version, oldURL, newURL string) {
|
|
|
|
|
h.proxy.Logger.Debug("rewrote tarball URL", "package", packageName, "version", version,
|
|
|
|
|
"old", oldURL, "new", newURL)
|
|
|
|
|
},
|
|
|
|
|
OnCooldownFilter: func(version, publishedAt string) {
|
|
|
|
|
h.proxy.Logger.Info("cooldown: filtering npm version", "package", packageName, "version", version,
|
|
|
|
|
"published", publishedAt)
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func npmRewriteError(err error) error {
|
|
|
|
|
switch {
|
|
|
|
|
case errors.Is(err, npm.ErrVersionDenied):
|
|
|
|
|
return ErrVersionDenied
|
|
|
|
|
case errors.Is(err, npm.ErrVersionNotFound):
|
|
|
|
|
return ErrVersionNotFound
|
|
|
|
|
default:
|
2026-10-08 07:36:33 +02:00
|
|
|
return err
|
2026-10-13 13:23:29 +02:00
|
|
|
}
|
2026-03-18 10:59:29 +00:00
|
|
|
}
|
2026-03-04 19:00:31 +00:00
|
|
|
|
2026-10-08 07:36:33 +02:00
|
|
|
func (h *NPMHandler) proxyTarballURL(packageName, version, tarball string) string {
|
2026-10-18 17:59:14 +01:00
|
|
|
return npm.TarballURL(h.proxyURL, packageName, version, tarball)
|
2026-03-04 19:00:31 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
// handleDownload serves a package tarball, fetching and caching from upstream if needed.
|
|
|
|
|
func (h *NPMHandler) handleDownload(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
packageName, filename := h.parseDownloadPath(r.URL.Path)
|
|
|
|
|
|
|
|
|
|
if packageName == "" || filename == "" {
|
|
|
|
|
JSONError(w, http.StatusBadRequest, "invalid request")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Extract version from filename (e.g., "lodash-4.17.21.tgz" -> "4.17.21")
|
|
|
|
|
version := h.extractVersionFromFilename(packageName, filename)
|
|
|
|
|
if version == "" {
|
|
|
|
|
JSONError(w, http.StatusBadRequest, "could not determine version from filename")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
h.proxy.Logger.Info("npm download request",
|
|
|
|
|
"package", packageName, "version", version, "filename", filename)
|
|
|
|
|
|
2026-10-04 18:05:42 +01:00
|
|
|
if h.proxy.versionDenied("npm", packageName, version) {
|
|
|
|
|
JSONError(w, http.StatusForbidden, ErrVersionDenied.Error()+": "+canonicalVersionPURL("npm", packageName, version))
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-10-13 13:34:52 +02:00
|
|
|
// Pushed versions are listed without cooldown, so they are served without
|
|
|
|
|
// it too, even when the upstream published the same version recently.
|
|
|
|
|
local, err := h.proxy.GetLocalArtifact(r.Context(), "npm", packageName, version, filename)
|
|
|
|
|
if err != nil {
|
2026-10-13 15:16:55 +02:00
|
|
|
h.proxy.serveArtifactError(w, err, "failed to read pushed package")
|
2026-10-13 13:34:52 +02:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if local != nil {
|
|
|
|
|
ServeArtifactRequest(w, r, local)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-10-13 13:42:34 +02:00
|
|
|
if h.proxy.hostedOnly("npm", packageName) {
|
|
|
|
|
JSONError(w, http.StatusNotFound, ErrVersionNotFound.Error())
|
|
|
|
|
return
|
|
|
|
|
}
|
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
|
|
|
registryURL, cacheKey := h.sourceFor(packageName)
|
2026-10-04 18:05:42 +01:00
|
|
|
metadata := sync.OnceValues(func() ([]byte, error) {
|
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
|
|
|
upstreamURL := registryURL + "/" + url.PathEscape(packageName)
|
|
|
|
|
body, _, err := h.proxy.FetchOrCacheMetadata(r.Context(), "npm", cacheKey, upstreamURL, contentTypeJSON)
|
2026-10-04 18:05:42 +01:00
|
|
|
return body, err
|
|
|
|
|
})
|
|
|
|
|
if h.versionInCooldown(packageName, version, metadata) {
|
2026-08-10 12:27:03 +04:00
|
|
|
h.proxy.Logger.Info("cooldown: withholding npm tarball",
|
|
|
|
|
"package", packageName, "version", version)
|
2026-10-12 14:45:34 +02:00
|
|
|
JSONError(w, http.StatusNotFound, ErrVersionNotFound.Error())
|
2026-08-10 12:27:03 +04:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
result, err := h.getTarball(r, registryURL, packageName, version, filename, metadata)
|
2026-01-20 21:52:44 +00:00
|
|
|
if err != nil {
|
2026-09-03 05:06:44 -04:00
|
|
|
switch {
|
|
|
|
|
case errors.Is(err, ErrUpstreamNotFound):
|
2026-10-12 14:45:34 +02:00
|
|
|
JSONError(w, http.StatusNotFound, ErrPackageNotFound.Error())
|
2026-09-20 14:42:30 +05:30
|
|
|
case errors.Is(err, ErrArtifactBlocked), errors.Is(err, ErrVersionDenied):
|
2026-09-03 05:06:44 -04:00
|
|
|
JSONError(w, http.StatusForbidden, err.Error())
|
|
|
|
|
default:
|
2026-10-15 13:35:12 +01:00
|
|
|
if writeUpstreamHeld(w, err) {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-09-03 05:06:44 -04:00
|
|
|
h.proxy.Logger.Error("failed to get artifact", "error", err)
|
|
|
|
|
JSONError(w, http.StatusBadGateway, "failed to fetch package")
|
2026-07-26 20:07:46 +02:00
|
|
|
}
|
2026-01-20 21:52:44 +00:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
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-01-20 21:52:44 +00:00
|
|
|
}
|
|
|
|
|
|
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
|
|
|
func (h *NPMHandler) getTarball(r *http.Request, registryURL, packageName, version, filename string, metadata func() ([]byte, error)) (*CacheResult, error) {
|
|
|
|
|
cacheFilename := h.routes.cacheFilename(packageName, filename)
|
|
|
|
|
if cached, err := h.proxy.GetCachedArtifact(r.Context(), "npm", packageName, version, cacheFilename); err != nil || cached != nil {
|
2026-10-04 18:05:42 +01:00
|
|
|
return cached, err
|
|
|
|
|
}
|
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
|
|
|
downloadURL := fmt.Sprintf("%s/%s/-/%s", registryURL, escapeNPMDownloadPackage(packageName), url.PathEscape(filename))
|
2026-10-14 15:36:26 +02:00
|
|
|
var sri string
|
2026-10-04 18:05:42 +01:00
|
|
|
body, err := metadata()
|
|
|
|
|
if err == nil {
|
|
|
|
|
if tarball := npmVersionTarball(body, version); tarball != "" {
|
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
|
|
|
downloadURL, err = validateTarballURL(tarball, registryURL)
|
2026-10-04 18:05:42 +01:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-14 15:36:26 +02:00
|
|
|
sri = npmVersionIntegrity(body, version)
|
2026-10-04 18:05:42 +01:00
|
|
|
}
|
2026-10-14 15:36:26 +02:00
|
|
|
return h.proxy.GetOrFetchArtifactFromURLWithIntegrity(r.Context(), "npm", packageName, version, cacheFilename, downloadURL, sri)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-14 15:43:39 +02:00
|
|
|
// npmVersionIntegrity returns the integrity a packument declares for a
|
|
|
|
|
// version's tarball: dist.integrity when it is a Subresource Integrity string
|
|
|
|
|
// the proxy can verify, or else the SHA-1 in dist.shasum, which is all
|
|
|
|
|
// versions published before integrity existed carry, as "sha1-<hex>". It
|
|
|
|
|
// returns "" when neither is usable, and the tarball is fetched unverified as
|
|
|
|
|
// before.
|
2026-10-14 15:36:26 +02:00
|
|
|
func npmVersionIntegrity(body []byte, version string) string {
|
2026-10-18 17:59:14 +01:00
|
|
|
sri, _, _ := jsonscan.LookupString(body, "versions", version, "dist", "integrity")
|
2026-10-14 15:43:39 +02:00
|
|
|
if sri != "" {
|
|
|
|
|
if _, err := integrity.ParseSRI(sri); err == nil {
|
|
|
|
|
return sri
|
|
|
|
|
}
|
2026-10-14 15:36:26 +02:00
|
|
|
}
|
2026-10-18 17:59:14 +01:00
|
|
|
shasum, _, _ := jsonscan.LookupString(body, "versions", version, "dist", "shasum")
|
2026-10-14 15:43:39 +02:00
|
|
|
return sha1Checksum(shasum)
|
2026-10-04 18:05:42 +01:00
|
|
|
}
|
|
|
|
|
|
2026-10-08 07:36:33 +02:00
|
|
|
// npmVersionTarball returns versions[version].dist.tarball from a packument,
|
|
|
|
|
// or "" if it has none. It reads that one value in place rather than decoding
|
|
|
|
|
// every version, since it runs on each tarball the proxy has not cached yet.
|
2026-10-04 18:05:42 +01:00
|
|
|
func npmVersionTarball(body []byte, version string) string {
|
2026-10-18 17:59:14 +01:00
|
|
|
tarball, _, _ := jsonscan.LookupString(body, "versions", version, "dist", "tarball")
|
2026-10-08 07:36:33 +02:00
|
|
|
return tarball
|
2026-10-04 18:05:42 +01:00
|
|
|
}
|
|
|
|
|
|
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
|
|
|
func validateTarballURL(raw, registryURL string) (string, error) {
|
2026-10-04 18:05:42 +01:00
|
|
|
tarball, err := url.Parse(raw)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", fmt.Errorf("parsing npm tarball URL: %w", err)
|
|
|
|
|
}
|
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
|
|
|
upstream, err := url.Parse(registryURL)
|
2026-10-04 18:05:42 +01:00
|
|
|
if err != nil {
|
|
|
|
|
return "", fmt.Errorf("parsing npm upstream URL: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if tarball.User != nil || tarball.Fragment != "" ||
|
|
|
|
|
tarball.Scheme != upstream.Scheme || !strings.EqualFold(tarball.Host, upstream.Host) {
|
|
|
|
|
return "", errors.New("npm tarball URL does not match upstream registry")
|
|
|
|
|
}
|
|
|
|
|
if containsPathTraversal(tarball.Path) || strings.Contains(tarball.Path, "\\") {
|
|
|
|
|
return "", errors.New("npm tarball URL contains path traversal")
|
|
|
|
|
}
|
|
|
|
|
basePath := strings.TrimRight(upstream.Path, "/")
|
|
|
|
|
if basePath != "" && !strings.HasPrefix(tarball.Path, basePath+"/") {
|
|
|
|
|
return "", errors.New("npm tarball URL is outside upstream base path")
|
|
|
|
|
}
|
|
|
|
|
return tarball.String(), nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-10 12:27:03 +04:00
|
|
|
// versionInCooldown reports whether a version is still inside the cooldown
|
|
|
|
|
// window. Filtering the packument is not enough on its own: tarball URLs are
|
|
|
|
|
// predictable and lockfiles record them directly, so `npm ci` reaches the
|
|
|
|
|
// download path without ever requesting metadata.
|
|
|
|
|
//
|
2026-09-02 07:17:55 -04:00
|
|
|
// A version's publish time is immutable, so the check reads the stored
|
|
|
|
|
// versions row first and only falls back to the packument for a version the
|
|
|
|
|
// proxy has never seen, persisting the parsed time so the packument is
|
|
|
|
|
// fetched and parsed at most once per version. A version with no usable
|
|
|
|
|
// publish time is allowed through, matching how applyCooldownFiltering
|
|
|
|
|
// treats it.
|
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
|
|
|
//
|
|
|
|
|
// The versions row is keyed by the plain version PURL, which does not say
|
|
|
|
|
// which registry the version came from, so routed packages leave it alone:
|
|
|
|
|
// their publish time always comes from the route's own metadata, cached under
|
|
|
|
|
// the route. Otherwise a time read from the default registry would be trusted
|
|
|
|
|
// for a private version of the same name, a private time would hold back the
|
|
|
|
|
// public version, and moving a route to another upstream would keep the old
|
|
|
|
|
// one's times.
|
2026-10-04 18:05:42 +01:00
|
|
|
func (h *NPMHandler) versionInCooldown(packageName, version string, metadata func() ([]byte, error)) bool {
|
2026-08-10 12:27:03 +04:00
|
|
|
if h.proxy.Cooldown == nil || !h.proxy.Cooldown.Enabled() {
|
|
|
|
|
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
|
|
|
_, routed := h.routes.match(packageName)
|
2026-09-02 07:17:55 -04:00
|
|
|
versionPURL := canonicalVersionPURL("npm", packageName, version)
|
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 {
|
|
|
|
|
if ver, err := h.proxy.DB.GetVersionByPURL(versionPURL); err == nil && ver != nil && ver.PublishedAt.Valid {
|
|
|
|
|
return !h.proxy.Cooldown.IsAllowed("npm", canonicalPackagePURL("npm", packageName), ver.PublishedAt.Time)
|
|
|
|
|
}
|
2026-09-02 07:17:55 -04:00
|
|
|
}
|
|
|
|
|
|
2026-10-04 18:05:42 +01:00
|
|
|
body, err := metadata()
|
2026-08-10 12:27:03 +04:00
|
|
|
if err != nil {
|
|
|
|
|
h.proxy.Logger.Warn("cooldown: could not fetch npm metadata for download check",
|
|
|
|
|
"package", packageName, "version", version, "error", err)
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-18 17:59:14 +01:00
|
|
|
published, ok, err := jsonscan.LookupString(body, "time", version)
|
2026-10-08 07:36:33 +02:00
|
|
|
if err != nil {
|
2026-08-10 12:27:03 +04:00
|
|
|
h.proxy.Logger.Warn("cooldown: could not parse npm metadata for download check",
|
|
|
|
|
"package", packageName, "version", version, "error", err)
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
if !ok {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
publishedAt, err := time.Parse(time.RFC3339, published)
|
|
|
|
|
if err != nil {
|
|
|
|
|
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
|
|
|
if !routed {
|
|
|
|
|
if err := h.proxy.DB.SetVersionPublishedAt(versionPURL, canonicalPackagePURL("npm", packageName), publishedAt); err != nil {
|
|
|
|
|
h.proxy.Logger.Warn("cooldown: could not store npm publish time",
|
|
|
|
|
"package", packageName, "version", version, "error", err)
|
|
|
|
|
}
|
2026-09-02 07:17:55 -04:00
|
|
|
}
|
|
|
|
|
|
2026-08-10 12:27:03 +04:00
|
|
|
return !h.proxy.Cooldown.IsAllowed("npm", canonicalPackagePURL("npm", packageName), publishedAt)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-12 14:45:34 +02:00
|
|
|
// npmMetadataPath returns the package name and the encoded registry path.
|
|
|
|
|
// Examples: lodash → lodash; lodash/4.17.21 → lodash/4.17.21;
|
|
|
|
|
// @babel/core → @babel%2Fcore; @babel/core/7.23.0 → @babel%2Fcore/7.23.0.
|
|
|
|
|
func npmMetadataPath(packagePath string) (packageName, registryPath string) {
|
|
|
|
|
// GET /npm/ has no package segment; handlePackageMetadata rejects that earlier.
|
|
|
|
|
if packagePath == "" {
|
|
|
|
|
return "", ""
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(packagePath, "/") {
|
|
|
|
|
return packagePath, url.PathEscape(packagePath)
|
|
|
|
|
}
|
|
|
|
|
if strings.HasPrefix(packagePath, "@") {
|
|
|
|
|
scope, rest, _ := strings.Cut(packagePath, "/")
|
|
|
|
|
name, version, ok := strings.Cut(rest, "/")
|
|
|
|
|
packageName = scope + "/" + name
|
|
|
|
|
registryPath = url.PathEscape(scope) + "%2F" + url.PathEscape(name)
|
|
|
|
|
if ok {
|
|
|
|
|
registryPath += "/" + url.PathEscape(version)
|
|
|
|
|
}
|
|
|
|
|
return packageName, registryPath
|
|
|
|
|
}
|
|
|
|
|
name, version, _ := strings.Cut(packagePath, "/")
|
|
|
|
|
return name, url.PathEscape(name) + "/" + url.PathEscape(version)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-26 19:11:24 +01:00
|
|
|
func escapeNPMDownloadPackage(packageName string) string {
|
|
|
|
|
scope, name, scoped := strings.Cut(packageName, "/")
|
|
|
|
|
if scoped && strings.HasPrefix(scope, "@") && len(scope) > 1 && name != "" && !strings.Contains(name, "/") {
|
|
|
|
|
return url.PathEscape(scope) + "/" + url.PathEscape(name)
|
|
|
|
|
}
|
|
|
|
|
return url.PathEscape(packageName)
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
// extractPackageName extracts the package name from the request path.
|
|
|
|
|
// Handles both scoped (@scope/name) and unscoped (name) packages.
|
|
|
|
|
func (h *NPMHandler) extractPackageName(r *http.Request) string {
|
|
|
|
|
path := strings.TrimPrefix(r.URL.Path, "/")
|
|
|
|
|
|
|
|
|
|
// Remove /-/filename suffix if present
|
|
|
|
|
if idx := strings.Index(path, "/-/"); idx >= 0 {
|
|
|
|
|
path = path[:idx]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// URL decode the path (handles %40 -> @, %2f -> /)
|
|
|
|
|
decoded, err := url.PathUnescape(path)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return path
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return decoded
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// parseDownloadPath extracts package name and filename from a download path.
|
|
|
|
|
// Path format: /@scope/name/-/filename.tgz or /name/-/filename.tgz
|
|
|
|
|
func (h *NPMHandler) parseDownloadPath(path string) (packageName, filename string) {
|
|
|
|
|
path = strings.TrimPrefix(path, "/")
|
|
|
|
|
|
|
|
|
|
idx := strings.Index(path, "/-/")
|
|
|
|
|
if idx < 0 {
|
|
|
|
|
return "", ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
packageName = path[:idx]
|
|
|
|
|
filename = path[idx+3:] // skip "/-/"
|
|
|
|
|
|
|
|
|
|
// URL decode package name
|
|
|
|
|
if decoded, err := url.PathUnescape(packageName); err == nil {
|
|
|
|
|
packageName = decoded
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return packageName, filename
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *NPMHandler) extractVersionFromFilename(packageName, filename string) string {
|
2026-10-18 17:59:14 +01:00
|
|
|
return npm.VersionFromFilename(packageName, filename)
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|