mirror of
https://github.com/git-pkgs/proxy.git
synced 2026-08-01 10:08:08 -04:00
NormalizedPackages runs config keys through purl.Parse().String(), which applies per-type rules like lowercasing pypi names. The handler side was building lookup keys with MakePURLString, which does not, so a config entry for pkg:pypi/Django would be normalized to pkg:pypi/django and never match the runtime key pkg:pypi/Django. Route both sides through the same canonical form: a new canonicalPackagePURL helper calls Normalize() on the constructed PURL before stringifying, and all cooldown IsAllowed call sites use it.
243 lines
6.8 KiB
Go
243 lines
6.8 KiB
Go
package handler
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
condaUpstream = "https://conda.anaconda.org"
|
|
minCondaParts = 3 // name-version-build requires at least 3 hyphen-separated parts
|
|
)
|
|
|
|
// CondaHandler handles Conda/Anaconda registry protocol requests.
|
|
type CondaHandler struct {
|
|
proxy *Proxy
|
|
upstreamURL string
|
|
proxyURL string
|
|
}
|
|
|
|
// NewCondaHandler creates a new Conda protocol handler.
|
|
func NewCondaHandler(proxy *Proxy, proxyURL string) *CondaHandler {
|
|
return &CondaHandler{
|
|
proxy: proxy,
|
|
upstreamURL: condaUpstream,
|
|
proxyURL: strings.TrimSuffix(proxyURL, "/"),
|
|
}
|
|
}
|
|
|
|
// Routes returns the HTTP handler for Conda requests.
|
|
func (h *CondaHandler) Routes() http.Handler {
|
|
mux := http.NewServeMux()
|
|
|
|
// Channel index (repodata)
|
|
mux.HandleFunc("GET /{channel}/{arch}/repodata.json", h.handleRepodata)
|
|
mux.HandleFunc("GET /{channel}/{arch}/repodata.json.bz2", h.proxyCached)
|
|
mux.HandleFunc("GET /{channel}/{arch}/current_repodata.json", h.handleRepodata)
|
|
|
|
// Package downloads (cache these)
|
|
mux.HandleFunc("GET /{channel}/{arch}/{filename}", h.handleDownload)
|
|
|
|
return mux
|
|
}
|
|
|
|
// handleDownload serves a package file, fetching and caching from upstream if needed.
|
|
func (h *CondaHandler) handleDownload(w http.ResponseWriter, r *http.Request) {
|
|
channel := r.PathValue("channel")
|
|
arch := r.PathValue("arch")
|
|
filename := r.PathValue("filename")
|
|
|
|
// Only cache actual package files
|
|
if !h.isPackageFile(filename) {
|
|
h.proxyUpstream(w, r)
|
|
return
|
|
}
|
|
|
|
name, version := h.parseFilename(filename)
|
|
if name == "" {
|
|
h.proxyUpstream(w, r)
|
|
return
|
|
}
|
|
|
|
// Include channel in package name
|
|
packageName := channel + "/" + name
|
|
|
|
h.proxy.Logger.Info("conda download request",
|
|
"channel", channel, "arch", arch, "name", name, "version", version)
|
|
|
|
upstreamURL := h.upstreamURL + r.URL.Path
|
|
|
|
result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "conda", packageName, version, filename, upstreamURL)
|
|
if err != nil {
|
|
h.proxy.Logger.Error("failed to get artifact", "error", err)
|
|
http.Error(w, "failed to fetch package", http.StatusBadGateway)
|
|
return
|
|
}
|
|
|
|
ServeArtifact(w, result)
|
|
}
|
|
|
|
// isPackageFile returns true if the filename is a Conda package.
|
|
func (h *CondaHandler) isPackageFile(filename string) bool {
|
|
return strings.HasSuffix(filename, ".tar.bz2") || strings.HasSuffix(filename, ".conda")
|
|
}
|
|
|
|
// parseFilename extracts name and version from a Conda package filename.
|
|
// Conda filenames are: {name}-{version}-{build}.{ext}
|
|
// e.g., "numpy-1.24.0-py311h64a7726_0.conda"
|
|
func (h *CondaHandler) parseFilename(filename string) (name, version string) {
|
|
// Remove extension
|
|
base := filename
|
|
for _, ext := range []string{".conda", ".tar.bz2"} {
|
|
if strings.HasSuffix(base, ext) {
|
|
base = strings.TrimSuffix(base, ext)
|
|
break
|
|
}
|
|
}
|
|
|
|
// Split by hyphens, the format is name-version-build
|
|
// The name can contain hyphens, so we need to find version-build at the end
|
|
parts := strings.Split(base, "-")
|
|
if len(parts) < minCondaParts {
|
|
return "", ""
|
|
}
|
|
|
|
// Build is the last part, version is second to last
|
|
// Everything before is the name
|
|
build := parts[len(parts)-1]
|
|
version = parts[len(parts)-2]
|
|
name = strings.Join(parts[:len(parts)-2], "-")
|
|
|
|
// Version should start with a digit
|
|
if len(version) == 0 || version[0] < '0' || version[0] > '9' {
|
|
return "", ""
|
|
}
|
|
|
|
// Include build in version for uniqueness
|
|
_ = build
|
|
|
|
return name, version
|
|
}
|
|
|
|
// handleRepodata proxies repodata.json, applying cooldown filtering when enabled.
|
|
func (h *CondaHandler) handleRepodata(w http.ResponseWriter, r *http.Request) {
|
|
if h.proxy.Cooldown == nil || !h.proxy.Cooldown.Enabled() {
|
|
h.proxyCached(w, r)
|
|
return
|
|
}
|
|
|
|
upstreamURL := h.upstreamURL + r.URL.Path
|
|
|
|
h.proxy.Logger.Debug("fetching repodata for cooldown filtering", "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
|
|
}
|
|
req.Header.Set(headerAcceptEncoding, "gzip")
|
|
|
|
resp, err := h.proxy.HTTPClient.Do(req)
|
|
if err != nil {
|
|
h.proxy.Logger.Error("upstream request failed", "error", err)
|
|
http.Error(w, "upstream request failed", http.StatusBadGateway)
|
|
return
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
for k, vv := range resp.Header {
|
|
for _, v := range vv {
|
|
w.Header().Add(k, v)
|
|
}
|
|
}
|
|
w.WriteHeader(resp.StatusCode)
|
|
_, _ = io.Copy(w, resp.Body)
|
|
return
|
|
}
|
|
|
|
body, err := h.proxy.ReadMetadata(resp.Body)
|
|
if err != nil {
|
|
http.Error(w, "failed to read response", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
filtered, err := h.applyCooldownFiltering(body)
|
|
if err != nil {
|
|
h.proxy.Logger.Warn("failed to filter repodata, proxying original", "error", err)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write(body)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write(filtered)
|
|
}
|
|
|
|
// condaTimestampDivisor converts Conda's millisecond timestamps to seconds.
|
|
const condaTimestampDivisor = 1000
|
|
|
|
// applyCooldownFiltering removes entries from repodata.json that were
|
|
// published too recently based on their timestamp field.
|
|
func (h *CondaHandler) applyCooldownFiltering(body []byte) ([]byte, error) {
|
|
if h.proxy.Cooldown == nil || !h.proxy.Cooldown.Enabled() {
|
|
return body, nil
|
|
}
|
|
|
|
var repodata map[string]any
|
|
if err := json.Unmarshal(body, &repodata); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, key := range []string{"packages", "packages.conda"} {
|
|
packages, ok := repodata[key].(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
for filename, entry := range packages {
|
|
entryMap, ok := entry.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
ts, ok := entryMap["timestamp"].(float64)
|
|
if !ok || ts == 0 {
|
|
continue
|
|
}
|
|
|
|
publishedAt := time.Unix(int64(ts)/condaTimestampDivisor, 0)
|
|
|
|
name, _ := entryMap["name"].(string)
|
|
if name == "" {
|
|
continue
|
|
}
|
|
|
|
packagePURL := canonicalPackagePURL("conda", name)
|
|
|
|
if !h.proxy.Cooldown.IsAllowed("conda", packagePURL, publishedAt) {
|
|
version, _ := entryMap["version"].(string)
|
|
h.proxy.Logger.Info("cooldown: filtering conda package",
|
|
"name", name, "version", version, "filename", filename)
|
|
delete(packages, filename)
|
|
}
|
|
}
|
|
}
|
|
|
|
return json.Marshal(repodata)
|
|
}
|
|
|
|
// proxyCached forwards a metadata request with caching.
|
|
func (h *CondaHandler) proxyCached(w http.ResponseWriter, r *http.Request) {
|
|
cacheKey := strings.TrimPrefix(r.URL.Path, "/")
|
|
cacheKey = strings.ReplaceAll(cacheKey, "/", "_")
|
|
h.proxy.ProxyCached(w, r, h.upstreamURL+r.URL.Path, "conda", cacheKey, "*/*")
|
|
}
|
|
|
|
// proxyUpstream forwards a request to Anaconda without caching.
|
|
func (h *CondaHandler) proxyUpstream(w http.ResponseWriter, r *http.Request) {
|
|
h.proxy.ProxyUpstream(w, r, h.upstreamURL+r.URL.Path, []string{headerAcceptEncoding})
|
|
}
|