diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index e0d3cfb..82ee83c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -38,7 +38,7 @@ jobs: images: ghcr.io/${{ github.repository }} - name: Build and push Docker image - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf with: context: . push: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index baba90b..c23de56 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,7 +27,7 @@ jobs: go-version-file: go.mod cache: false - - uses: goreleaser/goreleaser-action@1a80836c5c9d9e5755a25cb59ec6f45a3b5f41a8 # v7.2.1 + - uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2 with: version: "~> v2" args: release --clean diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index e34bdc6..f621254 100644 --- a/.github/workflows/zizmor.yml +++ b/.github/workflows/zizmor.yml @@ -26,4 +26,4 @@ jobs: persist-credentials: false - name: Run zizmor - uses: zizmorcore/zizmor-action@a16621b09c6db4281f81a93cb393b05dcd7b7165 # v0.5.5 + uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6 diff --git a/README.md b/README.md index 2e0755e..abf3e11 100644 --- a/README.md +++ b/README.md @@ -819,16 +819,16 @@ Response: ## Web Interface -The proxy serves a web UI under `/ui`. No separate frontend build is needed -- templates and assets are embedded in the binary. `GET /` redirects to `/ui/`. The UI is mounted under its own prefix so a reverse proxy can apply different access rules to it than to the package endpoints (for example, requiring auth for `PathPrefix(/ui)` while leaving `/npm`, `/pypi` etc. open to build machines). +The proxy serves a web UI at the root URL. No separate frontend build is needed -- templates and assets are embedded in the binary. -- **Dashboard** (`/ui/`) -- cache stats, popular packages, recently cached artifacts, and vulnerability overview. -- **Install guide** (`/ui/install`) -- per-ecosystem configuration instructions, so you don't have to look them up here. -- **Package browser** (`/ui/packages`) -- browse all cached packages with filtering by ecosystem and sorting by hits, size, name, or vulnerability count. -- **Search** (`/ui/search?q=...`) -- search cached packages by name. -- **Package detail** (`/ui/package/{ecosystem}/{name}`) -- metadata, license, vulnerabilities, and version list for a package. You can select two versions to compare. -- **Version detail** (`/ui/package/{ecosystem}/{name}/{version}`) -- per-version metadata, integrity hash, artifact cache status, and hit counts. -- **Source browser** (`/ui/package/{ecosystem}/{name}/{version}/browse`) -- browse files inside cached archives with syntax highlighting for text files and image previews. -- **Version diff** (`/ui/package/{ecosystem}/{name}/compare/{v1}...{v2}`) -- side-by-side diff of two cached versions showing added, removed, and changed files. +- **Dashboard** (`/`) -- cache stats, popular packages, recently cached artifacts, and vulnerability overview. +- **Install guide** (`/install`) -- per-ecosystem configuration instructions, so you don't have to look them up here. +- **Package browser** (`/packages`) -- browse all cached packages with filtering by ecosystem and sorting by hits, size, name, or vulnerability count. +- **Search** (`/search?q=...`) -- search cached packages by name. +- **Package detail** (`/package/{ecosystem}/{name}`) -- metadata, license, vulnerabilities, and version list for a package. You can select two versions to compare. +- **Version detail** (`/package/{ecosystem}/{name}/{version}`) -- per-version metadata, integrity hash, artifact cache status, and hit counts. +- **Source browser** (`/package/{ecosystem}/{name}/{version}/browse`) -- browse files inside cached archives with syntax highlighting for text files and image previews. +- **Version diff** (`/package/{ecosystem}/{name}/compare/{v1}...{v2}`) -- side-by-side diff of two cached versions showing added, removed, and changed files. ## Monitoring diff --git a/cmd/proxy/main.go b/cmd/proxy/main.go index a8c8bdf..15a71c0 100644 --- a/cmd/proxy/main.go +++ b/cmd/proxy/main.go @@ -470,6 +470,7 @@ func runMirror() { proxy := handler.NewProxy(db, store, fetcher, resolver, logger) proxy.CacheMetadata = true // mirror always caches metadata proxy.MetadataTTL = cfg.ParseMetadataTTL() + proxy.MetadataMaxSize = cfg.ParseMetadataMaxSize() m := mirror.New(proxy, db, store, logger, *concurrency) diff --git a/docs/architecture.md b/docs/architecture.md index f04d548..85e5aaf 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -15,7 +15,7 @@ The proxy is a caching HTTP server that sits between package manager clients and │ │ /cargo/* -> CargoHandler /stats -> statsHandler │ │ │ │ /gem/* -> GemHandler /metrics -> prometheus │ │ │ │ ...17 ecosystems /api/* -> APIHandler │ │ -│ │ /ui/* -> Web UI │ │ +│ │ / -> Web UI │ │ │ └──────────────────────────────────────────────────────────┘ │ │ │ │ │ │ │ ▼ ▼ ▼ │ @@ -274,7 +274,7 @@ HTTP server setup, web UI, and API handlers. - Creates and wires together all components - Mounts protocol handlers at ecosystem-specific paths - Middleware: request ID, real IP, logging, panic recovery, active request tracking -- Web UI under `/ui`: dashboard, package browser, source browser, version comparison +- Web UI: dashboard, package browser, source browser, version comparison - Templates are embedded in the binary via `//go:embed` - Enrichment API for package metadata, vulnerability scanning, and outdated detection - Health, stats, and Prometheus metrics endpoints. `/health` runs an active write → size-check → read → verify → delete probe against the storage backend and returns a structured JSON response (`HealthResponse`) with `"ok"` / `"error"` status per subsystem. Probe results are cached (default 30 s, configurable via `health.storage_probe_interval`) to avoid overwhelming remote backends. diff --git a/docs/configuration.md b/docs/configuration.md index cf6c101..1310bd0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -265,6 +265,16 @@ Set to `"0"` to always revalidate with upstream (ETag-based conditional requests When upstream is unreachable and the cached entry is past its TTL, the proxy serves the stale cached copy with a `Warning: 110 - "Response is Stale"` header so clients can tell the data may be outdated. +### Metadata size limit + +Upstream metadata responses are buffered in memory before being rewritten and served. `metadata_max_size` caps that buffer to protect against OOM from a misbehaving upstream. Some npm packages with thousands of versions (for example `renovate`) exceed the 100 MB default, so raise this if you see `metadata response exceeds size limit` in the logs. + +```yaml +metadata_max_size: "100MB" # default +``` + +Or via environment variable: `PROXY_METADATA_MAX_SIZE=250MB`. + ## Mirror API The `/api/mirror` endpoints are disabled by default. Enable them to allow starting mirror jobs via HTTP: diff --git a/docs/swagger/docs.go b/docs/swagger/docs.go index c4b21f3..23ff54a 100644 --- a/docs/swagger/docs.go +++ b/docs/swagger/docs.go @@ -15,6 +15,135 @@ const docTemplate = `{ "host": "{{.Host}}", "basePath": "{{.BasePath}}", "paths": { + "/api/browse/{ecosystem}/{name}/{version}": { + "get": { + "description": "Lists files from the first cached artifact for a package version.", + "produces": [ + "application/json" + ], + "tags": [ + "browse" + ], + "summary": "List files inside a cached artifact", + "parameters": [ + { + "type": "string", + "description": "Ecosystem", + "name": "ecosystem", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Package name", + "name": "name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Version", + "name": "version", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Directory path inside the archive", + "name": "path", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/server.BrowseListResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + } + } + } + }, + "/api/browse/{ecosystem}/{name}/{version}/file/{filepath}": { + "get": { + "description": "Streams a single file from the cached artifact. The file path may contain slashes.", + "produces": [ + "application/octet-stream" + ], + "tags": [ + "browse" + ], + "summary": "Fetch a file inside a cached artifact", + "parameters": [ + { + "type": "string", + "description": "Ecosystem", + "name": "ecosystem", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Package name", + "name": "name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Version", + "name": "version", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "File path inside the archive", + "name": "filepath", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "file" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + } + } + } + }, "/api/bulk": { "post": { "consumes": [ @@ -60,6 +189,69 @@ const docTemplate = `{ } } }, + "/api/compare/{ecosystem}/{name}/{fromVersion}/{toVersion}": { + "get": { + "description": "Returns a structured diff for two cached versions.", + "produces": [ + "application/json" + ], + "tags": [ + "browse" + ], + "summary": "Compare two cached versions", + "parameters": [ + { + "type": "string", + "description": "Ecosystem", + "name": "ecosystem", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Package name", + "name": "name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "From version", + "name": "fromVersion", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "To version", + "name": "toVersion", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + } + } + } + }, "/api/outdated": { "post": { "consumes": [ @@ -253,198 +445,6 @@ const docTemplate = `{ } } } - }, - "/ui/api/browse/{ecosystem}/{name}/{version}": { - "get": { - "description": "Lists files from the first cached artifact for a package version.", - "produces": [ - "application/json" - ], - "tags": [ - "browse" - ], - "summary": "List files inside a cached artifact", - "parameters": [ - { - "type": "string", - "description": "Ecosystem", - "name": "ecosystem", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "Package name", - "name": "name", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "Version", - "name": "version", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "Directory path inside the archive", - "name": "path", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/server.BrowseListResponse" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/server.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/server.ErrorResponse" - } - } - } - } - }, - "/ui/api/browse/{ecosystem}/{name}/{version}/file/{filepath}": { - "get": { - "description": "Streams a single file from the cached artifact. The file path may contain slashes.", - "produces": [ - "application/octet-stream" - ], - "tags": [ - "browse" - ], - "summary": "Fetch a file inside a cached artifact", - "parameters": [ - { - "type": "string", - "description": "Ecosystem", - "name": "ecosystem", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "Package name", - "name": "name", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "Version", - "name": "version", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "File path inside the archive", - "name": "filepath", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "file" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/server.ErrorResponse" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/server.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/server.ErrorResponse" - } - } - } - } - }, - "/ui/api/compare/{ecosystem}/{name}/{fromVersion}/{toVersion}": { - "get": { - "description": "Returns a structured diff for two cached versions.", - "produces": [ - "application/json" - ], - "tags": [ - "browse" - ], - "summary": "Compare two cached versions", - "parameters": [ - { - "type": "string", - "description": "Ecosystem", - "name": "ecosystem", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "Package name", - "name": "name", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "From version", - "name": "fromVersion", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "To version", - "name": "toVersion", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": true - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/server.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/server.ErrorResponse" - } - } - } - } } }, "definitions": { diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json index 898f580..c2b4dfc 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.json @@ -8,6 +8,135 @@ }, "basePath": "/", "paths": { + "/api/browse/{ecosystem}/{name}/{version}": { + "get": { + "description": "Lists files from the first cached artifact for a package version.", + "produces": [ + "application/json" + ], + "tags": [ + "browse" + ], + "summary": "List files inside a cached artifact", + "parameters": [ + { + "type": "string", + "description": "Ecosystem", + "name": "ecosystem", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Package name", + "name": "name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Version", + "name": "version", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Directory path inside the archive", + "name": "path", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/server.BrowseListResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + } + } + } + }, + "/api/browse/{ecosystem}/{name}/{version}/file/{filepath}": { + "get": { + "description": "Streams a single file from the cached artifact. The file path may contain slashes.", + "produces": [ + "application/octet-stream" + ], + "tags": [ + "browse" + ], + "summary": "Fetch a file inside a cached artifact", + "parameters": [ + { + "type": "string", + "description": "Ecosystem", + "name": "ecosystem", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Package name", + "name": "name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Version", + "name": "version", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "File path inside the archive", + "name": "filepath", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "file" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + } + } + } + }, "/api/bulk": { "post": { "consumes": [ @@ -53,6 +182,69 @@ } } }, + "/api/compare/{ecosystem}/{name}/{fromVersion}/{toVersion}": { + "get": { + "description": "Returns a structured diff for two cached versions.", + "produces": [ + "application/json" + ], + "tags": [ + "browse" + ], + "summary": "Compare two cached versions", + "parameters": [ + { + "type": "string", + "description": "Ecosystem", + "name": "ecosystem", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Package name", + "name": "name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "From version", + "name": "fromVersion", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "To version", + "name": "toVersion", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + } + } + } + }, "/api/outdated": { "post": { "consumes": [ @@ -246,198 +438,6 @@ } } } - }, - "/ui/api/browse/{ecosystem}/{name}/{version}": { - "get": { - "description": "Lists files from the first cached artifact for a package version.", - "produces": [ - "application/json" - ], - "tags": [ - "browse" - ], - "summary": "List files inside a cached artifact", - "parameters": [ - { - "type": "string", - "description": "Ecosystem", - "name": "ecosystem", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "Package name", - "name": "name", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "Version", - "name": "version", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "Directory path inside the archive", - "name": "path", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/server.BrowseListResponse" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/server.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/server.ErrorResponse" - } - } - } - } - }, - "/ui/api/browse/{ecosystem}/{name}/{version}/file/{filepath}": { - "get": { - "description": "Streams a single file from the cached artifact. The file path may contain slashes.", - "produces": [ - "application/octet-stream" - ], - "tags": [ - "browse" - ], - "summary": "Fetch a file inside a cached artifact", - "parameters": [ - { - "type": "string", - "description": "Ecosystem", - "name": "ecosystem", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "Package name", - "name": "name", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "Version", - "name": "version", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "File path inside the archive", - "name": "filepath", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "file" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/server.ErrorResponse" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/server.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/server.ErrorResponse" - } - } - } - } - }, - "/ui/api/compare/{ecosystem}/{name}/{fromVersion}/{toVersion}": { - "get": { - "description": "Returns a structured diff for two cached versions.", - "produces": [ - "application/json" - ], - "tags": [ - "browse" - ], - "summary": "Compare two cached versions", - "parameters": [ - { - "type": "string", - "description": "Ecosystem", - "name": "ecosystem", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "Package name", - "name": "name", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "From version", - "name": "fromVersion", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "To version", - "name": "toVersion", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": true - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/server.ErrorResponse" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/server.ErrorResponse" - } - } - } - } } }, "definitions": { diff --git a/internal/config/config.go b/internal/config/config.go index 87e23ac..0e8405d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -96,6 +96,11 @@ type Config struct { // Default: "5m". Set to "0" to always revalidate. MetadataTTL string `json:"metadata_ttl" yaml:"metadata_ttl"` + // MetadataMaxSize is the maximum size of an upstream metadata response + // the proxy will buffer (e.g. "100MB", "250MB"). Responses over this + // size return ErrMetadataTooLarge. Default: "100MB". + MetadataMaxSize string `json:"metadata_max_size" yaml:"metadata_max_size"` + // MirrorAPI enables the /api/mirror endpoints for starting mirror jobs via HTTP. // Disabled by default to prevent unauthenticated users from triggering downloads. MirrorAPI bool `json:"mirror_api" yaml:"mirror_api"` @@ -424,6 +429,9 @@ func (c *Config) LoadFromEnv() { if v := os.Getenv("PROXY_METADATA_TTL"); v != "" { c.MetadataTTL = v } + if v := os.Getenv("PROXY_METADATA_MAX_SIZE"); v != "" { + c.MetadataMaxSize = v + } if v := os.Getenv("PROXY_GRADLE_BUILD_CACHE_READ_ONLY"); v != "" { c.Gradle.BuildCache.ReadOnly = v == "true" || v == "1" } @@ -513,6 +521,10 @@ func (c *Config) Validate() error { } } + if err := validateMetadataMaxSize(c.MetadataMaxSize); err != nil { + return err + } + if err := c.Health.Validate(); err != nil { return err } @@ -582,6 +594,7 @@ func (g *GradleBuildCacheConfig) Validate() error { const ( defaultMetadataTTL = 5 * time.Minute //nolint:mnd // sensible default defaultDirectServeTTL = 15 * time.Minute //nolint:mnd // sensible default + defaultMetadataMaxSize = 100 << 20 defaultGradleBuildCacheMaxUploadSize = 100 << 20 defaultGradleBuildCacheSweepInterval = 10 * time.Minute defaultGradleMaxUploadSizeStr = "100MB" @@ -601,6 +614,33 @@ func (c *Config) ParseMaxSize() int64 { return size } +func validateMetadataMaxSize(s string) error { + if s == "" { + return nil + } + size, err := ParseSize(s) + if err != nil { + return fmt.Errorf("invalid metadata_max_size: %w", err) + } + if size <= 0 { + return fmt.Errorf("invalid metadata_max_size %q: must be positive", s) + } + return nil +} + +// ParseMetadataMaxSize returns the maximum metadata response size in bytes. +// Returns 100MB if unset or invalid. +func (c *Config) ParseMetadataMaxSize() int64 { + if c.MetadataMaxSize == "" { + return defaultMetadataMaxSize + } + size, err := ParseSize(c.MetadataMaxSize) + if err != nil || size <= 0 { + return defaultMetadataMaxSize + } + return size +} + // ParseMetadataTTL returns the metadata TTL duration. // Returns 5 minutes if unset, 0 if explicitly disabled. func (c *Config) ParseMetadataTTL() time.Duration { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 05fec3a..d633c25 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -428,6 +428,52 @@ func TestParseMetadataTTL(t *testing.T) { } } +func TestParseMetadataMaxSize(t *testing.T) { + tests := []struct { + name string + size string + want int64 + }{ + {"unset uses default", "", defaultMetadataMaxSize}, + {"explicit value", "250MB", 250 << 20}, + {"bytes", "1024", 1024}, + {"invalid uses default", "lots", defaultMetadataMaxSize}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := Default() + cfg.MetadataMaxSize = tt.size + got := cfg.ParseMetadataMaxSize() + if got != tt.want { + t.Errorf("ParseMetadataMaxSize() = %d, want %d", got, tt.want) + } + }) + } +} + +func TestValidateMetadataMaxSize(t *testing.T) { + cfg := Default() + cfg.MetadataMaxSize = "not-a-size" + if err := cfg.Validate(); err == nil { + t.Error("expected validation error for invalid metadata_max_size") + } + + cfg.MetadataMaxSize = "0" + if err := cfg.Validate(); err == nil { + t.Error("expected validation error for zero metadata_max_size") + } + + cfg.MetadataMaxSize = "250MB" + if err := cfg.Validate(); err != nil { + t.Errorf("unexpected error for valid metadata_max_size: %v", err) + } + + cfg.MetadataMaxSize = "" + if err := cfg.Validate(); err != nil { + t.Errorf("unexpected error for unset metadata_max_size: %v", err) + } +} + func TestValidateMetadataTTL(t *testing.T) { cfg := Default() cfg.MetadataTTL = "invalid" diff --git a/internal/handler/conda.go b/internal/handler/conda.go index 1336f94..cfa20c8 100644 --- a/internal/handler/conda.go +++ b/internal/handler/conda.go @@ -161,7 +161,7 @@ func (h *CondaHandler) handleRepodata(w http.ResponseWriter, r *http.Request) { return } - body, err := ReadMetadata(resp.Body) + body, err := h.proxy.ReadMetadata(resp.Body) if err != nil { http.Error(w, "failed to read response", http.StatusInternalServerError) return diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 3df2777..d06ca83 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -52,23 +52,25 @@ const contentTypeJSON = "application/json" const headerAcceptEncoding = "Accept-Encoding" -// maxMetadataSize is the maximum size of upstream metadata responses (100 MB). -// Package metadata (e.g. npm with many versions) can be large, but unbounded -// reads risk OOM if an upstream misbehaves. -const maxMetadataSize = 100 << 20 +// defaultMetadataMaxSize is used when Proxy.MetadataMaxSize is unset. +const defaultMetadataMaxSize = 100 << 20 -// ErrMetadataTooLarge is returned when upstream metadata exceeds maxMetadataSize. +// ErrMetadataTooLarge is returned when upstream metadata exceeds the configured limit. var ErrMetadataTooLarge = errors.New("metadata response exceeds size limit") // ReadMetadata reads an upstream response body with a size limit to prevent OOM // from unexpectedly large responses. Returns ErrMetadataTooLarge if the response // is truncated by the limit. -func ReadMetadata(r io.Reader) ([]byte, error) { - data, err := io.ReadAll(io.LimitReader(r, maxMetadataSize+1)) +func (p *Proxy) ReadMetadata(r io.Reader) ([]byte, error) { + limit := p.MetadataMaxSize + if limit <= 0 { + limit = defaultMetadataMaxSize + } + data, err := io.ReadAll(io.LimitReader(r, limit+1)) if err != nil { return nil, err } - if int64(len(data)) > maxMetadataSize { + if int64(len(data)) > limit { return nil, ErrMetadataTooLarge } return data, nil @@ -84,6 +86,7 @@ type Proxy struct { Cooldown *cooldown.Config CacheMetadata bool MetadataTTL time.Duration + MetadataMaxSize int64 GradleReadOnly bool GradleMaxUploadSize int64 DirectServe bool @@ -474,7 +477,7 @@ func (p *Proxy) FetchOrCacheMetadata(ctx context.Context, ecosystem, cacheKey, u cached, readErr := p.Storage.Open(ctx, entry.StoragePath) if readErr == nil { defer func() { _ = cached.Close() }() - data, readErr := ReadMetadata(cached) + data, readErr := p.ReadMetadata(cached) if readErr == nil { ct := contentTypeJSON if entry.ContentType.Valid { @@ -519,7 +522,7 @@ func (p *Proxy) FetchOrCacheMetadata(ctx context.Context, ecosystem, cacheKey, u } defer func() { _ = cached.Close() }() - data, readErr := ReadMetadata(cached) + data, readErr := p.ReadMetadata(cached) if readErr != nil { return nil, "", fmt.Errorf("upstream failed and cached read error: %w", err) } @@ -561,7 +564,7 @@ func (p *Proxy) fetchUpstreamMetadata(ctx context.Context, upstreamURL string, e return nil, "", "", zeroTime, errStale304 } defer func() { _ = cached.Close() }() - data, readErr := ReadMetadata(cached) + data, readErr := p.ReadMetadata(cached) if readErr != nil { return nil, "", "", zeroTime, errStale304 } @@ -583,7 +586,7 @@ func (p *Proxy) fetchUpstreamMetadata(ctx context.Context, upstreamURL string, e return nil, "", "", zeroTime, fmt.Errorf("upstream returned %d", resp.StatusCode) } - body, err := ReadMetadata(resp.Body) + body, err := p.ReadMetadata(resp.Body) if err != nil { return nil, "", "", zeroTime, fmt.Errorf("reading response: %w", err) } diff --git a/internal/handler/nuget.go b/internal/handler/nuget.go index 3cce7f8..40b8b5f 100644 --- a/internal/handler/nuget.go +++ b/internal/handler/nuget.go @@ -193,7 +193,7 @@ func (h *NuGetHandler) handleRegistration(w http.ResponseWriter, r *http.Request return } - body, err := ReadMetadata(resp.Body) + body, err := h.proxy.ReadMetadata(resp.Body) if err != nil { http.Error(w, "failed to read response", http.StatusInternalServerError) return diff --git a/internal/handler/read_metadata_test.go b/internal/handler/read_metadata_test.go index 60c1cf2..b13bddb 100644 --- a/internal/handler/read_metadata_test.go +++ b/internal/handler/read_metadata_test.go @@ -7,9 +7,12 @@ import ( ) func TestReadMetadata(t *testing.T) { + const limit = 1024 + p := &Proxy{MetadataMaxSize: limit} + t.Run("small body", func(t *testing.T) { data := []byte("hello world") - got, err := ReadMetadata(bytes.NewReader(data)) + got, err := p.ReadMetadata(bytes.NewReader(data)) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -19,27 +22,39 @@ func TestReadMetadata(t *testing.T) { }) t.Run("exactly at limit", func(t *testing.T) { - data := make([]byte, maxMetadataSize) + data := make([]byte, limit) for i := range data { data[i] = 'x' } - got, err := ReadMetadata(bytes.NewReader(data)) + got, err := p.ReadMetadata(bytes.NewReader(data)) if err != nil { t.Fatalf("unexpected error: %v", err) } - if len(got) != int(maxMetadataSize) { - t.Errorf("got length %d, want %d", len(got), maxMetadataSize) + if len(got) != limit { + t.Errorf("got length %d, want %d", len(got), limit) } }) t.Run("over limit returns error", func(t *testing.T) { - data := make([]byte, maxMetadataSize+100) + data := make([]byte, limit+100) for i := range data { data[i] = 'x' } - _, err := ReadMetadata(bytes.NewReader(data)) + _, err := p.ReadMetadata(bytes.NewReader(data)) if !errors.Is(err, ErrMetadataTooLarge) { t.Errorf("got error %v, want ErrMetadataTooLarge", err) } }) + + t.Run("zero limit uses default", func(t *testing.T) { + p := &Proxy{} + data := make([]byte, 1<<20) + got, err := p.ReadMetadata(bytes.NewReader(data)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != len(data) { + t.Errorf("got length %d, want %d", len(got), len(data)) + } + }) } diff --git a/internal/server/browse.go b/internal/server/browse.go index ba25afc..be2b04a 100644 --- a/internal/server/browse.go +++ b/internal/server/browse.go @@ -119,7 +119,7 @@ type BrowseFileInfo struct { // @Success 200 {object} BrowseListResponse // @Failure 404 {object} ErrorResponse // @Failure 500 {object} ErrorResponse -// @Router /ui/api/browse/{ecosystem}/{name}/{version} [get] +// @Router /api/browse/{ecosystem}/{name}/{version} [get] // handleBrowsePath dispatches /api/browse/{ecosystem}/* to the appropriate browse handler. // It resolves namespaced package names by consulting the database. // @@ -296,7 +296,7 @@ func (s *Server) browseList(w http.ResponseWriter, r *http.Request, ecosystem, n // @Failure 400 {object} ErrorResponse // @Failure 404 {object} ErrorResponse // @Failure 500 {object} ErrorResponse -// @Router /ui/api/browse/{ecosystem}/{name}/{version}/file/{filepath} [get] +// @Router /api/browse/{ecosystem}/{name}/{version}/file/{filepath} [get] func (s *Server) browseFile(w http.ResponseWriter, r *http.Request, ecosystem, name, version, filePath string) { if filePath == "" { badRequest(w, "file path required") @@ -498,7 +498,7 @@ type BrowseSourceData struct { // @Success 200 {object} map[string]any // @Failure 404 {object} ErrorResponse // @Failure 500 {object} ErrorResponse -// @Router /ui/api/compare/{ecosystem}/{name}/{fromVersion}/{toVersion} [get] +// @Router /api/compare/{ecosystem}/{name}/{fromVersion}/{toVersion} [get] func (s *Server) compareDiff(w http.ResponseWriter, r *http.Request, ecosystem, name, fromVersion, toVersion string) { // Get artifacts for both versions fromPURL := purl.MakePURLString(ecosystem, name, fromVersion) diff --git a/internal/server/browse_test.go b/internal/server/browse_test.go index f1fb993..28f08da 100644 --- a/internal/server/browse_test.go +++ b/internal/server/browse_test.go @@ -65,7 +65,7 @@ func TestHandleBrowseList(t *testing.T) { } // Test listing root directory - req := httptest.NewRequest("GET", "/ui/api/browse/npm/test-browse/1.0.0", nil) + req := httptest.NewRequest("GET", "/api/browse/npm/test-browse/1.0.0", nil) w := httptest.NewRecorder() ts.handler.ServeHTTP(w, req) @@ -83,7 +83,7 @@ func TestHandleBrowseList(t *testing.T) { } // Test listing subdirectory - req = httptest.NewRequest("GET", "/ui/api/browse/npm/test-browse/1.0.0?path=lib", nil) + req = httptest.NewRequest("GET", "/api/browse/npm/test-browse/1.0.0?path=lib", nil) w = httptest.NewRecorder() ts.handler.ServeHTTP(w, req) @@ -138,7 +138,7 @@ func TestHandleBrowseFile(t *testing.T) { } // Test fetching a file - req := httptest.NewRequest("GET", "/ui/api/browse/npm/test-browse/1.0.0/file/README.md", nil) + req := httptest.NewRequest("GET", "/api/browse/npm/test-browse/1.0.0/file/README.md", nil) w := httptest.NewRecorder() ts.handler.ServeHTTP(w, req) @@ -158,7 +158,7 @@ func TestHandleBrowseFile(t *testing.T) { } // Test fetching non-existent file - req = httptest.NewRequest("GET", "/ui/api/browse/npm/test-browse/1.0.0/file/nonexistent.txt", nil) + req = httptest.NewRequest("GET", "/api/browse/npm/test-browse/1.0.0/file/nonexistent.txt", nil) w = httptest.NewRecorder() ts.handler.ServeHTTP(w, req) @@ -314,7 +314,7 @@ func TestBrowseNonCachedArtifact(t *testing.T) { } // Try to browse - req := httptest.NewRequest("GET", "/ui/api/browse/npm/not-cached/1.0.0", nil) + req := httptest.NewRequest("GET", "/api/browse/npm/not-cached/1.0.0", nil) w := httptest.NewRecorder() ts.handler.ServeHTTP(w, req) @@ -368,7 +368,7 @@ func TestHandleBrowseSourcePage(t *testing.T) { } // Test the browse source page loads - req := httptest.NewRequest("GET", "/ui/package/npm/test-browse/1.0.0/browse", nil) + req := httptest.NewRequest("GET", "/package/npm/test-browse/1.0.0/browse", nil) w := httptest.NewRecorder() ts.handler.ServeHTTP(w, req) @@ -501,7 +501,7 @@ func TestHandleCompareDiff(t *testing.T) { } // Test the compare endpoint - req := httptest.NewRequest("GET", "/ui/api/compare/npm/test-compare/1.0.0/2.0.0", nil) + req := httptest.NewRequest("GET", "/api/compare/npm/test-compare/1.0.0/2.0.0", nil) w := httptest.NewRecorder() ts.handler.ServeHTTP(w, req) @@ -572,7 +572,7 @@ func TestHandleComparePage(t *testing.T) { defer ts.close() // Test valid format with ... separator - req := httptest.NewRequest("GET", "/ui/package/npm/test/compare/1.0.0...2.0.0", nil) + req := httptest.NewRequest("GET", "/package/npm/test/compare/1.0.0...2.0.0", nil) w := httptest.NewRecorder() ts.handler.ServeHTTP(w, req) @@ -591,7 +591,7 @@ func TestHandleComparePage(t *testing.T) { } // Test invalid format (missing separator) - req = httptest.NewRequest("GET", "/ui/package/npm/test/compare/invalid", nil) + req = httptest.NewRequest("GET", "/package/npm/test/compare/invalid", nil) w = httptest.NewRecorder() ts.handler.ServeHTTP(w, req) @@ -600,7 +600,7 @@ func TestHandleComparePage(t *testing.T) { } // Test with only one dot (should fail) - req = httptest.NewRequest("GET", "/ui/package/npm/test/compare/1.0.0.2.0.0", nil) + req = httptest.NewRequest("GET", "/package/npm/test/compare/1.0.0.2.0.0", nil) w = httptest.NewRecorder() ts.handler.ServeHTTP(w, req) diff --git a/internal/server/server.go b/internal/server/server.go index 70473bf..7de5041 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -21,20 +21,11 @@ // - /rpm/* - RPM/Yum repository protocol // // Additional endpoints: -// - /health - Health check endpoint -// - /stats - Cache statistics (JSON) +// - /health - Health check endpoint +// - /stats - Cache statistics (JSON) // - /openapi.json - OpenAPI spec (JSON) -// - /metrics - Prometheus metrics -// -// Web UI (HTML), mounted under /ui so reverse proxies can gate it -// separately from the package endpoints: -// - /ui/ - Dashboard -// - /ui/install - Client configuration guide -// - /ui/packages - List all cached packages -// - /ui/search - Search packages -// - /ui/package/... - Package and version detail pages -// - /ui/api/browse/... - Archive browsing (used by the UI) -// - /ui/api/compare/... - Archive diffing (used by the UI) +// - /packages - List all cached packages (HTML) +// - /search - Search packages (HTML) // // API endpoints for enrichment data: // - GET /api/package/{ecosystem}/{name} - Package metadata @@ -169,6 +160,7 @@ func (s *Server) Start() error { proxy.Cooldown = cd proxy.CacheMetadata = s.cfg.CacheMetadata proxy.MetadataTTL = s.cfg.ParseMetadataTTL() + proxy.MetadataMaxSize = s.cfg.ParseMetadataMaxSize() proxy.GradleReadOnly = s.cfg.Gradle.BuildCache.ReadOnly proxy.GradleMaxUploadSize = s.cfg.ParseGradleBuildCacheMaxUploadSize() proxy.DirectServe = s.cfg.Storage.DirectServe @@ -238,29 +230,19 @@ func (s *Server) Start() error { r.Mount("/debian", http.StripPrefix("/debian", debianHandler.Routes())) r.Mount("/rpm", http.StripPrefix("/rpm", rpmHandler.Routes())) - // Health, stats, and metrics endpoints + // Health, stats, and static endpoints r.Get("/health", s.handleHealth) r.Get("/stats", s.handleStats) r.Get("/openapi.json", s.handleOpenAPIJSON) r.Get("/metrics", func(w http.ResponseWriter, r *http.Request) { metrics.Handler().ServeHTTP(w, r) }) - - // Web UI. Mounted under /ui so a reverse proxy can apply different - // access rules to it than to the package endpoints above (#123). - r.Route("/ui", func(ui chi.Router) { - ui.Mount("/static", http.StripPrefix("/ui/static/", staticHandler())) - ui.Get("/", s.handleRoot) - ui.Get("/install", s.handleInstall) - ui.Get("/search", s.handleSearch) - ui.Get("/packages", s.handlePackagesList) - ui.Get("/package/{ecosystem}/*", s.handlePackagePath) - ui.Get("/api/browse/{ecosystem}/*", s.handleBrowsePath) - ui.Get("/api/compare/{ecosystem}/*", s.handleComparePath) - }) - r.Get("/", func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, "/ui/", http.StatusFound) - }) + r.Mount("/static", http.StripPrefix("/static/", staticHandler())) + r.Get("/", s.handleRoot) + r.Get("/install", s.handleInstall) + r.Get("/search", s.handleSearch) + r.Get("/packages", s.handlePackagesList) + r.Get("/package/{ecosystem}/*", s.handlePackagePath) // API endpoints for enrichment data enrichSvc := enrichment.New(s.logger) @@ -273,6 +255,10 @@ func (s *Server) Start() error { r.Get("/api/search", apiHandler.HandleSearch) r.Get("/api/packages", apiHandler.HandlePackagesList) + // Archive browsing and comparison endpoints also use wildcard for namespaced packages + r.Get("/api/browse/{ecosystem}/*", s.handleBrowsePath) + r.Get("/api/compare/{ecosystem}/*", s.handleComparePath) + // Start background context (used by mirror jobs and cleanup) bgCtx, bgCancel := context.WithCancel(context.Background()) s.cancel = bgCancel @@ -503,7 +489,7 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { ecosystem := r.URL.Query().Get("ecosystem") if query == "" { - http.Redirect(w, r, "/ui/", http.StatusSeeOther) + http.Redirect(w, r, "/", http.StatusSeeOther) return } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 17f2352..e2dc1c2 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -101,19 +101,14 @@ func newTestServer(t *testing.T) *testServer { r.Get("/health", s.handleHealth) r.Get("/stats", s.handleStats) r.Get("/openapi.json", s.handleOpenAPIJSON) - r.Route("/ui", func(ui chi.Router) { - ui.Mount("/static", http.StripPrefix("/ui/static/", staticHandler())) - ui.Get("/", s.handleRoot) - ui.Get("/install", s.handleInstall) - ui.Get("/search", s.handleSearch) - ui.Get("/packages", s.handlePackagesList) - ui.Get("/package/{ecosystem}/*", s.handlePackagePath) - ui.Get("/api/browse/{ecosystem}/*", s.handleBrowsePath) - ui.Get("/api/compare/{ecosystem}/*", s.handleComparePath) - }) - r.Get("/", func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, "/ui/", http.StatusFound) - }) + r.Mount("/static", http.StripPrefix("/static/", staticHandler())) + r.Get("/search", s.handleSearch) + r.Get("/package/{ecosystem}/*", s.handlePackagePath) + r.Get("/api/browse/{ecosystem}/*", s.handleBrowsePath) + r.Get("/api/compare/{ecosystem}/*", s.handleComparePath) + r.Get("/", s.handleRoot) + r.Get("/install", s.handleInstall) + r.Get("/packages", s.handlePackagesList) return &testServer{ handler: r, @@ -279,7 +274,7 @@ func TestDashboard(t *testing.T) { ts := newTestServer(t) defer ts.close() - req := httptest.NewRequest("GET", "/ui/", nil) + req := httptest.NewRequest("GET", "/", nil) w := httptest.NewRecorder() ts.handler.ServeHTTP(w, req) @@ -450,8 +445,8 @@ func TestStaticFiles(t *testing.T) { path string contentTypes []string }{ - {"/ui/static/tailwind.js", []string{"text/javascript", "application/javascript"}}, - {"/ui/static/style.css", []string{"text/css"}}, + {"/static/tailwind.js", []string{"text/javascript", "application/javascript"}}, + {"/static/style.css", []string{"text/css"}}, } for _, tc := range tests { @@ -502,27 +497,11 @@ func TestCategorizeLicenseCSS(t *testing.T) { } } -func TestRootRedirectsToUI(t *testing.T) { - ts := newTestServer(t) - defer ts.close() - - req := httptest.NewRequest("GET", "/", nil) - w := httptest.NewRecorder() - ts.handler.ServeHTTP(w, req) - - if w.Code != http.StatusFound { - t.Errorf("expected status 302, got %d", w.Code) - } - if loc := w.Header().Get("Location"); loc != "/ui/" { - t.Errorf("expected redirect to /ui/, got %q", loc) - } -} - func TestDashboardWithEnrichmentStats(t *testing.T) { ts := newTestServer(t) defer ts.close() - req := httptest.NewRequest("GET", "/ui/", nil) + req := httptest.NewRequest("GET", "/", nil) w := httptest.NewRecorder() ts.handler.ServeHTTP(w, req) @@ -533,7 +512,7 @@ func TestDashboardWithEnrichmentStats(t *testing.T) { body := w.Body.String() // Dashboard should link to Tailwind JS - if !strings.Contains(body, "/ui/static/tailwind.js") { + if !strings.Contains(body, "/static/tailwind.js") { t.Error("dashboard should link to Tailwind JS") } @@ -574,7 +553,7 @@ func TestVersionShowWithHitCount(t *testing.T) { t.Fatalf("failed to upsert artifact: %v", err) } - req := httptest.NewRequest("GET", "/ui/package/npm/test/1.0.0", nil) + req := httptest.NewRequest("GET", "/package/npm/test/1.0.0", nil) w := httptest.NewRecorder() ts.handler.ServeHTTP(w, req) @@ -626,7 +605,7 @@ func TestSearchWithNullValues(t *testing.T) { t.Fatalf("failed to upsert artifact: %v", err) } - req := httptest.NewRequest("GET", "/ui/search?q=test", nil) + req := httptest.NewRequest("GET", "/search?q=test", nil) w := httptest.NewRecorder() ts.handler.ServeHTTP(w, req) @@ -718,7 +697,7 @@ func TestSearchRedirectsWhenEmpty(t *testing.T) { ts := newTestServer(t) defer ts.close() - req := httptest.NewRequest("GET", "/ui/search", nil) + req := httptest.NewRequest("GET", "/search", nil) w := httptest.NewRecorder() ts.handler.ServeHTTP(w, req) @@ -727,8 +706,8 @@ func TestSearchRedirectsWhenEmpty(t *testing.T) { } loc := w.Header().Get("Location") - if loc != "/ui/" { - t.Errorf("expected redirect to /ui/, got %q", loc) + if loc != "/" { + t.Errorf("expected redirect to /, got %q", loc) } } @@ -736,7 +715,7 @@ func TestPackageShowPage_NotFoundServer(t *testing.T) { ts := newTestServer(t) defer ts.close() - req := httptest.NewRequest("GET", "/ui/package/npm/nonexistent-srv", nil) + req := httptest.NewRequest("GET", "/package/npm/nonexistent-srv", nil) w := httptest.NewRecorder() ts.handler.ServeHTTP(w, req) @@ -749,7 +728,7 @@ func TestVersionShowPage_NotFoundServer(t *testing.T) { ts := newTestServer(t) defer ts.close() - req := httptest.NewRequest("GET", "/ui/package/npm/nonexistent-srv/1.0.0", nil) + req := httptest.NewRequest("GET", "/package/npm/nonexistent-srv/1.0.0", nil) w := httptest.NewRecorder() ts.handler.ServeHTTP(w, req) @@ -780,7 +759,7 @@ func TestPackageShowPage_WithLicense(t *testing.T) { t.Fatalf("failed to upsert version: %v", err) } - req := httptest.NewRequest("GET", "/ui/package/npm/show-test-lic", nil) + req := httptest.NewRequest("GET", "/package/npm/show-test-lic", nil) w := httptest.NewRecorder() ts.handler.ServeHTTP(w, req) @@ -822,8 +801,8 @@ func TestComposerNamespacedPackageRoutes(t *testing.T) { url string want string }{ - {"package show", "/ui/package/composer/monolog/monolog", "monolog/monolog"}, - {"version show", "/ui/package/composer/symfony/console/6.0.0", "symfony/console"}, + {"package show", "/package/composer/monolog/monolog", "monolog/monolog"}, + {"version show", "/package/composer/symfony/console/6.0.0", "symfony/console"}, } for _, tt := range tests { @@ -880,11 +859,11 @@ func TestNamespacedPackageRoutes(t *testing.T) { url string want int }{ - {"npm scoped package show", "/ui/package/npm/@babel/core", http.StatusOK}, - {"golang module show", "/ui/package/golang/github.com/stretchr/testify", http.StatusOK}, - {"oci image show", "/ui/package/oci/library/nginx", http.StatusOK}, - {"conda package show", "/ui/package/conda/conda-forge/numpy", http.StatusOK}, - {"conan package show", "/ui/package/conan/zlib/1.2.13@demo/stable", http.StatusOK}, + {"npm scoped package show", "/package/npm/@babel/core", http.StatusOK}, + {"golang module show", "/package/golang/github.com/stretchr/testify", http.StatusOK}, + {"oci image show", "/package/oci/library/nginx", http.StatusOK}, + {"conda package show", "/package/conda/conda-forge/numpy", http.StatusOK}, + {"conan package show", "/package/conan/zlib/1.2.13@demo/stable", http.StatusOK}, } for _, tt := range tests { @@ -907,7 +886,7 @@ func TestSearchPage_WithSeededResults(t *testing.T) { seedTestPackage(t, ts.db, "searchable-pkg") - req := httptest.NewRequest("GET", "/ui/search?q=searchable", nil) + req := httptest.NewRequest("GET", "/search?q=searchable", nil) w := httptest.NewRecorder() ts.handler.ServeHTTP(w, req) @@ -955,7 +934,7 @@ func TestSearchPage_PaginationMultiPage(t *testing.T) { } // First page - req := httptest.NewRequest("GET", "/ui/search?q=page-test", nil) + req := httptest.NewRequest("GET", "/search?q=page-test", nil) w := httptest.NewRecorder() ts.handler.ServeHTTP(w, req) @@ -969,7 +948,7 @@ func TestSearchPage_PaginationMultiPage(t *testing.T) { } // Second page - req = httptest.NewRequest("GET", "/ui/search?q=page-test&page=2", nil) + req = httptest.NewRequest("GET", "/search?q=page-test&page=2", nil) w = httptest.NewRecorder() ts.handler.ServeHTTP(w, req) @@ -1035,7 +1014,7 @@ func TestSearchPage_EcosystemFilterWithSeededData(t *testing.T) { } // Search with ecosystem filter for npm only - req := httptest.NewRequest("GET", "/ui/search?q=eco-filter&ecosystem=npm", nil) + req := httptest.NewRequest("GET", "/search?q=eco-filter&ecosystem=npm", nil) w := httptest.NewRecorder() ts.handler.ServeHTTP(w, req) @@ -1058,7 +1037,7 @@ func TestHandlePackagesListPage(t *testing.T) { seedTestPackage(t, ts.db, "list-test") - req := httptest.NewRequest("GET", "/ui/packages", nil) + req := httptest.NewRequest("GET", "/packages", nil) w := httptest.NewRecorder() ts.handler.ServeHTTP(w, req) diff --git a/internal/server/templates/layout/base.html b/internal/server/templates/layout/base.html index a7d03cc..ee2549f 100644 --- a/internal/server/templates/layout/base.html +++ b/internal/server/templates/layout/base.html @@ -5,7 +5,7 @@