2026-10-13 13:27:13 +02:00
|
|
|
package handler
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"errors"
|
|
|
|
|
"fmt"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/url"
|
|
|
|
|
"strings"
|
|
|
|
|
"time"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// composerRootOnlyKeys are composer.json keys that only apply to the root
|
|
|
|
|
// project. Repositories leave them out of the version entries they serve.
|
|
|
|
|
var composerRootOnlyKeys = []string{"repositories", "config", "minimum-stability", "prefer-stable"}
|
|
|
|
|
|
|
|
|
|
// composerLocalEntry is a pushed version as Composer metadata lists it.
|
|
|
|
|
type composerLocalEntry struct {
|
|
|
|
|
version string
|
|
|
|
|
entry json.RawMessage
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// composerLocalEntries returns the pushed versions of a package for one of
|
|
|
|
|
// its metadata files: the dev versions for the ~dev file, the tagged ones for
|
|
|
|
|
// the other.
|
|
|
|
|
func (h *ComposerHandler) composerLocalEntries(packageName string, dev bool) ([]composerLocalEntry, error) {
|
|
|
|
|
local, err := h.proxy.localVersions("composer", packageName)
|
|
|
|
|
if err != nil || len(local) == 0 {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
var entries []composerLocalEntry
|
|
|
|
|
for _, lv := range local {
|
|
|
|
|
if isDevVersion(lv.Version) != dev {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
for _, key := range composerRootOnlyKeys {
|
|
|
|
|
delete(manifest, key)
|
|
|
|
|
}
|
|
|
|
|
manifest["name"] = packageName
|
|
|
|
|
manifest["version"] = lv.Version
|
|
|
|
|
manifest["time"] = lv.PushedAt.Format(time.RFC3339)
|
2026-11-08 07:59:45 +01:00
|
|
|
// As for routed packages, keep Composer from reporting installs of
|
|
|
|
|
// a private version to the default repository's notify-batch.
|
2026-11-08 08:51:39 +01:00
|
|
|
// Composer reads a null notification-url as absent too.
|
2026-11-08 10:06:20 +01:00
|
|
|
if manifest["notification-url"] == nil {
|
2026-11-08 07:59:45 +01:00
|
|
|
manifest["notification-url"] = ""
|
|
|
|
|
}
|
2026-10-13 13:27:13 +02:00
|
|
|
manifest["dist"] = map[string]string{
|
|
|
|
|
"type": "zip",
|
|
|
|
|
"url": h.localDistURL(packageName, lv.Version, lv.Filename),
|
|
|
|
|
"reference": lv.SHA256,
|
|
|
|
|
"shasum": "",
|
|
|
|
|
}
|
|
|
|
|
entry, err := json.Marshal(manifest)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
entries = append(entries, composerLocalEntry{version: lv.Version, entry: entry})
|
|
|
|
|
}
|
|
|
|
|
return entries, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *ComposerHandler) localDistURL(packageName, version, filename string) string {
|
|
|
|
|
return fmt.Sprintf("%s/composer/files/%s/%s/%s", h.proxyURL, packageName, url.PathEscape(version), filename)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// pushedComposerVersion reports whether version is one of the pushed ones.
|
|
|
|
|
// Tags are listed with and without a leading v, and both name one version.
|
|
|
|
|
func pushedComposerVersion(local []composerLocalEntry, version string) bool {
|
|
|
|
|
for _, e := range local {
|
|
|
|
|
if strings.TrimPrefix(e.version, "v") == strings.TrimPrefix(version, "v") {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// serveMetadataWithLocal serves a package's metadata with its pushed versions
|
|
|
|
|
// added, bypassing the rewrite cache like the npm handler does. When the
|
|
|
|
|
// upstream does not know the package, the document holds only the pushed
|
|
|
|
|
// versions.
|
|
|
|
|
func (h *ComposerHandler) serveMetadataWithLocal(w http.ResponseWriter, r *http.Request, packageName, upstreamURL, cacheKey string, local []composerLocalEntry) {
|
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(), "composer", cacheKey, upstreamURL, contentTypeJSON, nil)
|
2026-10-13 13:27:13 +02:00
|
|
|
var out []byte
|
|
|
|
|
var err error
|
|
|
|
|
switch {
|
|
|
|
|
case errors.Is(upstream.err, ErrUpstreamNotFound):
|
|
|
|
|
out, err = localComposerDocument(packageName, local)
|
|
|
|
|
case upstream.err != nil:
|
2026-10-15 13:35:12 +01:00
|
|
|
if writeUpstreamHeld(w, upstream.err) {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-10-13 13:27:13 +02:00
|
|
|
h.proxy.Logger.Error("upstream request failed", "error", upstream.err)
|
|
|
|
|
http.Error(w, "upstream request failed", http.StatusBadGateway)
|
|
|
|
|
return
|
|
|
|
|
default:
|
|
|
|
|
out, _, err = h.rewriteMetadataWithLocal(upstream.body, packageName, local)
|
|
|
|
|
}
|
|
|
|
|
if err != nil {
|
|
|
|
|
h.proxy.Logger.Error("failed to add pushed versions to composer metadata", "package", packageName, "error", err)
|
|
|
|
|
http.Error(w, "failed to rewrite package metadata", http.StatusBadGateway)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-10-20 21:33:08 +02:00
|
|
|
markStale(w, upstream.stale)
|
2026-10-13 13:27:13 +02:00
|
|
|
serveRewrittenMetadata(w, r, h.proxy.uncachedRewrite("composer", h.proxyURL, cacheKey, out))
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-13 13:42:34 +02:00
|
|
|
// serveHostedOnlyMetadata answers a metadata request for a package of a
|
|
|
|
|
// hosted-only vendor from its pushed versions alone.
|
|
|
|
|
func (h *ComposerHandler) serveHostedOnlyMetadata(w http.ResponseWriter, r *http.Request, packageName, cacheKey string, local []composerLocalEntry) {
|
|
|
|
|
if len(local) == 0 {
|
|
|
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
out, err := localComposerDocument(packageName, local)
|
|
|
|
|
if err != nil {
|
|
|
|
|
h.proxy.Logger.Error("failed to build composer metadata from pushed versions", "package", packageName, "error", err)
|
|
|
|
|
http.Error(w, "failed to build package metadata", http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
serveRewrittenMetadata(w, r, h.proxy.uncachedRewrite("composer", h.proxyURL, cacheKey, out))
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-13 13:27:13 +02:00
|
|
|
func localComposerDocument(packageName string, local []composerLocalEntry) ([]byte, error) {
|
|
|
|
|
entries := make([]json.RawMessage, 0, len(local))
|
|
|
|
|
for _, e := range local {
|
|
|
|
|
entries = append(entries, e.entry)
|
|
|
|
|
}
|
|
|
|
|
return json.Marshal(map[string]any{"packages": map[string]any{packageName: entries}})
|
|
|
|
|
}
|