2026-10-13 13:23:29 +02:00
|
|
|
package handler
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"errors"
|
|
|
|
|
"fmt"
|
|
|
|
|
"net/http"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/Masterminds/semver/v3"
|
2026-10-25 22:19:24 +01:00
|
|
|
"github.com/git-pkgs/proxy/internal/metadata/npm"
|
2026-10-13 13:23:29 +02:00
|
|
|
)
|
|
|
|
|
|
2026-10-25 22:19:24 +01:00
|
|
|
type npmLocalEntry = npm.LocalVersion
|
2026-10-13 13:23:29 +02:00
|
|
|
|
|
|
|
|
// npmLocalEntries returns the pushed versions of a package as packument
|
|
|
|
|
// entries, or nil when there are none or hosted packages are disabled.
|
|
|
|
|
func (h *NPMHandler) npmLocalEntries(packageName string) ([]npmLocalEntry, error) {
|
|
|
|
|
local, err := h.proxy.localVersions("npm", packageName)
|
|
|
|
|
if err != nil || len(local) == 0 {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
entries := make([]npmLocalEntry, 0, len(local))
|
|
|
|
|
for _, lv := range local {
|
|
|
|
|
var manifest map[string]any
|
|
|
|
|
if err := json.Unmarshal(lv.Manifest, &manifest); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("decoding manifest of %s@%s: %w", packageName, lv.Version, err)
|
|
|
|
|
}
|
|
|
|
|
manifest["name"] = packageName
|
|
|
|
|
manifest["version"] = lv.Version
|
|
|
|
|
manifest["_id"] = packageName + "@" + lv.Version
|
|
|
|
|
manifest["dist"] = map[string]string{
|
|
|
|
|
"tarball": h.proxyTarballURL(packageName, lv.Version, lv.Filename),
|
|
|
|
|
"integrity": lv.Integrity,
|
|
|
|
|
}
|
|
|
|
|
entry, err := json.Marshal(manifest)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
2026-10-25 22:19:24 +01:00
|
|
|
entries = append(entries, npmLocalEntry{Version: lv.Version, Entry: entry, PublishedAt: lv.PushedAt.Format(time.RFC3339)})
|
2026-10-13 13:23:29 +02:00
|
|
|
}
|
|
|
|
|
return entries, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func findNPMLocalEntry(local []npmLocalEntry, version string) (npmLocalEntry, bool) {
|
|
|
|
|
for _, e := range local {
|
2026-10-25 22:19:24 +01:00
|
|
|
if e.Version == version {
|
2026-10-13 13:23:29 +02:00
|
|
|
return e, true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return npmLocalEntry{}, false
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-13 13:42:34 +02:00
|
|
|
// npmMetadataRequest is a packument or version metadata request.
|
|
|
|
|
type npmMetadataRequest struct {
|
|
|
|
|
packageName string
|
|
|
|
|
specifier string
|
|
|
|
|
versionLookup bool
|
|
|
|
|
upstreamURL string
|
|
|
|
|
accept string
|
|
|
|
|
metadataCacheKey string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// serveLocalMetadata answers the metadata requests that pushed versions
|
|
|
|
|
// decide, and reports whether it did: a request for a pushed version, any
|
|
|
|
|
// request for a package in a hosted-only scope, and a packument of a package
|
|
|
|
|
// with pushed versions. Everything else takes the usual upstream path.
|
|
|
|
|
func (h *NPMHandler) serveLocalMetadata(w http.ResponseWriter, r *http.Request, req npmMetadataRequest) bool {
|
|
|
|
|
local, err := h.npmLocalEntries(req.packageName)
|
|
|
|
|
if err != nil {
|
|
|
|
|
h.proxy.Logger.Error("failed to read pushed npm versions", "package", req.packageName, "error", err)
|
|
|
|
|
JSONError(w, http.StatusInternalServerError, "failed to read pushed versions")
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
switch e, pushed := findNPMLocalEntry(local, req.specifier); {
|
|
|
|
|
case req.versionLookup && pushed:
|
|
|
|
|
w.Header().Set(headerContentType, contentTypeJSON)
|
|
|
|
|
w.WriteHeader(http.StatusOK)
|
2026-10-25 22:19:24 +01:00
|
|
|
_, _ = w.Write(e.Entry)
|
2026-10-13 13:42:34 +02:00
|
|
|
case h.proxy.hostedOnly("npm", req.packageName):
|
|
|
|
|
h.serveHostedOnlyMetadata(w, r, req.packageName, req.specifier, req.versionLookup, req.metadataCacheKey, local)
|
|
|
|
|
case len(local) > 0 && !req.versionLookup:
|
|
|
|
|
h.serveMetadataWithLocal(w, r, req.packageName, req.upstreamURL, req.accept, req.metadataCacheKey, local)
|
|
|
|
|
default:
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-13 13:23:29 +02:00
|
|
|
// serveMetadataWithLocal serves a packument with the pushed versions of the
|
|
|
|
|
// package added. It bypasses the rewrite cache, whose entries are keyed by the
|
|
|
|
|
// upstream document alone, so a push or delete shows up on the next request.
|
|
|
|
|
// When the upstream does not know the package, the packument holds only the
|
|
|
|
|
// pushed versions.
|
|
|
|
|
func (h *NPMHandler) serveMetadataWithLocal(w http.ResponseWriter, r *http.Request, packageName, upstreamURL, accept, metadataCacheKey string, local []npmLocalEntry) {
|
Fix npm search routing and abbreviated packuments under cooldown
`npm search` requests /-/v1/search?text=... . The path has no "/-/"
after the leading slash is trimmed, so it fell through to the package
metadata handler: upstream got /-/v1%2Fsearch without the query
string, and the answer was cached under "-/v1/search", so every later
search was served the first one's results. Search and /-/ping (used
by `npm ping`, which lost its query string the same way) are now
relayed to upstream uncached with their query strings, like the
signing-keys endpoint.
Packuments share one metadata cache row whatever format they were
fetched in. Without cooldown the proxy asks for the abbreviated
format, which has no "time" map. After cooldown was turned on, a
still-fresh abbreviated packument kept being served from cache, and
used as the stale copy when upstream failed, so cooldown filtered
nothing and the tarball check let recent versions through. When full
metadata is required (cooldown or npm_full_metadata on), packument
lookups now pass a validator that rejects an abbreviated document
(top-level "modified", no "time"), so it counts as a cache miss, is
refetched in full and is never used as a stale fallback. Behaviour
without cooldown is unchanged.
fetchMetadataDocument takes the validate callback that
fetchOrCacheMetadata already has; other callers pass nil.
2026-10-23 13:22:49 +02:00
|
|
|
upstream := h.proxy.fetchMetadataDocument(r.Context(), "npm", metadataCacheKey, upstreamURL, accept, h.packumentValidator())
|
2026-10-13 13:23:29 +02:00
|
|
|
var out []byte
|
|
|
|
|
var err error
|
|
|
|
|
switch {
|
|
|
|
|
case errors.Is(upstream.err, ErrUpstreamNotFound):
|
|
|
|
|
out, err = localNPMPackument(packageName, local)
|
|
|
|
|
case upstream.err != nil:
|
2026-10-15 13:35:12 +01:00
|
|
|
if writeUpstreamHeld(w, upstream.err) {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-10-13 13:23:29 +02:00
|
|
|
h.proxy.Logger.Error("failed to fetch npm metadata", "error", upstream.err)
|
|
|
|
|
JSONError(w, http.StatusBadGateway, "failed to fetch from upstream")
|
|
|
|
|
return
|
|
|
|
|
default:
|
|
|
|
|
out, _, err = h.rewriteMetadataWithLocal(packageName, upstream.body, local)
|
|
|
|
|
}
|
|
|
|
|
if err != nil {
|
|
|
|
|
if errors.Is(err, ErrVersionDenied) {
|
|
|
|
|
JSONError(w, http.StatusForbidden, err.Error())
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
h.proxy.Logger.Error("failed to add pushed versions to npm metadata", "package", packageName, "error", err)
|
|
|
|
|
JSONError(w, http.StatusBadGateway, "failed to rewrite package metadata")
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-10-20 21:33:08 +02:00
|
|
|
markStale(w, upstream.stale)
|
2026-10-13 13:23:29 +02:00
|
|
|
serveRewrittenMetadata(w, r, h.proxy.uncachedRewrite("npm", h.proxyURL, metadataCacheKey, out))
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-13 13:42:34 +02:00
|
|
|
// serveHostedOnlyMetadata answers a metadata request for a package in a
|
|
|
|
|
// hosted-only scope from its pushed versions alone.
|
|
|
|
|
func (h *NPMHandler) serveHostedOnlyMetadata(w http.ResponseWriter, r *http.Request, packageName, specifier string, versionLookup bool, metadataCacheKey string, local []npmLocalEntry) {
|
|
|
|
|
if len(local) == 0 {
|
|
|
|
|
JSONError(w, http.StatusNotFound, ErrPackageNotFound.Error())
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if versionLookup {
|
|
|
|
|
if specifier == "latest" {
|
|
|
|
|
specifier = latestNPMVersion(local)
|
|
|
|
|
}
|
|
|
|
|
e, ok := findNPMLocalEntry(local, specifier)
|
|
|
|
|
if !ok {
|
|
|
|
|
JSONError(w, http.StatusNotFound, ErrVersionNotFound.Error())
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
w.Header().Set(headerContentType, contentTypeJSON)
|
|
|
|
|
w.WriteHeader(http.StatusOK)
|
2026-10-25 22:19:24 +01:00
|
|
|
_, _ = w.Write(e.Entry)
|
2026-10-13 13:42:34 +02:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
out, err := localNPMPackument(packageName, local)
|
|
|
|
|
if err != nil {
|
|
|
|
|
h.proxy.Logger.Error("failed to build npm metadata from pushed versions", "package", packageName, "error", err)
|
|
|
|
|
JSONError(w, http.StatusInternalServerError, "failed to build package metadata")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
serveRewrittenMetadata(w, r, h.proxy.uncachedRewrite("npm", h.proxyURL, metadataCacheKey, out))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// localNPMPackument builds a packument from pushed versions alone.
|
2026-10-13 13:23:29 +02:00
|
|
|
func localNPMPackument(packageName string, local []npmLocalEntry) ([]byte, error) {
|
|
|
|
|
versions := make(map[string]json.RawMessage, len(local))
|
|
|
|
|
times := make(map[string]string, len(local))
|
2026-10-13 13:42:34 +02:00
|
|
|
for _, e := range local {
|
2026-10-25 22:19:24 +01:00
|
|
|
versions[e.Version] = e.Entry
|
|
|
|
|
times[e.Version] = e.PublishedAt
|
2026-10-13 13:42:34 +02:00
|
|
|
}
|
|
|
|
|
return json.Marshal(map[string]any{
|
|
|
|
|
"name": packageName,
|
|
|
|
|
"dist-tags": map[string]string{"latest": latestNPMVersion(local)},
|
|
|
|
|
"versions": versions,
|
|
|
|
|
"time": times,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// latestNPMVersion returns the highest pushed version, or the last pushed
|
|
|
|
|
// when a version is not semver.
|
|
|
|
|
func latestNPMVersion(local []npmLocalEntry) string {
|
2026-10-13 13:23:29 +02:00
|
|
|
latest, latestTime := "", ""
|
|
|
|
|
var latestSemver *semver.Version
|
|
|
|
|
semverOnly := true
|
|
|
|
|
for _, e := range local {
|
2026-10-25 22:19:24 +01:00
|
|
|
if e.PublishedAt > latestTime {
|
|
|
|
|
latest, latestTime = e.Version, e.PublishedAt
|
2026-10-13 13:23:29 +02:00
|
|
|
}
|
2026-10-25 22:19:24 +01:00
|
|
|
v, err := semver.NewVersion(e.Version)
|
2026-10-13 13:23:29 +02:00
|
|
|
if err != nil {
|
|
|
|
|
semverOnly = false
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if latestSemver == nil || v.GreaterThan(latestSemver) {
|
|
|
|
|
latestSemver = v
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if semverOnly && latestSemver != nil {
|
2026-10-13 13:42:34 +02:00
|
|
|
return latestSemver.Original()
|
2026-10-13 13:23:29 +02:00
|
|
|
}
|
2026-10-13 13:42:34 +02:00
|
|
|
return latest
|
2026-10-13 13:23:29 +02:00
|
|
|
}
|