package handler import ( "bytes" "context" "encoding/json" "errors" "fmt" "net/http" "net/url" "path" "regexp" "strings" "time" "github.com/git-pkgs/proxy/internal/jsonscan" "github.com/git-pkgs/proxy/internal/retention" "github.com/git-pkgs/purl" ) const ( composerUpstream = "https://packagist.org" composerRepo = "https://repo.packagist.org" composerMinified = "composer/2.0" composerUnset = "__unset" composerDevReset = "~dev" composerDevFile = "~dev" // suffix of the metadata file holding dev versions vendorPackageParts = 2 ) var composerUnsetJSON = []byte(`"` + composerUnset + `"`) // Archives are stored under pkg:composer/{vendor}/{package}, which purl // lowercases, so a package rule matches whatever case it is written in. The // spec also claims the "packagist" spelling git-pkgs writes into // packages.ecosystem. func init() { retention.Default.Register(retention.Spec{Key: "composer", CanonicalPackage: composerRetentionKey}) } // composerRetentionKey maps a package rule to the stored package PURL. A // key without a vendor parses fine but can never match, since every stored // package has one, so it is refused. func composerRetentionKey(p *purl.PURL) (string, bool) { if p == nil || p.Namespace == "" { return "", false } return retention.DefaultCanonical("composer")(p) } // ComposerHandler handles Composer/Packagist registry protocol requests. type ComposerHandler struct { proxy *Proxy upstreamURL string repoURL string proxyURL string routes packageRoutes } // NewComposerHandler creates a new Composer protocol handler. func NewComposerHandler(proxy *Proxy, proxyURL string) *ComposerHandler { return &ComposerHandler{ proxy: proxy, upstreamURL: composerUpstream, repoURL: composerRepo, proxyURL: strings.TrimSuffix(proxyURL, "/"), } } // NewComposerHandlerWithUpstreams creates a Composer handler with custom API // and repository upstreams. func NewComposerHandlerWithUpstreams(proxy *Proxy, proxyURL, upstreamURL, repoURL string) *ComposerHandler { h := NewComposerHandler(proxy, proxyURL) h.upstreamURL = configuredUpstreamURL(upstreamURL, composerUpstream) h.repoURL = configuredUpstreamURL(repoURL, composerRepo) return h } // WithPackageRoutes sends packages matching a pattern to a dedicated Composer // repository instead of the default one. See packageRoutes. func (h *ComposerHandler) WithPackageRoutes(routes map[string]string) *ComposerHandler { h.routes = newPackageRoutes(routes) return h } // Routes returns the HTTP handler for Composer requests. func (h *ComposerHandler) Routes() http.Handler { mux := http.NewServeMux() // Service index mux.HandleFunc("GET /packages.json", h.handleServiceIndex) // Package metadata (Composer v2 format) - use prefix since {package}.json isn't allowed mux.HandleFunc("GET /p2/", h.handlePackageMetadata) // Package downloads mux.HandleFunc("GET /files/{vendor}/{package}/{version}/{filename}", h.handleDownload) // Search and list (proxy without modification) mux.HandleFunc("GET /search.json", h.proxyUpstream) mux.HandleFunc("GET /packages/list.json", h.proxyUpstream) return mux } // handleServiceIndex returns the Composer repository service index. func (h *ComposerHandler) handleServiceIndex(w http.ResponseWriter, r *http.Request) { // Return a minimal service index pointing to our proxy index := map[string]any{ "packages": map[string]any{}, "metadata-url": h.proxyURL + "/composer/p2/%package%.json", "notify-batch": h.upstreamURL + "/downloads/", "search": h.proxyURL + "/composer/search.json?q=%query%&type=%type%", "providers-lazy-url": h.proxyURL + "/composer/p2/%package%.json", } w.Header().Set(headerContentType, "application/json") _ = json.NewEncoder(w).Encode(index) } // sourceFor returns the repository serving a package and the key its metadata // is cached under. packageName may carry the ~dev suffix of the file holding // the package's dev versions: routes match the package without it, so its dev // versions come from the same repository, while the cache key keeps it so the // two files stay apart. func (h *ComposerHandler) sourceFor(packageName string) (repoURL, cacheKey string) { if route, routed := h.routes.match(strings.TrimSuffix(packageName, composerDevFile)); routed { return route.url, route.cacheKey(packageName) } return h.repoURL, packageName } // handlePackageMetadata proxies and rewrites package metadata. func (h *ComposerHandler) handlePackageMetadata(w http.ResponseWriter, r *http.Request) { // Parse path: /p2/{vendor}/{package}.json path := strings.TrimPrefix(r.URL.Path, "/p2/") path = strings.TrimSuffix(path, ".json") parts := strings.SplitN(path, "/", vendorPackageParts) if len(parts) != vendorPackageParts || parts[0] == "" || parts[1] == "" { http.Error(w, "invalid package path", http.StatusBadRequest) return } vendor := parts[0] pkg := parts[1] packageName := vendor + "/" + pkg h.proxy.Logger.Info("composer metadata request", "package", packageName) repoURL, cacheKey := h.sourceFor(packageName) upstreamURL := fmt.Sprintf("%s/p2/%s/%s.json", repoURL, vendor, pkg) basePackage := strings.TrimSuffix(packageName, composerDevFile) local, err := h.composerLocalEntries(basePackage, basePackage != packageName) if err != nil { h.proxy.Logger.Error("failed to read pushed composer versions", "package", basePackage, "error", err) http.Error(w, "failed to read pushed versions", http.StatusInternalServerError) return } if h.proxy.hostedOnly("composer", basePackage) { h.serveHostedOnlyMetadata(w, r, basePackage, cacheKey, local) return } if len(local) > 0 { h.serveMetadataWithLocal(w, r, basePackage, upstreamURL, cacheKey, local) return } if stored, ok := h.proxy.storedRewrite("composer", cacheKey, h.proxyURL, cacheKey); ok { serveRewrittenMetadata(w, r, stored) return } upstream := h.proxy.fetchMetadataDocument(r.Context(), "composer", cacheKey, upstreamURL, contentTypeJSON) if err := upstream.err; err != nil { if errors.Is(err, ErrUpstreamNotFound) { http.Error(w, "not found", http.StatusNotFound) return } if writeUpstreamHeld(w, err) { return } h.proxy.Logger.Error("upstream request failed", "error", err) http.Error(w, "upstream request failed", http.StatusBadGateway) return } rewritten, err := h.proxy.cachedRewrite(r.Context(), "composer", h.proxyURL, cacheKey, upstream.body, upstream.digest, h.rewriteMetadataKeeping) if err != nil { if r.Context().Err() != nil { return // the client left while waiting on a shared rewrite } h.proxy.Logger.Warn("failed to rewrite metadata, proxying original", "error", err) w.Header().Set(headerContentType, "application/json") _, _ = w.Write(upstream.body) return } serveRewrittenMetadata(w, r, rewritten) } // rewriteMetadata rewrites dist URLs in Composer metadata to point at this proxy. // If cooldown is enabled, versions published too recently are filtered out. // // The document is never decoded as a whole. Packagist serves large packages // as minified lists of thousands of versions, and expanding them into generic // maps took many times the document's size for every rewrite. Instead each // version list is walked in place and the response is assembled from slices // of the original bytes: only dist values are written fresh, and minified // metadata stays minified. func (h *ComposerHandler) rewriteMetadata(body []byte) ([]byte, error) { out, _, err := h.rewriteMetadataKeeping(body) return out, err } // rewriteMetadataKeeping is rewriteMetadata that also reports the versions // it kept, each as its package name and version. func (h *ComposerHandler) rewriteMetadataKeeping(body []byte) ([]byte, []string, error) { return h.rewriteMetadataWithLocal(body, "", nil) } // rewriteMetadataWithLocal is rewriteMetadataKeeping that also lists the // pushed versions in local under localPackage, in place of any upstream entry // with the same version. Cooldown only filters upstream versions. A document // that does not list localPackage at all is replaced by one holding only the // pushed versions. func (h *ComposerHandler) rewriteMetadataWithLocal(body []byte, localPackage string, local []composerLocalEntry) ([]byte, []string, error) { if !json.Valid(body) { return nil, nil, errors.New("composer metadata is not valid JSON") } format, _, err := jsonscan.LookupString(body, "minified") if err != nil { return nil, nil, err } minified := format == composerMinified var out bytes.Buffer var kept []string listed := false out.Grow(len(body) + len(body)/8) err = jsonscan.RewriteMembers(&out, body, func(out *bytes.Buffer, m jsonscan.Member) (bool, error) { packages := m.Value(body) if !jsonscan.KeyIs(m.Key(body), "packages") || packages[0] != '{' { return false, nil } return true, jsonscan.RewriteMembers(out, packages, func(out *bytes.Buffer, p jsonscan.Member) (bool, error) { versions := p.Value(packages) if versions[0] != '[' { return false, nil } packageName, err := jsonscan.Key(p.Key(packages)) if err != nil { return false, err } var pushed []composerLocalEntry if packageName == localPackage { pushed, listed = local, true } return true, h.writeVersions(out, packageName, versions, minified, &kept, pushed) }) }) if err != nil { return nil, nil, err } if len(local) > 0 && !listed { document, err := localComposerDocument(localPackage, local) return document, nil, err } return out.Bytes(), kept, nil } // writeVersions writes one package's version list without the versions in // cooldown and with each dist pointing at this proxy, and appends each version // it writes to kept as its package name and version. // // Minified lists stay minified. Each version written carries the fields that // differ from what the client has expanded so far, which is the upstream // entry itself unless filtered versions were dropped before it; then it also // carries the changes those versions made. Every version gets its own dist, // since the proxied URL names the version. // // The pushed versions in local follow the upstream ones and take the place of // upstream entries with the same version. func (h *ComposerHandler) writeVersions(out *bytes.Buffer, packageName string, versions []byte, minified bool, kept *[]string, local []composerLocalEntry) error { packagePURL := canonicalPackagePURL("composer", packageName) upstream, written := newComposerFields(), newComposerFields() devReset, first := false, true _, routed := h.routes.match(packageName) out.WriteByte('[') err := jsonscan.EachElement(versions, func(entry []byte) error { if minified && jsonscan.StringIs(entry, composerDevReset) { upstream.reset() written.reset() devReset = true return nil } if entry[0] != '{' { return nil } if !minified { upstream.reset() written.reset() } if err := upstream.apply(entry, minified); err != nil { return err } version := upstream.string("version") if pushedComposerVersion(local, version) || h.shouldFilterVersion(packagePURL, packageName, version, upstream.get("time")) { return nil } if !first { out.WriteByte(',') } first = false if devReset { out.WriteString(`"` + composerDevReset + `",`) devReset = false } if routed { disableDefaultNotification(upstream) } h.writeVersion(out, packageName, version, upstream, written) *kept = append(*kept, packageName+"\x00"+version) return nil }) if err != nil { return err } for _, e := range local { pushed := newComposerFields() if err := pushed.apply(e.entry, false); err != nil { return err } if !minified { written.reset() } if !first { out.WriteByte(',') } first = false if devReset { out.WriteString(`"` + composerDevReset + `",`) devReset = false } h.writeVersion(out, packageName, e.version, pushed, written) *kept = append(*kept, packageName+"\x00"+e.version) } out.WriteByte(']') return nil } // writeVersion writes the fields of upstream that differ from written, with // dist pointing at this proxy, and "__unset" for those written has and // upstream lacks. written is then what the client expands this version to. func (h *ComposerHandler) writeVersion(out *bytes.Buffer, packageName, version string, upstream, written *composerFields) { out.WriteByte('{') first := true writeMember := func(key, value []byte) { if !first { out.WriteByte(',') } first = false out.Write(key) out.WriteByte(':') out.Write(value) } for _, f := range upstream.fields { value := f.value if value == nil { continue } if f.name == "dist" { value = h.rewriteDist(packageName, version, value) } if bytes.Equal(value, written.get(f.name)) { continue } writeMember(f.key, value) written.set(f.name, f.key, value) } for _, f := range written.fields { if f.value != nil && upstream.get(f.name) == nil { writeMember(f.key, composerUnsetJSON) written.set(f.name, f.key, nil) } } out.WriteByte('}') } // shouldFilterVersion returns true if the version should be excluded due to cooldown. func (h *ComposerHandler) shouldFilterVersion(packagePURL, packageName, version string, published []byte) bool { if h.proxy.Cooldown == nil || !h.proxy.Cooldown.Enabled() { return false } var timeStr string if err := json.Unmarshal(published, &timeStr); err != nil { return false } publishedAt, err := time.Parse(time.RFC3339, timeStr) if err != nil { return false } if !h.proxy.Cooldown.IsAllowed("composer", packagePURL, publishedAt) { h.proxy.Logger.Info("cooldown: filtering composer version", "package", packageName, "version", version) return true } return false } // disableDefaultNotification stops Composer from reporting installs of a routed // package to the default repository's notify-batch URL, which would disclose // the package name to it. Composer only falls back to notify-batch when a // version has no notification-url of its own, and skips an empty one. Setting // it on the expanded fields keeps it set for the versions that inherit them. func disableDefaultNotification(fields *composerFields) { if fields.get("notification-url") == nil { fields.set("notification-url", []byte(`"notification-url"`), []byte(`""`)) } } // rewriteDist returns a version's dist object with its url pointing at this // proxy. A dist without a string url is returned unchanged. func (h *ComposerHandler) rewriteDist(packageName, version string, dist []byte) []byte { urlMember, ok, err := jsonscan.FindMember(dist, "url") if err != nil || !ok || dist[urlMember.ValStart] != '"' { return dist } var upstreamURL string if err := json.Unmarshal(urlMember.Value(dist), &upstreamURL); err != nil { return dist } distType, _, _ := jsonscan.LookupString(dist, "type") proxyURL, ok := h.proxyDistURL(packageName, version, upstreamURL, distType) if !ok { return dist } encoded, err := json.Marshal(proxyURL) if err != nil { return dist } rewritten := make([]byte, 0, len(dist)-len(urlMember.Value(dist))+len(encoded)) rewritten = append(rewritten, dist[:urlMember.ValStart]...) rewritten = append(rewritten, encoded...) return append(rewritten, dist[urlMember.ValEnd:]...) } // proxyDistURL returns the URL this proxy serves a version's archive at, or // false when the upstream URL or package name gives nothing to build it from. func (h *ComposerHandler) proxyDistURL(packageName, version, upstreamURL, distType string) (string, bool) { if upstreamURL == "" { return "", false } parts := strings.SplitN(packageName, "/", vendorPackageParts) if len(parts) != vendorPackageParts { return "", false } return fmt.Sprintf("%s/composer/files/%s/%s/%s/%s", h.proxyURL, parts[0], parts[1], url.PathEscape(version), distFilename(upstreamURL, distType)), true } // distFilename returns the file name this proxy serves an archive under: the // last segment of its upstream URL. func distFilename(upstreamURL, distType string) string { filename := "package.zip" if idx := strings.LastIndex(upstreamURL, "/"); idx >= 0 { filename = upstreamURL[idx+1:] } // GitHub zipball URLs end with a bare commit hash (no extension). // Append .zip so the archives library can detect the format. if path.Ext(filename) == "" && distType == "zip" { filename += ".zip" } return filename } // composerCommitPattern matches a full git commit hash, SHA-1 or SHA-256. var composerCommitPattern = regexp.MustCompile(`^([0-9a-f]{40}|[0-9a-f]{64})$`) // upstreamDistURL returns the upstream URL of the archive served as filename, // given the dist URL the version's metadata lists now. A branch version only // lists its current head while lock files pin older commits, so for a branch // a dist URL ending in a commit hash is rebuilt for the requested commit. A // tagged release has to ask for the listed name. Any other name gives false: // fetching the listed archive would cache it under a name it does not have. func upstreamDistURL(listedURL, distType, filename string, branch bool) (string, bool) { listed := distFilename(listedURL, distType) // Proxy versions before the .zip suffix handed out bare commit names, // and lock files written back then still ask for them. if bare := distFilename(listedURL, ""); listed != bare && path.Ext(filename) == "" { filename += listed[len(bare):] } if filename == listed { return listedURL, true } if commit, ok := shaQueryCommit(listed); ok { requested, ok := shaQueryCommit(filename) listedSha, requestedSha := "sha="+commit, "sha="+requested idx := strings.LastIndex(listed, listedSha) if !branch || !ok || idx < 0 || listed[:idx]+requestedSha+listed[idx+len(listedSha):] != filename { return "", false } idx = strings.LastIndex(listedURL, listedSha) return listedURL[:idx] + requestedSha + listedURL[idx+len(listedSha):], true } ext := path.Ext(listed) commit := strings.TrimSuffix(listed, ext) requested, ok := strings.CutSuffix(filename, ext) if !branch || !ok || !composerCommitPattern.MatchString(commit) || !composerCommitPattern.MatchString(requested) { return "", false } idx := strings.LastIndex(listedURL, commit) return listedURL[:idx] + requested + listedURL[idx+len(commit):], true } // shaQueryCommit returns the commit an archive name passes in a sha query // parameter. GitLab names the archive of every commit archive.zip and picks // the commit that way. func shaQueryCommit(filename string) (string, bool) { _, query, ok := strings.Cut(filename, "?") if !ok { return "", false } values, err := url.ParseQuery(query) if err != nil { return "", false } commit := values.Get("sha") return commit, composerCommitPattern.MatchString(commit) } // composerFields holds one version's fields as Composer sees them after // expanding the minified format: raw values under their decoded names, in the // order the names first appeared. A removed field keeps its slot with a nil // value, so the order stays stable when it comes back. type composerFields struct { fields []composerField index map[string]int } type composerField struct { name string key, value []byte } func newComposerFields() *composerFields { return &composerFields{index: map[string]int{}} } func (f *composerFields) reset() { f.fields = f.fields[:0] clear(f.index) } // get returns the raw value of the named field, or nil when it is not set. func (f *composerFields) get(name string) []byte { if i, ok := f.index[name]; ok { return f.fields[i].value } return nil } // string returns the named field when it is a string, or "". func (f *composerFields) string(name string) string { raw := f.get(name) if len(raw) == 0 || raw[0] != '"' { return "" } var s string _ = json.Unmarshal(raw, &s) return s } // set stores value under name, written with the quoted key. A nil value // removes the field. func (f *composerFields) set(name string, key, value []byte) { if i, ok := f.index[name]; ok { f.fields[i].key, f.fields[i].value = key, value return } f.index[name] = len(f.fields) f.fields = append(f.fields, composerField{name: name, key: key, value: value}) } // apply overlays the members of a version entry. In minified metadata the // value "__unset" removes the field instead. func (f *composerFields) apply(entry []byte, minified bool) error { return jsonscan.EachMember(entry, func(m jsonscan.Member) error { key, value := m.Key(entry), m.Value(entry) if minified && jsonscan.StringIs(value, composerUnset) { value = nil } if bytes.IndexByte(key, '\\') < 0 { if i, ok := f.index[string(key[1:len(key)-1])]; ok { f.fields[i].key, f.fields[i].value = key, value return nil } } name, err := jsonscan.Key(key) if err != nil { return err } f.set(name, key, value) return nil }) } // handleDownload serves a package file, fetching and caching from upstream if needed. func (h *ComposerHandler) handleDownload(w http.ResponseWriter, r *http.Request) { vendor := r.PathValue("vendor") pkg := r.PathValue("package") version := r.PathValue("version") filename := r.PathValue("filename") packageName := vendor + "/" + pkg h.proxy.Logger.Info("composer download request", "package", packageName, "version", version, "filename", filename) local, err := h.proxy.GetLocalArtifact(r.Context(), "composer", packageName, version, filename) if err != nil { h.proxy.serveArtifactError(w, err, "failed to read pushed package") return } if local != nil { ServeArtifactRequest(w, r, local) return } if h.proxy.hostedOnly("composer", packageName) { http.Error(w, "version not found", http.StatusNotFound) return } // We need to fetch the metadata to get the actual download URL since // Packagist URLs include a hash. Packagist serves dev versions (e.g. // "3.x-dev", "dev-master") from a separate "~dev" metadata file, while // tagged releases live in the regular file. Try the file most likely to // contain this version first, then fall back to the other so that both // stable and dev versions resolve correctly. metaURLs := h.metadataURLsForVersion(vendor, pkg, version) h.proxy.Logger.Debug("resolving download URL", "package", packageName, "version", version, "metadata_urls", metaURLs) var dist composerDist for _, metaURL := range metaURLs { found, err := h.findDownloadURLFromMetadata(r.Context(), metaURL, packageName, version) if err != nil { if writeUpstreamHeld(w, err) { return } h.proxy.Logger.Error("failed to fetch metadata", "error", err, "url", metaURL) http.Error(w, "failed to fetch metadata", http.StatusBadGateway) return } if found.url != "" { dist = found break } } if dist.url == "" { h.proxy.Logger.Debug("version not found in any metadata source", "package", packageName, "version", version, "tried_urls", metaURLs) http.Error(w, "version not found", http.StatusNotFound) return } // Lock files name archive URLs directly, so a version filtered out of the // metadata still has to be withheld here. if h.shouldFilterVersion(canonicalPackagePURL("composer", packageName), packageName, version, dist.published) { http.Error(w, "version not found", http.StatusNotFound) return } // The archive is cached under the file name from the path, so fetch the // one that name stands for. Compare it as sent, the way rewriteDist handed // it out, query included. requested := path.Base(r.URL.EscapedPath()) if r.URL.RawQuery != "" { requested += "?" + r.URL.RawQuery } listedURL := dist.url downloadURL, ok := upstreamDistURL(listedURL, dist.distType, requested, isDevVersion(version)) if !ok { h.proxy.Logger.Info("composer file not in upstream metadata", "package", packageName, "version", version, "filename", requested, "listed_url", listedURL) http.Error(w, "file not found", http.StatusNotFound) return } h.proxy.Logger.Debug("resolved download URL", "package", packageName, "version", version, "download_url", downloadURL) // Archives picked by ?sha= share one file name, so the commit goes into // the name they are cached under. Proxy versions before that cached every // commit under the shared name, which is dropped once a commit is fetched. var sharedFilename string if commit, ok := shaQueryCommit(requested); ok { sharedFilename = h.routes.cacheFilename(packageName, filename) ext := path.Ext(filename) filename = strings.TrimSuffix(filename, ext) + "-" + commit + ext } cacheFilename := h.routes.cacheFilename(packageName, filename) if commit := strings.TrimSuffix(filename, path.Ext(filename)); isDevVersion(version) && composerCommitPattern.MatchString(commit) { h.discardOtherCommit(packageName, version, cacheFilename, commit) } result, err := h.proxy.GetOrFetchArtifactFromURLWithIntegrity(r.Context(), "composer", packageName, version, cacheFilename, downloadURL, composerIntegrity(dist.shasum, version, downloadURL == listedURL)) if err != nil { h.proxy.serveArtifactError(w, err, "failed to fetch package") return } if sharedFilename != "" && !result.Cached { if err := h.proxy.ClearCachedArtifact("composer", packageName, version, sharedFilename); err != nil { h.proxy.Logger.Warn("failed to discard composer archive cached under a shared name", "package", packageName, "version", version, "filename", sharedFilename, "error", err) } } ServeArtifactRequest(w, r, result) } // discardOtherCommit clears a cached branch archive named for commit that was // fetched from another commit's URL. Proxy versions before the pinned-commit // fix cached the branch head under whatever commit a lock file asked for. The // record is only cleared while it still points at the object checked here. func (h *ComposerHandler) discardOtherCommit(packageName, version, cacheFilename, commit string) { _, versionPURL, err := packagePURLStrings("composer", packageName, version) if err != nil { return } record, err := h.proxy.DB.GetArtifact(versionPURL, cacheFilename) if err != nil { h.proxy.Logger.Warn("failed to read composer cache record", "purl", versionPURL, "filename", cacheFilename, "error", err) return } if record == nil || !record.StoragePath.Valid || strings.Contains(record.UpstreamURL, commit) { return } h.proxy.Logger.Warn("cached composer archive is from another commit, discarding", "purl", versionPURL, "filename", cacheFilename, "upstream_url", record.UpstreamURL) if err := h.proxy.DB.DiscardArtifact(versionPURL, cacheFilename, record.StoragePath.String); err != nil { h.proxy.Logger.Warn("failed to discard composer cache record", "purl", versionPURL, "filename", cacheFilename, "error", err) } } // isDevVersion reports whether a Composer version string refers to a // development (unstable, branch) version rather than a tagged release. // Composer formats these as either "dev-" (e.g. "dev-master") or // "-dev" (e.g. "3.x-dev"). func isDevVersion(version string) bool { return strings.HasPrefix(version, "dev-") || strings.HasSuffix(version, "-dev") } // metadataURLsForVersion returns the upstream metadata URLs to consult for a // given version, in priority order. Dev versions are served from the "~dev" // file, tagged releases from the regular file; the other file is included as a // fallback so an unexpected classification still resolves. func (h *ComposerHandler) metadataURLsForVersion(vendor, pkg, version string) []string { repoURL, _ := h.sourceFor(vendor + "/" + pkg) stable := fmt.Sprintf("%s/p2/%s/%s.json", repoURL, vendor, pkg) dev := fmt.Sprintf("%s/p2/%s/%s~dev.json", repoURL, vendor, pkg) if isDevVersion(version) { return []string{dev, stable} } return []string{stable, dev} } // composerDist is what a version's metadata says about its archive: the dist // URL, type and shasum, and the raw publish time the cooldown is checked // against. type composerDist struct { url, distType, shasum string published []byte } // findDownloadURLFromMetadata fetches a metadata document and returns the dist // of the given version, with an empty URL if the version is not present. An // error is returned only on transport failure; a missing document (non-200) // or a missing version both yield an empty URL so the caller can fall back. func (h *ComposerHandler) findDownloadURLFromMetadata(ctx context.Context, metaURL, packageName, version string) (composerDist, error) { h.proxy.Logger.Debug("fetching upstream metadata for download lookup", "url", metaURL, "package", packageName, "version", version) req, err := http.NewRequestWithContext(ctx, http.MethodGet, metaURL, nil) if err != nil { return composerDist{}, err } resp, err := h.proxy.HTTPClient.Do(req) if err != nil { return composerDist{}, err } defer func() { _ = resp.Body.Close() }() h.proxy.Logger.Debug("upstream metadata response", "url", metaURL, "status", resp.StatusCode) if resp.StatusCode != http.StatusOK { return composerDist{}, nil } body, err := h.proxy.ReadMetadata(resp.Body) if err != nil { return composerDist{}, err } dist, err := composerDistURL(body, packageName, version) if err != nil { return composerDist{}, err } h.proxy.Logger.Debug("download URL lookup result", "url", metaURL, "package", packageName, "version", version, "download_url", dist.url) return dist, nil } // composerDistURL returns the upstream dist of version from Composer // metadata, with an empty URL when the package has no such version or it has // no dist URL. It walks the version list in place, expanding only as much of // the minified format as it needs, since it runs on each archive the proxy // has not cached yet. func composerDistURL(body []byte, packageName, version string) (composerDist, error) { format, _, err := jsonscan.LookupString(body, "minified") if err != nil { return composerDist{}, err } versions, err := jsonscan.Lookup(body, "packages", packageName) if err != nil || len(versions) == 0 || versions[0] != '[' { return composerDist{}, err } minified := format == composerMinified fields := newComposerFields() var dist composerDist err = jsonscan.EachElement(versions, func(entry []byte) error { if minified && jsonscan.StringIs(entry, composerDevReset) { fields.reset() return nil } if entry[0] != '{' { return nil } if !minified { fields.reset() } if err := fields.apply(entry, minified); err != nil { return err } if !jsonscan.StringIs(fields.get("version"), version) { return nil } distRaw := fields.get("dist") dist.url, _, _ = jsonscan.LookupString(distRaw, "url") if dist.url != "" { dist.distType, _, _ = jsonscan.LookupString(distRaw, "type") dist.shasum, _, _ = jsonscan.LookupString(distRaw, "shasum") dist.published = fields.get("time") return jsonscan.ErrStop } return nil }) if errors.Is(err, jsonscan.ErrStop) { err = nil } return dist, err } // composerIntegrity returns the integrity to verify an archive of version // against: the SHA-1 its dist lists as shasum. A dev branch is left // unverified: its listed shasum describes only the current head, while every // commit of the branch is cached under the same version, which holds a single // recorded integrity, so a cache hit for an older pinned commit would be // checked against the head's shasum. Composer checks dist.shasum itself // anyway. A shasum also only describes the listed archive, and Packagist // lists none for GitHub archives; an unusable one is ignored. Those archives // are served unverified as before. func composerIntegrity(shasum, version string, listed bool) string { if !listed || isDevVersion(version) { return "" } return sha1Checksum(shasum) } // proxyUpstream forwards a request to packagist.org without caching. func (h *ComposerHandler) proxyUpstream(w http.ResponseWriter, r *http.Request) { upstreamURL := h.upstreamURL + r.URL.Path if r.URL.RawQuery != "" { upstreamURL += "?" + r.URL.RawQuery } h.proxy.Logger.Debug("proxying to upstream", "url", upstreamURL) req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, upstreamURL, nil) if err != nil { http.Error(w, "failed to create request", http.StatusInternalServerError) return } resp, err := h.proxy.HTTPClient.Do(req) if err != nil { if writeUpstreamHeld(w, err) { return } h.proxy.Logger.Error("upstream request failed", "error", err) http.Error(w, "upstream request failed", http.StatusBadGateway) return } defer func() { _ = resp.Body.Close() }() h.proxy.relayResponse(w, r, resp, nil) }