From 4ea4d47b1330dc10836de6c7ca8a7531c184a49b Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Sat, 23 May 2026 18:16:28 +0100 Subject: [PATCH] Mount web UI under /ui (closes #123) The UI now lives under /ui so reverse proxies can apply different access rules to it (e.g. require auth) while leaving the package endpoints (/npm, /pypi, /v2, ...) open to build machines. - GET / redirects to /ui/ - /api/browse and /api/compare move to /ui/api/browse and /ui/api/compare since only the browser JS calls them - /health, /stats, /metrics, /openapi.json and /api/* stay at root --- README.md | 20 +- docs/architecture.md | 4 +- docs/swagger/docs.go | 380 +++++++++--------- docs/swagger/swagger.json | 380 +++++++++--------- internal/server/browse.go | 6 +- internal/server/browse_test.go | 20 +- internal/server/server.go | 47 ++- internal/server/server_test.go | 87 ++-- internal/server/templates/layout/base.html | 2 +- internal/server/templates/layout/footer.html | 2 +- internal/server/templates/layout/header.html | 6 +- .../server/templates/pages/browse_source.html | 10 +- .../templates/pages/compare_versions.html | 6 +- .../server/templates/pages/dashboard.html | 8 +- internal/server/templates/pages/install.html | 2 +- .../server/templates/pages/package_show.html | 4 +- .../server/templates/pages/packages_list.html | 4 +- internal/server/templates/pages/search.html | 4 +- .../server/templates/pages/version_show.html | 6 +- internal/server/templates_test.go | 18 +- 20 files changed, 526 insertions(+), 490 deletions(-) diff --git a/README.md b/README.md index abf3e11..2e0755e 100644 --- a/README.md +++ b/README.md @@ -819,16 +819,16 @@ Response: ## Web Interface -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** (`/`) -- 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. +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). + +- **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. ## Monitoring diff --git a/docs/architecture.md b/docs/architecture.md index 85e5aaf..f04d548 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 │ │ -│ │ / -> Web UI │ │ +│ │ /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: dashboard, package browser, source browser, version comparison +- Web UI under `/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/swagger/docs.go b/docs/swagger/docs.go index 23ff54a..c4b21f3 100644 --- a/docs/swagger/docs.go +++ b/docs/swagger/docs.go @@ -15,54 +15,38 @@ 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.", + "/api/bulk": { + "post": { + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "browse" + "api" ], - "summary": "List files inside a cached artifact", + "summary": "Bulk package lookup by PURL", "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" + "description": "PURLs", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/server.BulkRequest" + } } ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/server.BrowseListResponse" + "$ref": "#/definitions/server.BulkResponse" } }, - "404": { - "description": "Not Found", + "400": { + "description": "Bad Request", "schema": { "$ref": "#/definitions/server.ErrorResponse" } @@ -76,51 +60,34 @@ const docTemplate = `{ } } }, - "/api/browse/{ecosystem}/{name}/{version}/file/{filepath}": { - "get": { - "description": "Streams a single file from the cached artifact. The file path may contain slashes.", + "/api/outdated": { + "post": { + "consumes": [ + "application/json" + ], "produces": [ - "application/octet-stream" + "application/json" ], "tags": [ - "browse" + "api" ], - "summary": "Fetch a file inside a cached artifact", + "summary": "Check outdated packages", "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 + "description": "Packages to check", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/server.OutdatedRequest" + } } ], "responses": { "200": { "description": "OK", "schema": { - "type": "file" + "$ref": "#/definitions/server.OutdatedResponse" } }, "400": { @@ -129,12 +96,6 @@ const docTemplate = `{ "$ref": "#/definitions/server.ErrorResponse" } }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/server.ErrorResponse" - } - }, "500": { "description": "Internal Server Error", "schema": { @@ -144,34 +105,42 @@ const docTemplate = `{ } } }, - "/api/bulk": { - "post": { - "consumes": [ - "application/json" - ], + "/api/packages": { + "get": { "produces": [ "application/json" ], "tags": [ "api" ], - "summary": "Bulk package lookup by PURL", + "summary": "List cached packages", "parameters": [ { - "description": "PURLs", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/server.BulkRequest" - } + "type": "string", + "description": "Ecosystem", + "name": "ecosystem", + "in": "query" + }, + { + "enum": [ + "hits", + "name", + "size", + "cached_at", + "ecosystem", + "vulns" + ], + "type": "string", + "description": "Sort", + "name": "sort", + "in": "query" } ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/server.BulkResponse" + "$ref": "#/definitions/server.PackagesListResponse" } }, "400": { @@ -189,56 +158,39 @@ const docTemplate = `{ } } }, - "/api/compare/{ecosystem}/{name}/{fromVersion}/{toVersion}": { + "/api/search": { "get": { - "description": "Returns a structured diff for two cached versions.", "produces": [ "application/json" ], "tags": [ - "browse" + "api" ], - "summary": "Compare two cached versions", + "summary": "Search cached packages", "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", + "description": "Query", + "name": "q", + "in": "query", "required": true }, { "type": "string", - "description": "To version", - "name": "toVersion", - "in": "path", - "required": true + "description": "Ecosystem", + "name": "ecosystem", + "in": "query" } ], "responses": { "200": { "description": "OK", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/server.SearchResponse" } }, - "404": { - "description": "Not Found", + "400": { + "description": "Bad Request", "schema": { "$ref": "#/definitions/server.ErrorResponse" } @@ -252,40 +204,45 @@ const docTemplate = `{ } } }, - "/api/outdated": { - "post": { - "consumes": [ - "application/json" - ], + "/health": { + "get": { "produces": [ "application/json" ], "tags": [ - "api" + "meta" ], - "summary": "Check outdated packages", - "parameters": [ - { - "description": "Packages to check", - "name": "request", - "in": "body", - "required": true, + "summary": "Health check", + "responses": { + "200": { + "description": "OK", "schema": { - "$ref": "#/definitions/server.OutdatedRequest" + "$ref": "#/definitions/server.HealthResponse" + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "$ref": "#/definitions/server.HealthResponse" } } + } + } + }, + "/stats": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "meta" ], + "summary": "Cache statistics", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/server.OutdatedResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/server.ErrorResponse" + "$ref": "#/definitions/server.StatsResponse" } }, "500": { @@ -297,34 +254,42 @@ const docTemplate = `{ } } }, - "/api/packages": { + "/ui/api/browse/{ecosystem}/{name}/{version}": { "get": { + "description": "Lists files from the first cached artifact for a package version.", "produces": [ "application/json" ], "tags": [ - "api" + "browse" ], - "summary": "List cached packages", + "summary": "List files inside a cached artifact", "parameters": [ { "type": "string", "description": "Ecosystem", "name": "ecosystem", - "in": "query" + "in": "path", + "required": true }, { - "enum": [ - "hits", - "name", - "size", - "cached_at", - "ecosystem", - "vulns" - ], "type": "string", - "description": "Sort", - "name": "sort", + "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" } ], @@ -332,11 +297,11 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/server.PackagesListResponse" + "$ref": "#/definitions/server.BrowseListResponse" } }, - "400": { - "description": "Bad Request", + "404": { + "description": "Not Found", "schema": { "$ref": "#/definitions/server.ErrorResponse" } @@ -350,35 +315,51 @@ const docTemplate = `{ } } }, - "/api/search": { + "/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/json" + "application/octet-stream" ], "tags": [ - "api" + "browse" ], - "summary": "Search cached packages", + "summary": "Fetch a file inside a cached artifact", "parameters": [ { "type": "string", - "description": "Query", - "name": "q", - "in": "query", + "description": "Ecosystem", + "name": "ecosystem", + "in": "path", "required": true }, { "type": "string", - "description": "Ecosystem", - "name": "ecosystem", - "in": "query" + "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": { - "$ref": "#/definitions/server.SearchResponse" + "type": "file" } }, "400": { @@ -387,6 +368,12 @@ const docTemplate = `{ "$ref": "#/definitions/server.ErrorResponse" } }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + }, "500": { "description": "Internal Server Error", "schema": { @@ -396,45 +383,58 @@ const docTemplate = `{ } } }, - "/health": { + "/ui/api/compare/{ecosystem}/{name}/{fromVersion}/{toVersion}": { "get": { + "description": "Returns a structured diff for two cached versions.", "produces": [ "application/json" ], "tags": [ - "meta" + "browse" ], - "summary": "Health check", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/server.HealthResponse" - } + "summary": "Compare two cached versions", + "parameters": [ + { + "type": "string", + "description": "Ecosystem", + "name": "ecosystem", + "in": "path", + "required": true }, - "503": { - "description": "Service Unavailable", - "schema": { - "$ref": "#/definitions/server.HealthResponse" - } + { + "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 } - } - } - }, - "/stats": { - "get": { - "produces": [ - "application/json" ], - "tags": [ - "meta" - ], - "summary": "Cache statistics", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/server.StatsResponse" + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" } }, "500": { diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json index c2b4dfc..898f580 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.json @@ -8,54 +8,38 @@ }, "basePath": "/", "paths": { - "/api/browse/{ecosystem}/{name}/{version}": { - "get": { - "description": "Lists files from the first cached artifact for a package version.", + "/api/bulk": { + "post": { + "consumes": [ + "application/json" + ], "produces": [ "application/json" ], "tags": [ - "browse" + "api" ], - "summary": "List files inside a cached artifact", + "summary": "Bulk package lookup by PURL", "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" + "description": "PURLs", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/server.BulkRequest" + } } ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/server.BrowseListResponse" + "$ref": "#/definitions/server.BulkResponse" } }, - "404": { - "description": "Not Found", + "400": { + "description": "Bad Request", "schema": { "$ref": "#/definitions/server.ErrorResponse" } @@ -69,51 +53,34 @@ } } }, - "/api/browse/{ecosystem}/{name}/{version}/file/{filepath}": { - "get": { - "description": "Streams a single file from the cached artifact. The file path may contain slashes.", + "/api/outdated": { + "post": { + "consumes": [ + "application/json" + ], "produces": [ - "application/octet-stream" + "application/json" ], "tags": [ - "browse" + "api" ], - "summary": "Fetch a file inside a cached artifact", + "summary": "Check outdated packages", "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 + "description": "Packages to check", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/server.OutdatedRequest" + } } ], "responses": { "200": { "description": "OK", "schema": { - "type": "file" + "$ref": "#/definitions/server.OutdatedResponse" } }, "400": { @@ -122,12 +89,6 @@ "$ref": "#/definitions/server.ErrorResponse" } }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/server.ErrorResponse" - } - }, "500": { "description": "Internal Server Error", "schema": { @@ -137,34 +98,42 @@ } } }, - "/api/bulk": { - "post": { - "consumes": [ - "application/json" - ], + "/api/packages": { + "get": { "produces": [ "application/json" ], "tags": [ "api" ], - "summary": "Bulk package lookup by PURL", + "summary": "List cached packages", "parameters": [ { - "description": "PURLs", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/server.BulkRequest" - } + "type": "string", + "description": "Ecosystem", + "name": "ecosystem", + "in": "query" + }, + { + "enum": [ + "hits", + "name", + "size", + "cached_at", + "ecosystem", + "vulns" + ], + "type": "string", + "description": "Sort", + "name": "sort", + "in": "query" } ], "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/server.BulkResponse" + "$ref": "#/definitions/server.PackagesListResponse" } }, "400": { @@ -182,56 +151,39 @@ } } }, - "/api/compare/{ecosystem}/{name}/{fromVersion}/{toVersion}": { + "/api/search": { "get": { - "description": "Returns a structured diff for two cached versions.", "produces": [ "application/json" ], "tags": [ - "browse" + "api" ], - "summary": "Compare two cached versions", + "summary": "Search cached packages", "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", + "description": "Query", + "name": "q", + "in": "query", "required": true }, { "type": "string", - "description": "To version", - "name": "toVersion", - "in": "path", - "required": true + "description": "Ecosystem", + "name": "ecosystem", + "in": "query" } ], "responses": { "200": { "description": "OK", "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/definitions/server.SearchResponse" } }, - "404": { - "description": "Not Found", + "400": { + "description": "Bad Request", "schema": { "$ref": "#/definitions/server.ErrorResponse" } @@ -245,40 +197,45 @@ } } }, - "/api/outdated": { - "post": { - "consumes": [ - "application/json" - ], + "/health": { + "get": { "produces": [ "application/json" ], "tags": [ - "api" + "meta" ], - "summary": "Check outdated packages", - "parameters": [ - { - "description": "Packages to check", - "name": "request", - "in": "body", - "required": true, + "summary": "Health check", + "responses": { + "200": { + "description": "OK", "schema": { - "$ref": "#/definitions/server.OutdatedRequest" + "$ref": "#/definitions/server.HealthResponse" + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "$ref": "#/definitions/server.HealthResponse" } } + } + } + }, + "/stats": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "meta" ], + "summary": "Cache statistics", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/server.OutdatedResponse" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/server.ErrorResponse" + "$ref": "#/definitions/server.StatsResponse" } }, "500": { @@ -290,34 +247,42 @@ } } }, - "/api/packages": { + "/ui/api/browse/{ecosystem}/{name}/{version}": { "get": { + "description": "Lists files from the first cached artifact for a package version.", "produces": [ "application/json" ], "tags": [ - "api" + "browse" ], - "summary": "List cached packages", + "summary": "List files inside a cached artifact", "parameters": [ { "type": "string", "description": "Ecosystem", "name": "ecosystem", - "in": "query" + "in": "path", + "required": true }, { - "enum": [ - "hits", - "name", - "size", - "cached_at", - "ecosystem", - "vulns" - ], "type": "string", - "description": "Sort", - "name": "sort", + "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" } ], @@ -325,11 +290,11 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/server.PackagesListResponse" + "$ref": "#/definitions/server.BrowseListResponse" } }, - "400": { - "description": "Bad Request", + "404": { + "description": "Not Found", "schema": { "$ref": "#/definitions/server.ErrorResponse" } @@ -343,35 +308,51 @@ } } }, - "/api/search": { + "/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/json" + "application/octet-stream" ], "tags": [ - "api" + "browse" ], - "summary": "Search cached packages", + "summary": "Fetch a file inside a cached artifact", "parameters": [ { "type": "string", - "description": "Query", - "name": "q", - "in": "query", + "description": "Ecosystem", + "name": "ecosystem", + "in": "path", "required": true }, { "type": "string", - "description": "Ecosystem", - "name": "ecosystem", - "in": "query" + "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": { - "$ref": "#/definitions/server.SearchResponse" + "type": "file" } }, "400": { @@ -380,6 +361,12 @@ "$ref": "#/definitions/server.ErrorResponse" } }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + }, "500": { "description": "Internal Server Error", "schema": { @@ -389,45 +376,58 @@ } } }, - "/health": { + "/ui/api/compare/{ecosystem}/{name}/{fromVersion}/{toVersion}": { "get": { + "description": "Returns a structured diff for two cached versions.", "produces": [ "application/json" ], "tags": [ - "meta" + "browse" ], - "summary": "Health check", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/server.HealthResponse" - } + "summary": "Compare two cached versions", + "parameters": [ + { + "type": "string", + "description": "Ecosystem", + "name": "ecosystem", + "in": "path", + "required": true }, - "503": { - "description": "Service Unavailable", - "schema": { - "$ref": "#/definitions/server.HealthResponse" - } + { + "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 } - } - } - }, - "/stats": { - "get": { - "produces": [ - "application/json" ], - "tags": [ - "meta" - ], - "summary": "Cache statistics", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/server.StatsResponse" + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" } }, "500": { diff --git a/internal/server/browse.go b/internal/server/browse.go index be2b04a..ba25afc 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 /api/browse/{ecosystem}/{name}/{version} [get] +// @Router /ui/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 /api/browse/{ecosystem}/{name}/{version}/file/{filepath} [get] +// @Router /ui/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 /api/compare/{ecosystem}/{name}/{fromVersion}/{toVersion} [get] +// @Router /ui/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 28f08da..f1fb993 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", "/api/browse/npm/test-browse/1.0.0", nil) + req := httptest.NewRequest("GET", "/ui/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", "/api/browse/npm/test-browse/1.0.0?path=lib", nil) + req = httptest.NewRequest("GET", "/ui/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", "/api/browse/npm/test-browse/1.0.0/file/README.md", nil) + req := httptest.NewRequest("GET", "/ui/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", "/api/browse/npm/test-browse/1.0.0/file/nonexistent.txt", nil) + req = httptest.NewRequest("GET", "/ui/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", "/api/browse/npm/not-cached/1.0.0", nil) + req := httptest.NewRequest("GET", "/ui/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", "/package/npm/test-browse/1.0.0/browse", nil) + req := httptest.NewRequest("GET", "/ui/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", "/api/compare/npm/test-compare/1.0.0/2.0.0", nil) + req := httptest.NewRequest("GET", "/ui/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", "/package/npm/test/compare/1.0.0...2.0.0", nil) + req := httptest.NewRequest("GET", "/ui/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", "/package/npm/test/compare/invalid", nil) + req = httptest.NewRequest("GET", "/ui/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", "/package/npm/test/compare/1.0.0.2.0.0", nil) + req = httptest.NewRequest("GET", "/ui/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 251386e..70473bf 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -21,11 +21,20 @@ // - /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) -// - /packages - List all cached packages (HTML) -// - /search - Search packages (HTML) +// - /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) // // API endpoints for enrichment data: // - GET /api/package/{ecosystem}/{name} - Package metadata @@ -229,19 +238,29 @@ func (s *Server) Start() error { r.Mount("/debian", http.StripPrefix("/debian", debianHandler.Routes())) r.Mount("/rpm", http.StripPrefix("/rpm", rpmHandler.Routes())) - // Health, stats, and static endpoints + // Health, stats, and metrics 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) }) - 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) + + // 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) + }) // API endpoints for enrichment data enrichSvc := enrichment.New(s.logger) @@ -254,10 +273,6 @@ 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 @@ -488,7 +503,7 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { ecosystem := r.URL.Query().Get("ecosystem") if query == "" { - http.Redirect(w, r, "/", http.StatusSeeOther) + http.Redirect(w, r, "/ui/", http.StatusSeeOther) return } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index e2dc1c2..17f2352 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -101,14 +101,19 @@ func newTestServer(t *testing.T) *testServer { r.Get("/health", s.handleHealth) r.Get("/stats", s.handleStats) r.Get("/openapi.json", s.handleOpenAPIJSON) - 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) + 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) + }) return &testServer{ handler: r, @@ -274,7 +279,7 @@ func TestDashboard(t *testing.T) { ts := newTestServer(t) defer ts.close() - req := httptest.NewRequest("GET", "/", nil) + req := httptest.NewRequest("GET", "/ui/", nil) w := httptest.NewRecorder() ts.handler.ServeHTTP(w, req) @@ -445,8 +450,8 @@ func TestStaticFiles(t *testing.T) { path string contentTypes []string }{ - {"/static/tailwind.js", []string{"text/javascript", "application/javascript"}}, - {"/static/style.css", []string{"text/css"}}, + {"/ui/static/tailwind.js", []string{"text/javascript", "application/javascript"}}, + {"/ui/static/style.css", []string{"text/css"}}, } for _, tc := range tests { @@ -497,7 +502,7 @@ func TestCategorizeLicenseCSS(t *testing.T) { } } -func TestDashboardWithEnrichmentStats(t *testing.T) { +func TestRootRedirectsToUI(t *testing.T) { ts := newTestServer(t) defer ts.close() @@ -505,6 +510,22 @@ func TestDashboardWithEnrichmentStats(t *testing.T) { 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) + w := httptest.NewRecorder() + ts.handler.ServeHTTP(w, req) + if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d", w.Code) } @@ -512,7 +533,7 @@ func TestDashboardWithEnrichmentStats(t *testing.T) { body := w.Body.String() // Dashboard should link to Tailwind JS - if !strings.Contains(body, "/static/tailwind.js") { + if !strings.Contains(body, "/ui/static/tailwind.js") { t.Error("dashboard should link to Tailwind JS") } @@ -553,7 +574,7 @@ func TestVersionShowWithHitCount(t *testing.T) { t.Fatalf("failed to upsert artifact: %v", err) } - req := httptest.NewRequest("GET", "/package/npm/test/1.0.0", nil) + req := httptest.NewRequest("GET", "/ui/package/npm/test/1.0.0", nil) w := httptest.NewRecorder() ts.handler.ServeHTTP(w, req) @@ -605,7 +626,7 @@ func TestSearchWithNullValues(t *testing.T) { t.Fatalf("failed to upsert artifact: %v", err) } - req := httptest.NewRequest("GET", "/search?q=test", nil) + req := httptest.NewRequest("GET", "/ui/search?q=test", nil) w := httptest.NewRecorder() ts.handler.ServeHTTP(w, req) @@ -697,7 +718,7 @@ func TestSearchRedirectsWhenEmpty(t *testing.T) { ts := newTestServer(t) defer ts.close() - req := httptest.NewRequest("GET", "/search", nil) + req := httptest.NewRequest("GET", "/ui/search", nil) w := httptest.NewRecorder() ts.handler.ServeHTTP(w, req) @@ -706,8 +727,8 @@ func TestSearchRedirectsWhenEmpty(t *testing.T) { } loc := w.Header().Get("Location") - if loc != "/" { - t.Errorf("expected redirect to /, got %q", loc) + if loc != "/ui/" { + t.Errorf("expected redirect to /ui/, got %q", loc) } } @@ -715,7 +736,7 @@ func TestPackageShowPage_NotFoundServer(t *testing.T) { ts := newTestServer(t) defer ts.close() - req := httptest.NewRequest("GET", "/package/npm/nonexistent-srv", nil) + req := httptest.NewRequest("GET", "/ui/package/npm/nonexistent-srv", nil) w := httptest.NewRecorder() ts.handler.ServeHTTP(w, req) @@ -728,7 +749,7 @@ func TestVersionShowPage_NotFoundServer(t *testing.T) { ts := newTestServer(t) defer ts.close() - req := httptest.NewRequest("GET", "/package/npm/nonexistent-srv/1.0.0", nil) + req := httptest.NewRequest("GET", "/ui/package/npm/nonexistent-srv/1.0.0", nil) w := httptest.NewRecorder() ts.handler.ServeHTTP(w, req) @@ -759,7 +780,7 @@ func TestPackageShowPage_WithLicense(t *testing.T) { t.Fatalf("failed to upsert version: %v", err) } - req := httptest.NewRequest("GET", "/package/npm/show-test-lic", nil) + req := httptest.NewRequest("GET", "/ui/package/npm/show-test-lic", nil) w := httptest.NewRecorder() ts.handler.ServeHTTP(w, req) @@ -801,8 +822,8 @@ func TestComposerNamespacedPackageRoutes(t *testing.T) { url string want string }{ - {"package show", "/package/composer/monolog/monolog", "monolog/monolog"}, - {"version show", "/package/composer/symfony/console/6.0.0", "symfony/console"}, + {"package show", "/ui/package/composer/monolog/monolog", "monolog/monolog"}, + {"version show", "/ui/package/composer/symfony/console/6.0.0", "symfony/console"}, } for _, tt := range tests { @@ -859,11 +880,11 @@ func TestNamespacedPackageRoutes(t *testing.T) { url string want int }{ - {"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}, + {"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}, } for _, tt := range tests { @@ -886,7 +907,7 @@ func TestSearchPage_WithSeededResults(t *testing.T) { seedTestPackage(t, ts.db, "searchable-pkg") - req := httptest.NewRequest("GET", "/search?q=searchable", nil) + req := httptest.NewRequest("GET", "/ui/search?q=searchable", nil) w := httptest.NewRecorder() ts.handler.ServeHTTP(w, req) @@ -934,7 +955,7 @@ func TestSearchPage_PaginationMultiPage(t *testing.T) { } // First page - req := httptest.NewRequest("GET", "/search?q=page-test", nil) + req := httptest.NewRequest("GET", "/ui/search?q=page-test", nil) w := httptest.NewRecorder() ts.handler.ServeHTTP(w, req) @@ -948,7 +969,7 @@ func TestSearchPage_PaginationMultiPage(t *testing.T) { } // Second page - req = httptest.NewRequest("GET", "/search?q=page-test&page=2", nil) + req = httptest.NewRequest("GET", "/ui/search?q=page-test&page=2", nil) w = httptest.NewRecorder() ts.handler.ServeHTTP(w, req) @@ -1014,7 +1035,7 @@ func TestSearchPage_EcosystemFilterWithSeededData(t *testing.T) { } // Search with ecosystem filter for npm only - req := httptest.NewRequest("GET", "/search?q=eco-filter&ecosystem=npm", nil) + req := httptest.NewRequest("GET", "/ui/search?q=eco-filter&ecosystem=npm", nil) w := httptest.NewRecorder() ts.handler.ServeHTTP(w, req) @@ -1037,7 +1058,7 @@ func TestHandlePackagesListPage(t *testing.T) { seedTestPackage(t, ts.db, "list-test") - req := httptest.NewRequest("GET", "/packages", nil) + req := httptest.NewRequest("GET", "/ui/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 ee2549f..a7d03cc 100644 --- a/internal/server/templates/layout/base.html +++ b/internal/server/templates/layout/base.html @@ -5,7 +5,7 @@ {{block "title" .}}git-pkgs proxy{{end}} - +