mirror of
https://github.com/git-pkgs/proxy.git
synced 2026-11-10 08:09:17 -05:00
A response arriving during a hold was sent before it began, so a late success no longer releases the host; only one after the hold has ended does. Artifact fetches check RateLimitGate.Held before calling the fetcher, so held cache misses fail with the HeldError without counting as failures in the host's circuit breaker. Every handler that answers an upstream fetch error with 502 now answers a held request with 503 and Retry-After instead. OnHold and RecordUpstreamRateLimitHold drop the unused duration.
262 lines
7.8 KiB
Go
262 lines
7.8 KiB
Go
package handler
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/git-pkgs/proxy/internal/retention"
|
|
"github.com/git-pkgs/purl"
|
|
)
|
|
|
|
const (
|
|
nugetUpstream = "https://api.nuget.org"
|
|
nugetSearchUpstream = "https://azuresearch-usnc.nuget.org"
|
|
)
|
|
|
|
// Packages are stored under pkg:nuget/{id} with the id lowercased, since
|
|
// NuGet ids are case-insensitive. purl keeps nuget names as written, so
|
|
// package rules are lowercased the same way before the default mapping.
|
|
func init() {
|
|
retention.Default.Register(retention.Spec{Key: "nuget", CanonicalPackage: nugetRetentionKey})
|
|
}
|
|
|
|
// nugetRetentionKey maps a package rule to the stored package PURL, so
|
|
// pkg:nuget/Newtonsoft.Json matches the stored pkg:nuget/newtonsoft.json.
|
|
func nugetRetentionKey(p *purl.PURL) (string, bool) {
|
|
if p == nil {
|
|
return "", false
|
|
}
|
|
return retention.DefaultCanonical("nuget")(purl.New(p.Type, p.Namespace, strings.ToLower(p.Name), "", nil))
|
|
}
|
|
|
|
// NuGetHandler handles NuGet V3 API protocol requests.
|
|
type NuGetHandler struct {
|
|
proxy *Proxy
|
|
upstreamURL string
|
|
searchURL string
|
|
proxyURL string
|
|
}
|
|
|
|
// NewNuGetHandler creates a new NuGet protocol handler.
|
|
func NewNuGetHandler(proxy *Proxy, proxyURL string) *NuGetHandler {
|
|
return &NuGetHandler{
|
|
proxy: proxy,
|
|
upstreamURL: nugetUpstream,
|
|
searchURL: nugetSearchUpstream,
|
|
proxyURL: strings.TrimSuffix(proxyURL, "/"),
|
|
}
|
|
}
|
|
|
|
// NewNuGetHandlerWithUpstreams creates a NuGet handler with custom API and
|
|
// search upstreams.
|
|
func NewNuGetHandlerWithUpstreams(proxy *Proxy, proxyURL, upstreamURL, searchURL string) *NuGetHandler {
|
|
h := NewNuGetHandler(proxy, proxyURL)
|
|
h.upstreamURL = configuredUpstreamURL(upstreamURL, nugetUpstream)
|
|
h.searchURL = configuredUpstreamURL(searchURL, nugetSearchUpstream)
|
|
return h
|
|
}
|
|
|
|
// Routes returns the HTTP handler for NuGet requests.
|
|
func (h *NuGetHandler) Routes() http.Handler {
|
|
mux := http.NewServeMux()
|
|
|
|
// V3 API service index
|
|
mux.HandleFunc("GET /v3/index.json", h.handleServiceIndex)
|
|
|
|
// Package content (downloads)
|
|
mux.HandleFunc("GET /v3-flatcontainer/{id}/{version}/{filename}", h.handleDownload)
|
|
mux.HandleFunc("GET /v3-flatcontainer/{id}/index.json", h.handleVersionList)
|
|
|
|
// Registration (package metadata) - use prefix matching since {version}.json isn't allowed
|
|
for _, prefix := range nugetRegistrationPrefixes {
|
|
mux.HandleFunc("GET "+prefix, h.handleRegistration)
|
|
}
|
|
|
|
// Search
|
|
mux.HandleFunc("GET /query", h.proxyUpstream)
|
|
|
|
// Autocomplete
|
|
mux.HandleFunc("GET /autocomplete", h.proxyUpstream)
|
|
|
|
return mux
|
|
}
|
|
|
|
// handleServiceIndex serves the NuGet V3 service index with rewritten URLs.
|
|
func (h *NuGetHandler) handleServiceIndex(w http.ResponseWriter, r *http.Request) {
|
|
h.proxy.Logger.Info("nuget service index request")
|
|
|
|
upstreamURL := h.upstreamURL + "/v3/index.json"
|
|
|
|
body, _, err := h.proxy.FetchOrCacheMetadata(r.Context(), "nuget", "_service_index", upstreamURL)
|
|
if 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.rewriteServiceIndex(body)
|
|
if err != nil {
|
|
if h.cooldownEnabled() {
|
|
h.nugetMetadataError(w, err)
|
|
return
|
|
}
|
|
h.proxy.Logger.Warn("failed to rewrite service index, proxying original", "error", err)
|
|
w.Header().Set(headerContentType, "application/json")
|
|
_, _ = w.Write(body)
|
|
return
|
|
}
|
|
|
|
w.Header().Set(headerContentType, "application/json")
|
|
_, _ = w.Write(rewritten)
|
|
}
|
|
|
|
// rewriteServiceIndex rewrites service URLs in the index to point at this proxy.
|
|
func (h *NuGetHandler) rewriteServiceIndex(body []byte) ([]byte, error) {
|
|
var index map[string]any
|
|
if err := json.Unmarshal(body, &index); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resources, ok := index["resources"].([]any)
|
|
if !ok {
|
|
return body, nil
|
|
}
|
|
|
|
for _, res := range resources {
|
|
rmap, ok := res.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
id, _ := rmap["@id"].(string)
|
|
rtype, _ := rmap["@type"].(string)
|
|
|
|
// Rewrite URLs for services we proxy. The service type determines the
|
|
// local route because an upstream index may advertise a different host.
|
|
if id != "" {
|
|
rmap["@id"] = h.rewriteNuGetURL(id, rtype)
|
|
}
|
|
}
|
|
|
|
return json.Marshal(index)
|
|
}
|
|
|
|
// rewriteNuGetURL rewrites a NuGet service URL based on its advertised type.
|
|
// Service types the proxy does not handle are returned unchanged.
|
|
func (h *NuGetHandler) rewriteNuGetURL(origURL, serviceType string) string {
|
|
switch serviceType {
|
|
case "PackageBaseAddress/3.0.0":
|
|
return h.proxyURL + "/nuget/v3-flatcontainer/"
|
|
case "RegistrationsBaseUrl", "RegistrationsBaseUrl/3.0.0-beta", "RegistrationsBaseUrl/3.0.0-rc":
|
|
return h.proxyURL + "/nuget/v3/registration5-semver1/"
|
|
case "RegistrationsBaseUrl/3.4.0":
|
|
return h.proxyURL + "/nuget/v3/registration5-gz-semver1/"
|
|
case "RegistrationsBaseUrl/3.6.0", "RegistrationsBaseUrl/Versioned":
|
|
return h.proxyURL + "/nuget/v3/registration5-gz-semver2/"
|
|
case "SearchQueryService", "SearchQueryService/3.0.0-rc", "SearchQueryService/3.5.0":
|
|
return h.proxyURL + "/nuget/query"
|
|
case "SearchAutocompleteService", "SearchAutocompleteService/3.5.0":
|
|
return h.proxyURL + "/nuget/autocomplete"
|
|
default:
|
|
return origURL
|
|
}
|
|
}
|
|
|
|
// handleDownload serves a package file, fetching and caching from upstream if needed.
|
|
func (h *NuGetHandler) handleDownload(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
version := r.PathValue("version")
|
|
filename := r.PathValue("filename")
|
|
|
|
if id == "" || version == "" || filename == "" {
|
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if h.cooldownEnabled() {
|
|
allowed, err := h.nugetDownloadAllowed(r.Context(), id, version)
|
|
if err != nil {
|
|
h.nugetMetadataError(w, err)
|
|
return
|
|
}
|
|
if !allowed {
|
|
JSONError(w, http.StatusNotFound, "version not found")
|
|
return
|
|
}
|
|
}
|
|
|
|
// Only cache .nupkg files
|
|
if !strings.HasSuffix(filename, ".nupkg") {
|
|
h.proxyUpstream(w, r)
|
|
return
|
|
}
|
|
|
|
h.proxy.Logger.Info("nuget download request",
|
|
"id", id, "version", version, "filename", filename)
|
|
|
|
// NuGet package IDs are case-insensitive, lowercase for storage
|
|
name := strings.ToLower(id)
|
|
upstreamURL := fmt.Sprintf("%s/v3-flatcontainer/%s/%s/%s", h.upstreamURL, name, version, filename)
|
|
|
|
result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "nuget", name, version, filename, upstreamURL)
|
|
if err != nil {
|
|
h.proxy.serveArtifactError(w, err, "failed to fetch package")
|
|
return
|
|
}
|
|
|
|
ServeArtifactRequest(w, r, result)
|
|
}
|
|
|
|
// proxyUpstream forwards a request to NuGet without caching.
|
|
func (h *NuGetHandler) proxyUpstream(w http.ResponseWriter, r *http.Request) {
|
|
// Build upstream URL based on the path
|
|
upstreamURL := h.buildUpstreamURL(r)
|
|
|
|
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
|
|
}
|
|
|
|
// Copy accept-encoding for compression
|
|
if ae := r.Header.Get(headerAcceptEncoding); ae != "" {
|
|
req.Header.Set(headerAcceptEncoding, ae)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// buildUpstreamURL constructs the upstream URL for a request.
|
|
func (h *NuGetHandler) buildUpstreamURL(r *http.Request) string {
|
|
path := r.URL.Path
|
|
|
|
// Handle query and autocomplete which go to azuresearch
|
|
if strings.HasPrefix(path, "/query") || strings.HasPrefix(path, "/autocomplete") {
|
|
return h.searchURL + path + "?" + r.URL.RawQuery
|
|
}
|
|
|
|
return h.upstreamURL + path
|
|
}
|