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. +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** (`/`) -- 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. +- **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,135 +15,6 @@ 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": [ @@ -189,69 +60,6 @@ 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": [ @@ -445,6 +253,198 @@ 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 c2b4dfc..898f580 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.json @@ -8,135 +8,6 @@ }, "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": [ @@ -182,69 +53,6 @@ } } }, - "/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": [ @@ -438,6 +246,198 @@ } } } + }, + "/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/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}} - +