1
0
Fork 1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-07-06 22:33:19 -04:00
pkg-proxy/internal/handler/pypi.go
Andrew Nesbitt 7b22638ef7
Hello world
2026-01-20 22:00:31 +00:00

383 lines
10 KiB
Go

package handler
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
)
const (
pypiUpstream = "https://pypi.org"
)
// PyPIHandler handles PyPI registry protocol requests.
type PyPIHandler struct {
proxy *Proxy
upstreamURL string
proxyURL string
}
// NewPyPIHandler creates a new PyPI protocol handler.
func NewPyPIHandler(proxy *Proxy, proxyURL string) *PyPIHandler {
return &PyPIHandler{
proxy: proxy,
upstreamURL: pypiUpstream,
proxyURL: strings.TrimSuffix(proxyURL, "/"),
}
}
// Routes returns the HTTP handler for PyPI requests.
func (h *PyPIHandler) Routes() http.Handler {
mux := http.NewServeMux()
// Simple API (used by pip)
mux.HandleFunc("GET /simple/", h.handleSimpleIndex)
mux.HandleFunc("GET /simple/{name}/", h.handleSimplePackage)
// JSON API
mux.HandleFunc("GET /pypi/{name}/json", h.handleJSON)
mux.HandleFunc("GET /pypi/{name}/{version}/json", h.handleVersionJSON)
// Package downloads (cache these)
mux.HandleFunc("GET /packages/{path...}", h.handleDownload)
return mux
}
// handleSimpleIndex serves the simple API index.
func (h *PyPIHandler) handleSimpleIndex(w http.ResponseWriter, r *http.Request) {
// Just proxy the index through
h.proxySimple(w, r, "/simple/")
}
// handleSimplePackage serves the simple API package page with rewritten links.
func (h *PyPIHandler) handleSimplePackage(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if name == "" {
http.Error(w, "invalid package name", http.StatusBadRequest)
return
}
h.proxy.Logger.Info("pypi simple request", "package", name)
upstreamURL := fmt.Sprintf("%s/simple/%s/", h.upstreamURL, name)
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("Accept", "text/html")
resp, err := http.DefaultClient.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 {
w.WriteHeader(resp.StatusCode)
_, _ = io.Copy(w, resp.Body)
return
}
body, err := io.ReadAll(resp.Body)
if err != nil {
http.Error(w, "failed to read response", http.StatusInternalServerError)
return
}
rewritten := h.rewriteSimpleHTML(body)
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(rewritten)
}
// rewriteSimpleHTML rewrites package URLs in simple API HTML to point at this proxy.
func (h *PyPIHandler) rewriteSimpleHTML(body []byte) []byte {
// Match href attributes pointing to packages
// PyPI URLs look like: https://files.pythonhosted.org/packages/...
re := regexp.MustCompile(`href="(https://files\.pythonhosted\.org/packages/[^"]+)"`)
return re.ReplaceAllFunc(body, func(match []byte) []byte {
// Extract the URL
submatch := re.FindSubmatch(match)
if len(submatch) < 2 {
return match
}
origURL := string(submatch[1])
// Parse the URL to get the path
u, err := url.Parse(origURL)
if err != nil {
return match
}
// Rewrite to our proxy
newURL := fmt.Sprintf("%s/pypi/packages%s", h.proxyURL, u.Path)
return []byte(fmt.Sprintf(`href="%s"`, newURL))
})
}
// handleJSON serves the JSON API package metadata.
func (h *PyPIHandler) handleJSON(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if name == "" {
http.Error(w, "invalid package name", http.StatusBadRequest)
return
}
h.proxy.Logger.Info("pypi json request", "package", name)
upstreamURL := fmt.Sprintf("%s/pypi/%s/json", h.upstreamURL, name)
h.proxyAndRewriteJSON(w, r, upstreamURL)
}
// handleVersionJSON serves the JSON API version metadata.
func (h *PyPIHandler) handleVersionJSON(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
version := r.PathValue("version")
if name == "" || version == "" {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
h.proxy.Logger.Info("pypi version json request", "package", name, "version", version)
upstreamURL := fmt.Sprintf("%s/pypi/%s/%s/json", h.upstreamURL, name, version)
h.proxyAndRewriteJSON(w, r, upstreamURL)
}
// proxyAndRewriteJSON fetches JSON metadata and rewrites download URLs.
func (h *PyPIHandler) proxyAndRewriteJSON(w http.ResponseWriter, r *http.Request, upstreamURL string) {
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("Accept", "application/json")
resp, err := http.DefaultClient.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 {
w.WriteHeader(resp.StatusCode)
_, _ = io.Copy(w, resp.Body)
return
}
body, err := io.ReadAll(resp.Body)
if err != nil {
http.Error(w, "failed to read response", http.StatusInternalServerError)
return
}
rewritten, err := h.rewriteJSONMetadata(body)
if err != nil {
h.proxy.Logger.Warn("failed to rewrite metadata, proxying original", "error", err)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(body)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(rewritten)
}
// rewriteJSONMetadata rewrites download URLs in PyPI JSON metadata.
func (h *PyPIHandler) rewriteJSONMetadata(body []byte) ([]byte, error) {
var metadata map[string]any
if err := json.Unmarshal(body, &metadata); err != nil {
return nil, err
}
// Rewrite URLs in urls array
if urls, ok := metadata["urls"].([]any); ok {
for _, u := range urls {
if umap, ok := u.(map[string]any); ok {
h.rewriteURLEntry(umap)
}
}
}
// Rewrite URLs in releases map
if releases, ok := metadata["releases"].(map[string]any); ok {
for _, files := range releases {
if filesArr, ok := files.([]any); ok {
for _, f := range filesArr {
if fmap, ok := f.(map[string]any); ok {
h.rewriteURLEntry(fmap)
}
}
}
}
}
return json.Marshal(metadata)
}
// rewriteURLEntry rewrites a single URL entry in PyPI metadata.
func (h *PyPIHandler) rewriteURLEntry(entry map[string]any) {
urlStr, ok := entry["url"].(string)
if !ok {
return
}
u, err := url.Parse(urlStr)
if err != nil {
return
}
// Only rewrite pythonhosted.org URLs
if u.Host == "files.pythonhosted.org" {
newURL := fmt.Sprintf("%s/pypi/packages%s", h.proxyURL, u.Path)
entry["url"] = newURL
}
}
// handleDownload serves a package file, fetching and caching from upstream if needed.
func (h *PyPIHandler) handleDownload(w http.ResponseWriter, r *http.Request) {
path := r.PathValue("path")
if path == "" {
http.Error(w, "invalid path", http.StatusBadRequest)
return
}
// Path format: /packages/{hash_prefix}/{hash}/{filename}
// e.g., /packages/ab/cd/abc123.../requests-2.31.0.tar.gz
parts := strings.Split(path, "/")
if len(parts) < 3 {
http.Error(w, "invalid path", http.StatusBadRequest)
return
}
filename := parts[len(parts)-1]
name, version := h.parseFilename(filename)
if name == "" {
// Can't determine name/version, use hash as identifier
name = fmt.Sprintf("_hash_%s", hashPath(path))
version = "0"
}
h.proxy.Logger.Info("pypi download request",
"name", name, "version", version, "filename", filename)
// Construct upstream URL
upstreamURL := fmt.Sprintf("https://files.pythonhosted.org/packages/%s", path)
result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "pypi", name, 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)
}
// parseFilename extracts package name and version from a PyPI filename.
// Handles both wheels and sdists:
// - requests-2.31.0-py3-none-any.whl
// - requests-2.31.0.tar.gz
func (h *PyPIHandler) parseFilename(filename string) (name, version string) {
// Try wheel format first: {name}-{version}(-{build})?-{python}-{abi}-{platform}.whl
if strings.HasSuffix(filename, ".whl") {
base := strings.TrimSuffix(filename, ".whl")
parts := strings.Split(base, "-")
if len(parts) >= 5 {
// Find where version ends (version followed by python tag)
for i := 1; i < len(parts)-2; i++ {
// Check if this looks like a python tag (py2, py3, cp39, etc)
if isPythonTag(parts[i]) {
name = strings.Join(parts[:i-1], "-")
version = parts[i-1]
return
}
}
}
}
// Try sdist formats: {name}-{version}.tar.gz, {name}-{version}.zip
for _, ext := range []string{".tar.gz", ".tar.bz2", ".zip", ".tar"} {
if strings.HasSuffix(filename, ext) {
base := strings.TrimSuffix(filename, ext)
// Find last hyphen followed by version
for i := len(base) - 1; i >= 0; i-- {
if base[i] == '-' && i+1 < len(base) && isVersionStart(base[i+1]) {
return base[:i], base[i+1:]
}
}
}
}
return "", ""
}
func isPythonTag(s string) bool {
if len(s) < 2 {
return false
}
// Python tags start with py, cp, pp, ip, jy
prefixes := []string{"py", "cp", "pp", "ip", "jy"}
for _, p := range prefixes {
if strings.HasPrefix(s, p) {
return true
}
}
return false
}
func isVersionStart(c byte) bool {
return c >= '0' && c <= '9'
}
func hashPath(path string) string {
h := sha256.Sum256([]byte(path))
return hex.EncodeToString(h[:8])
}
// proxySimple proxies a simple API request.
func (h *PyPIHandler) proxySimple(w http.ResponseWriter, r *http.Request, path string) {
upstreamURL := h.upstreamURL + path
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("Accept", "text/html")
resp, err := http.DefaultClient.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() }()
for k, vv := range resp.Header {
for _, v := range vv {
w.Header().Add(k, v)
}
}
w.WriteHeader(resp.StatusCode)
_, _ = io.Copy(w, resp.Body)
}