Watch
1
0
Fork
You've already forked pkg-proxy
1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-11-09 15:49:16 -05:00
pkg-proxy/internal/handler/npm.go
2026-10-18 17:59:14 +01:00

685 lines
24 KiB
Go

package handler
import (
"bytes"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/git-pkgs/integrity"
"github.com/git-pkgs/proxy/internal/jsonscan"
"github.com/git-pkgs/proxy/internal/metadata/npm"
"github.com/git-pkgs/proxy/internal/retention"
)
const (
npmUpstream = "https://registry.npmjs.org"
npmAcceptDefault = "application/vnd.npm.install-v1+json;q=1.0, application/json;q=0.8"
scopedParts = 2 // scope + name in scoped packages
// npmSecurityPrefix covers the audit endpoints: /-/npm/v1/security/audits,
// .../audits/quick and .../advisories/bulk.
npmSecurityPrefix = "/-/npm/v1/security/"
// 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.
npmSecurityMaxBody = 16 << 20
)
// 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")})
}
// NPMHandler handles npm registry protocol requests.
type NPMHandler struct {
proxy *Proxy
upstreamURL string
proxyURL string // URL where this proxy is hosted
routes packageRoutes
}
// NewNPMHandler creates a new npm protocol handler.
func NewNPMHandler(proxy *Proxy, proxyURL, upstreamURL string) *NPMHandler {
if strings.TrimSpace(upstreamURL) == "" {
upstreamURL = npmUpstream
}
return &NPMHandler{
proxy: proxy,
upstreamURL: strings.TrimSuffix(upstreamURL, "/"),
proxyURL: strings.TrimSuffix(proxyURL, "/"),
}
}
// 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
}
// 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) {
// 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.
if strings.HasPrefix(r.URL.Path, npmSecurityPrefix) {
h.handleSecurity(w, r)
return
}
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if r.URL.Path == npmKeysPath {
h.proxy.ProxyUpstream(w, r, h.upstreamURL+npmKeysPath,
[]string{headerAccept, headerAcceptEncoding})
return
}
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)
})
}
// 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.
//
// Advisories come from upstream's database, not the proxy's own vulnerability
// data, and versions withheld by cooldown are not excluded from the report.
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
}
// A client aborting mid-upload must not be told its payload was too big.
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
}
// EscapedPath keeps an encoded "?" or "#" from turning the rest of the path
// into a query or fragment upstream.
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
}
// Clients may gzip the body, so the headers describing it travel with it.
for _, header := range []string{headerContentType, headerContentEncoding, headerAccept, headerAcceptEncoding} {
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 {
if writeUpstreamHeld(w, err) {
return
}
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() }()
h.proxy.relayResponse(w, r, resp, nil)
}
// 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
}
// handlePackageMetadata proxies package metadata from upstream and rewrites tarball URLs.
func (h *NPMHandler) handlePackageMetadata(w http.ResponseWriter, r *http.Request) {
packagePath := h.extractPackageName(r)
if packagePath == "" || containsPathTraversal(packagePath) {
JSONError(w, http.StatusBadRequest, "invalid package name")
return
}
packageName, registryPath := npmMetadataPath(packagePath)
if packageName == "" {
JSONError(w, http.StatusBadRequest, "invalid package name")
return
}
h.proxy.Logger.Info("npm metadata request", "package", packageName, "path", packagePath)
registryURL, cacheKey := h.sourceFor(packageName)
// 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)
// 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.
// Operators can also force full metadata so clients that gate on publish
// age (for example Yarn's npmMinimalAgeGate) keep working through the proxy.
accept := npmAcceptDefault
if h.proxy.NPMFullMetadata || (h.proxy.Cooldown != nil && h.proxy.Cooldown.Enabled()) {
accept = contentTypeJSON
}
specifier, versionLookup := npmVersionSpecifier(packagePath, packageName)
if h.serveLocalMetadata(w, r, npmMetadataRequest{packageName, specifier, versionLookup, upstreamURL, accept, metadataCacheKey}) {
return
}
if stored, ok := h.proxy.storedRewrite("npm", metadataCacheKey, h.proxyURL, metadataCacheKey); ok {
serveRewrittenMetadata(w, r, stored)
return
}
if versionLookup && h.npmVersionMetadataUsesFilteredPackument(packageName) {
h.handleFilteredNPMVersionMetadata(w, r, packageName, specifier, accept, registryURL, cacheKey, metadataCacheKey)
return
}
upstream := h.proxy.fetchMetadataDocument(r.Context(), "npm", metadataCacheKey, upstreamURL, accept)
if err := upstream.err; err != nil {
if errors.Is(err, ErrUpstreamNotFound) {
if versionLookup {
JSONError(w, http.StatusNotFound, ErrVersionNotFound.Error())
} else {
JSONError(w, http.StatusNotFound, ErrPackageNotFound.Error())
}
return
}
if writeUpstreamHeld(w, err) {
return
}
h.proxy.Logger.Error("failed to fetch npm metadata", "error", err)
JSONError(w, http.StatusBadGateway, "failed to fetch from upstream")
return
}
rewritten, err := h.proxy.cachedRewrite(r.Context(), "npm", h.proxyURL, metadataCacheKey, upstream.body, upstream.digest, func(b []byte) ([]byte, []string, error) {
return h.rewriteMetadataKeeping(packageName, b)
})
if err != nil {
if r.Context().Err() != nil {
return // the client left while waiting on a shared rewrite
}
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
}
// If rewriting fails, just proxy the original
h.proxy.Logger.Warn("failed to rewrite metadata, proxying original", "error", err)
w.Header().Set(headerContentType, contentTypeJSON)
w.WriteHeader(http.StatusOK)
_, _ = w.Write(upstream.body)
return
}
serveRewrittenMetadata(w, r, rewritten)
}
// 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
}
if writeUpstreamHeld(w, err) {
return
}
h.proxy.Logger.Error("failed to fetch npm metadata", "error", err)
JSONError(w, http.StatusBadGateway, "failed to fetch from upstream")
return
}
rewritten, err := h.proxy.cachedRewrite(r.Context(), "npm", h.proxyURL, metadataCacheKey, packument, "", func(b []byte) ([]byte, []string, error) {
out, err := h.rewriteNPMVersionFromPackument(packageName, specifier, b)
return out, nil, err
})
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)
_, _ = w.Write(rewritten.body)
}
// 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) {
out, err := npm.Version(packageName, specifier, packument, h.npmRewriteOptions(packageName, nil))
return out, npmRewriteError(err)
}
func (h *NPMHandler) rewriteMetadataKeeping(packageName string, body []byte) ([]byte, []string, error) {
return h.rewriteMetadataWithLocal(packageName, body, nil)
}
func (h *NPMHandler) rewriteMetadataWithLocal(packageName string, body []byte, local []npmLocalEntry) ([]byte, []string, error) {
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:
return err
}
}
func (h *NPMHandler) proxyTarballURL(packageName, version, tarball string) string {
return npm.TarballURL(h.proxyURL, packageName, version, tarball)
}
// 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)
if h.proxy.versionDenied("npm", packageName, version) {
JSONError(w, http.StatusForbidden, ErrVersionDenied.Error()+": "+canonicalVersionPURL("npm", packageName, version))
return
}
// 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 {
h.proxy.serveArtifactError(w, err, "failed to read pushed package")
return
}
if local != nil {
ServeArtifactRequest(w, r, local)
return
}
if h.proxy.hostedOnly("npm", packageName) {
JSONError(w, http.StatusNotFound, ErrVersionNotFound.Error())
return
}
registryURL, cacheKey := h.sourceFor(packageName)
metadata := sync.OnceValues(func() ([]byte, error) {
upstreamURL := registryURL + "/" + url.PathEscape(packageName)
body, _, err := h.proxy.FetchOrCacheMetadata(r.Context(), "npm", cacheKey, upstreamURL, contentTypeJSON)
return body, err
})
if h.versionInCooldown(packageName, version, metadata) {
h.proxy.Logger.Info("cooldown: withholding npm tarball",
"package", packageName, "version", version)
JSONError(w, http.StatusNotFound, ErrVersionNotFound.Error())
return
}
result, err := h.getTarball(r, registryURL, packageName, version, filename, metadata)
if err != nil {
switch {
case errors.Is(err, ErrUpstreamNotFound):
JSONError(w, http.StatusNotFound, ErrPackageNotFound.Error())
case errors.Is(err, ErrArtifactBlocked), errors.Is(err, ErrVersionDenied):
JSONError(w, http.StatusForbidden, err.Error())
default:
if writeUpstreamHeld(w, err) {
return
}
h.proxy.Logger.Error("failed to get artifact", "error", err)
JSONError(w, http.StatusBadGateway, "failed to fetch package")
}
return
}
ServeArtifactRequest(w, r, result)
}
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 {
return cached, err
}
downloadURL := fmt.Sprintf("%s/%s/-/%s", registryURL, escapeNPMDownloadPackage(packageName), url.PathEscape(filename))
var sri string
body, err := metadata()
if err == nil {
if tarball := npmVersionTarball(body, version); tarball != "" {
downloadURL, err = validateTarballURL(tarball, registryURL)
if err != nil {
return nil, err
}
}
sri = npmVersionIntegrity(body, version)
}
return h.proxy.GetOrFetchArtifactFromURLWithIntegrity(r.Context(), "npm", packageName, version, cacheFilename, downloadURL, sri)
}
// 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.
func npmVersionIntegrity(body []byte, version string) string {
sri, _, _ := jsonscan.LookupString(body, "versions", version, "dist", "integrity")
if sri != "" {
if _, err := integrity.ParseSRI(sri); err == nil {
return sri
}
}
shasum, _, _ := jsonscan.LookupString(body, "versions", version, "dist", "shasum")
return sha1Checksum(shasum)
}
// 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.
func npmVersionTarball(body []byte, version string) string {
tarball, _, _ := jsonscan.LookupString(body, "versions", version, "dist", "tarball")
return tarball
}
func validateTarballURL(raw, registryURL string) (string, error) {
tarball, err := url.Parse(raw)
if err != nil {
return "", fmt.Errorf("parsing npm tarball URL: %w", err)
}
upstream, err := url.Parse(registryURL)
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
}
// 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.
//
// 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.
//
// 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.
func (h *NPMHandler) versionInCooldown(packageName, version string, metadata func() ([]byte, error)) bool {
if h.proxy.Cooldown == nil || !h.proxy.Cooldown.Enabled() {
return false
}
_, routed := h.routes.match(packageName)
versionPURL := canonicalVersionPURL("npm", packageName, version)
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)
}
}
body, err := metadata()
if err != nil {
h.proxy.Logger.Warn("cooldown: could not fetch npm metadata for download check",
"package", packageName, "version", version, "error", err)
return false
}
published, ok, err := jsonscan.LookupString(body, "time", version)
if err != nil {
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
}
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)
}
}
return !h.proxy.Cooldown.IsAllowed("npm", canonicalPackagePURL("npm", packageName), publishedAt)
}
// 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)
}
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)
}
// 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 {
return npm.VersionFromFilename(packageName, filename)
}