diff --git a/internal/handler/cargo.go b/internal/handler/cargo.go index 5d7810c..2cf0b3e 100644 --- a/internal/handler/cargo.go +++ b/internal/handler/cargo.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "net/http" + "net/url" "strings" "time" @@ -30,11 +31,18 @@ type CargoHandler struct { } // NewCargoHandler creates a new cargo protocol handler. -func NewCargoHandler(proxy *Proxy, proxyURL string) *CargoHandler { +func NewCargoHandler(proxy *Proxy, proxyURL, indexURL, downloadURL string) *CargoHandler { + if strings.TrimSpace(indexURL) == "" { + indexURL = cargoUpstream + } + if strings.TrimSpace(downloadURL) == "" { + downloadURL = cargoDownloadBase + } + return &CargoHandler{ proxy: proxy, - indexURL: cargoUpstream, - downloadURL: cargoDownloadBase, + indexURL: strings.TrimSuffix(indexURL, "/"), + downloadURL: strings.TrimSuffix(downloadURL, "/"), proxyURL: strings.TrimSuffix(proxyURL, "/"), } } @@ -191,7 +199,15 @@ func (h *CargoHandler) handleDownload(w http.ResponseWriter, r *http.Request) { h.proxy.Logger.Info("cargo download request", "crate", name, "version", version, "filename", filename) - result, err := h.proxy.GetOrFetchArtifact(r.Context(), "cargo", name, version, filename) + downloadURL := fmt.Sprintf( + "%s/%s/%s", + h.downloadURL, + url.PathEscape(name), + url.PathEscape(filename), + ) + result, err := h.proxy.GetOrFetchArtifactFromURL( + r.Context(), "cargo", name, version, filename, downloadURL, + ) if err != nil { h.proxy.Logger.Error("failed to get artifact", "error", err) http.Error(w, "failed to fetch crate", http.StatusBadGateway) diff --git a/internal/handler/cargo_test.go b/internal/handler/cargo_test.go index 10d3faf..7694252 100644 --- a/internal/handler/cargo_test.go +++ b/internal/handler/cargo_test.go @@ -2,6 +2,7 @@ package handler import ( "encoding/json" + "io" "log/slog" "net/http" "net/http/httptest" @@ -10,6 +11,7 @@ import ( "time" "github.com/git-pkgs/cooldown" + "github.com/git-pkgs/registries/fetch" ) func cargoTestProxy() *Proxy { @@ -70,6 +72,64 @@ func TestCargoConfigEndpoint(t *testing.T) { } } +func TestCargoHandlerUsesConfiguredUpstreams(t *testing.T) { + t.Run("index", func(t *testing.T) { + var requestPath string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + w.Header().Set("Content-Type", "text/plain") + _, _ = io.WriteString(w, `{"name":"serde","vers":"1.0.0"}`) + })) + defer upstream.Close() + + proxy, _, _, _ := setupTestProxy(t) + proxy.HTTPClient = upstream.Client() + h := NewCargoHandler( + proxy, + "http://proxy.test", + upstream.URL+"/index/", + "https://crates.example.test/files/", + ) + + req := httptest.NewRequest(http.MethodGet, "/se/rd/serde", nil) + w := httptest.NewRecorder() + h.Routes().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String()) + } + if requestPath != "/index/se/rd/serde" { + t.Errorf("upstream path = %q, want %q", requestPath, "/index/se/rd/serde") + } + }) + + t.Run("download", func(t *testing.T) { + proxy, _, _, artifactFetcher := setupTestProxy(t) + artifactFetcher.artifact = &fetch.Artifact{ + Body: io.NopCloser(strings.NewReader("crate")), + ContentType: "application/gzip", + } + h := NewCargoHandler( + proxy, + "http://proxy.test", + "https://index.example.test/root/", + "https://crates.example.test/files/", + ) + + req := httptest.NewRequest(http.MethodGet, "/crates/serde/1.0.0/download", nil) + w := httptest.NewRecorder() + h.Routes().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String()) + } + want := "https://crates.example.test/files/serde/serde-1.0.0.crate" + if artifactFetcher.fetchedURL != want { + t.Errorf("fetched URL = %q, want %q", artifactFetcher.fetchedURL, want) + } + }) +} + func TestCargoIndexProxy(t *testing.T) { // Create a mock upstream index server indexContent := `{"name":"serde","vers":"1.0.0","deps":[],"cksum":"abc123"} diff --git a/internal/handler/npm.go b/internal/handler/npm.go index 0585eda..c82d736 100644 --- a/internal/handler/npm.go +++ b/internal/handler/npm.go @@ -27,10 +27,14 @@ type NPMHandler struct { } // NewNPMHandler creates a new npm protocol handler. -func NewNPMHandler(proxy *Proxy, proxyURL string) *NPMHandler { +func NewNPMHandler(proxy *Proxy, proxyURL, upstreamURL string) *NPMHandler { + if strings.TrimSpace(upstreamURL) == "" { + upstreamURL = npmUpstream + } + return &NPMHandler{ proxy: proxy, - upstreamURL: npmUpstream, + upstreamURL: strings.TrimSuffix(upstreamURL, "/"), proxyURL: strings.TrimSuffix(proxyURL, "/"), } } @@ -263,7 +267,15 @@ func (h *NPMHandler) handleDownload(w http.ResponseWriter, r *http.Request) { h.proxy.Logger.Info("npm download request", "package", packageName, "version", version, "filename", filename) - result, err := h.proxy.GetOrFetchArtifact(r.Context(), "npm", packageName, version, filename) + downloadURL := fmt.Sprintf( + "%s/%s/-/%s", + h.upstreamURL, + escapeNPMDownloadPackage(packageName), + url.PathEscape(filename), + ) + result, err := h.proxy.GetOrFetchArtifactFromURL( + r.Context(), "npm", packageName, version, filename, downloadURL, + ) if err != nil { h.proxy.Logger.Error("failed to get artifact", "error", err) JSONError(w, http.StatusBadGateway, "failed to fetch package") @@ -273,6 +285,14 @@ func (h *NPMHandler) handleDownload(w http.ResponseWriter, r *http.Request) { ServeArtifact(w, result) } +func escapeNPMDownloadPackage(packageName string) string { + scope, name, scoped := strings.Cut(packageName, "/") + if scoped && strings.HasPrefix(scope, "@") && len(scope) > 1 && name != "" && !strings.Contains(name, "/") { + return url.PathEscape(scope) + "/" + url.PathEscape(name) + } + return url.PathEscape(packageName) +} + // extractPackageName extracts the package name from the request path. // Handles both scoped (@scope/name) and unscoped (name) packages. func (h *NPMHandler) extractPackageName(r *http.Request) string { diff --git a/internal/handler/npm_test.go b/internal/handler/npm_test.go index bc1edde..b08ec0e 100644 --- a/internal/handler/npm_test.go +++ b/internal/handler/npm_test.go @@ -2,13 +2,16 @@ package handler import ( "encoding/json" + "io" "log/slog" "net/http" "net/http/httptest" + "strings" "testing" "time" "github.com/git-pkgs/cooldown" + "github.com/git-pkgs/registries/fetch" ) const testVersion100 = "1.0.0" @@ -46,6 +49,75 @@ func TestNPMExtractVersionFromFilename(t *testing.T) { } } +func TestNPMHandlerUsesConfiguredUpstream(t *testing.T) { + t.Run("metadata", func(t *testing.T) { + var requestPath string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"versions":{}}`) + })) + defer upstream.Close() + + proxy, _, _, _ := setupTestProxy(t) + proxy.HTTPClient = upstream.Client() + h := NewNPMHandler(proxy, "http://proxy.test", upstream.URL+"/root/") + + req := httptest.NewRequest(http.MethodGet, "/testpkg", nil) + w := httptest.NewRecorder() + h.Routes().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String()) + } + if requestPath != "/root/testpkg" { + t.Errorf("upstream path = %q, want %q", requestPath, "/root/testpkg") + } + }) + + t.Run("download", func(t *testing.T) { + proxy, _, _, artifactFetcher := setupTestProxy(t) + artifactFetcher.artifact = &fetch.Artifact{ + Body: io.NopCloser(strings.NewReader("package")), + ContentType: "application/gzip", + } + h := NewNPMHandler(proxy, "http://proxy.test", "https://npm.example.test/root/") + + req := httptest.NewRequest(http.MethodGet, "/testpkg/-/testpkg-1.0.0.tgz", nil) + w := httptest.NewRecorder() + h.Routes().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String()) + } + want := "https://npm.example.test/root/testpkg/-/testpkg-1.0.0.tgz" + if artifactFetcher.fetchedURL != want { + t.Errorf("fetched URL = %q, want %q", artifactFetcher.fetchedURL, want) + } + }) + + t.Run("scoped download", func(t *testing.T) { + proxy, _, _, artifactFetcher := setupTestProxy(t) + artifactFetcher.artifact = &fetch.Artifact{ + Body: io.NopCloser(strings.NewReader("package")), + ContentType: "application/gzip", + } + h := NewNPMHandler(proxy, "http://proxy.test", "https://npm.example.test/root/") + + req := httptest.NewRequest(http.MethodGet, "/@scope/name/-/name-1.0.0.tgz", nil) + w := httptest.NewRecorder() + h.Routes().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String()) + } + want := "https://npm.example.test/root/@scope/name/-/name-1.0.0.tgz" + if artifactFetcher.fetchedURL != want { + t.Errorf("fetched URL = %q, want %q", artifactFetcher.fetchedURL, want) + } + }) +} + func TestNPMRewriteMetadata(t *testing.T) { h := &NPMHandler{ proxy: testProxy(), diff --git a/internal/server/server.go b/internal/server/server.go index 5aac4db..0c63978 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -196,8 +196,13 @@ func (s *Server) Start() error { }) // Mount protocol handlers - npmHandler := handler.NewNPMHandler(proxy, s.cfg.BaseURL) - cargoHandler := handler.NewCargoHandler(proxy, s.cfg.BaseURL) + npmHandler := handler.NewNPMHandler(proxy, s.cfg.BaseURL, s.cfg.Upstream.NPM) + cargoHandler := handler.NewCargoHandler( + proxy, + s.cfg.BaseURL, + s.cfg.Upstream.Cargo, + s.cfg.Upstream.CargoDownload, + ) gemHandler := handler.NewGemHandler(proxy, s.cfg.BaseURL) goHandler := handler.NewGoHandler(proxy, s.cfg.BaseURL) hexHandler := handler.NewHexHandler(proxy, s.cfg.BaseURL) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index f1de62d..2d27147 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -67,8 +67,13 @@ func newTestServer(t *testing.T) *testServer { r := chi.NewRouter() // Mount handlers - npmHandler := handler.NewNPMHandler(proxy, cfg.BaseURL) - cargoHandler := handler.NewCargoHandler(proxy, cfg.BaseURL) + npmHandler := handler.NewNPMHandler(proxy, cfg.BaseURL, cfg.Upstream.NPM) + cargoHandler := handler.NewCargoHandler( + proxy, + cfg.BaseURL, + cfg.Upstream.Cargo, + cfg.Upstream.CargoDownload, + ) gemHandler := handler.NewGemHandler(proxy, cfg.BaseURL) goHandler := handler.NewGoHandler(proxy, cfg.BaseURL) pypiHandler := handler.NewPyPIHandler(proxy, cfg.BaseURL)