1
0
Fork 1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-06-02 08:38:17 -04:00

Compare commits

..

4 commits

Author SHA1 Message Date
Andrew Nesbitt
0e7af4aed6
Make metadata size limit configurable (closes #149) (#150) 2026-06-02 07:59:00 +01:00
dependabot[bot]
65474c77e8
Bump goreleaser/goreleaser-action from 7.2.1 to 7.2.2 (#153)
Bumps [goreleaser/goreleaser-action](https://github.com/goreleaser/goreleaser-action) from 7.2.1 to 7.2.2.
- [Release notes](https://github.com/goreleaser/goreleaser-action/releases)
- [Commits](1a80836c5c...5daf1e915a)

---
updated-dependencies:
- dependency-name: goreleaser/goreleaser-action
  dependency-version: 7.2.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-28 18:40:31 +01:00
dependabot[bot]
946d39f193
Bump zizmorcore/zizmor-action from 0.5.5 to 0.5.6 (#152)
Bumps [zizmorcore/zizmor-action](https://github.com/zizmorcore/zizmor-action) from 0.5.5 to 0.5.6.
- [Release notes](https://github.com/zizmorcore/zizmor-action/releases)
- [Commits](a16621b09c...5f14fd08f7)

---
updated-dependencies:
- dependency-name: zizmorcore/zizmor-action
  dependency-version: 0.5.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-28 18:40:23 +01:00
dependabot[bot]
ee57878386
Bump docker/build-push-action from 7.1.0 to 7.2.0 (#151)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 7.1.0 to 7.2.0.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](bcafcacb16...f9f3042f7e)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: 7.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-28 18:40:15 +01:00
31 changed files with 630 additions and 550 deletions

View file

@ -38,7 +38,7 @@ jobs:
images: ghcr.io/${{ github.repository }} images: ghcr.io/${{ github.repository }}
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf
with: with:
context: . context: .
push: true push: true

View file

@ -27,7 +27,7 @@ jobs:
go-version-file: go.mod go-version-file: go.mod
cache: false cache: false
- uses: goreleaser/goreleaser-action@1a80836c5c9d9e5755a25cb59ec6f45a3b5f41a8 # v7.2.1 - uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2
with: with:
version: "~> v2" version: "~> v2"
args: release --clean args: release --clean

View file

@ -26,4 +26,4 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Run zizmor - name: Run zizmor
uses: zizmorcore/zizmor-action@a16621b09c6db4281f81a93cb393b05dcd7b7165 # v0.5.5 uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6

View file

@ -819,16 +819,16 @@ Response:
## Web Interface ## 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. - **Dashboard** (`/`) -- 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. - **Install guide** (`/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. - **Package browser** (`/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. - **Search** (`/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. - **Package detail** (`/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. - **Version detail** (`/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. - **Source browser** (`/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. - **Version diff** (`/package/{ecosystem}/{name}/compare/{v1}...{v2}`) -- side-by-side diff of two cached versions showing added, removed, and changed files.
## Monitoring ## Monitoring

View file

@ -470,6 +470,7 @@ func runMirror() {
proxy := handler.NewProxy(db, store, fetcher, resolver, logger) proxy := handler.NewProxy(db, store, fetcher, resolver, logger)
proxy.CacheMetadata = true // mirror always caches metadata proxy.CacheMetadata = true // mirror always caches metadata
proxy.MetadataTTL = cfg.ParseMetadataTTL() proxy.MetadataTTL = cfg.ParseMetadataTTL()
proxy.MetadataMaxSize = cfg.ParseMetadataMaxSize()
m := mirror.New(proxy, db, store, logger, *concurrency) m := mirror.New(proxy, db, store, logger, *concurrency)

View file

@ -15,7 +15,7 @@ The proxy is a caching HTTP server that sits between package manager clients and
│ │ /cargo/* -> CargoHandler /stats -> statsHandler │ │ │ │ /cargo/* -> CargoHandler /stats -> statsHandler │ │
│ │ /gem/* -> GemHandler /metrics -> prometheus │ │ │ │ /gem/* -> GemHandler /metrics -> prometheus │ │
│ │ ...17 ecosystems /api/* -> APIHandler │ │ │ │ ...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 - Creates and wires together all components
- Mounts protocol handlers at ecosystem-specific paths - Mounts protocol handlers at ecosystem-specific paths
- Middleware: request ID, real IP, logging, panic recovery, active request tracking - 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` - Templates are embedded in the binary via `//go:embed`
- Enrichment API for package metadata, vulnerability scanning, and outdated detection - 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. - 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.

View file

@ -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. 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 ## Mirror API
The `/api/mirror` endpoints are disabled by default. Enable them to allow starting mirror jobs via HTTP: The `/api/mirror` endpoints are disabled by default. Enable them to allow starting mirror jobs via HTTP:

View file

@ -15,6 +15,135 @@ const docTemplate = `{
"host": "{{.Host}}", "host": "{{.Host}}",
"basePath": "{{.BasePath}}", "basePath": "{{.BasePath}}",
"paths": { "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": { "/api/bulk": {
"post": { "post": {
"consumes": [ "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": { "/api/outdated": {
"post": { "post": {
"consumes": [ "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": { "definitions": {

View file

@ -8,6 +8,135 @@
}, },
"basePath": "/", "basePath": "/",
"paths": { "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": { "/api/bulk": {
"post": { "post": {
"consumes": [ "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": { "/api/outdated": {
"post": { "post": {
"consumes": [ "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": { "definitions": {

View file

@ -96,6 +96,11 @@ type Config struct {
// Default: "5m". Set to "0" to always revalidate. // Default: "5m". Set to "0" to always revalidate.
MetadataTTL string `json:"metadata_ttl" yaml:"metadata_ttl"` 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. // MirrorAPI enables the /api/mirror endpoints for starting mirror jobs via HTTP.
// Disabled by default to prevent unauthenticated users from triggering downloads. // Disabled by default to prevent unauthenticated users from triggering downloads.
MirrorAPI bool `json:"mirror_api" yaml:"mirror_api"` MirrorAPI bool `json:"mirror_api" yaml:"mirror_api"`
@ -424,6 +429,9 @@ func (c *Config) LoadFromEnv() {
if v := os.Getenv("PROXY_METADATA_TTL"); v != "" { if v := os.Getenv("PROXY_METADATA_TTL"); v != "" {
c.MetadataTTL = 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 != "" { if v := os.Getenv("PROXY_GRADLE_BUILD_CACHE_READ_ONLY"); v != "" {
c.Gradle.BuildCache.ReadOnly = v == "true" || v == "1" 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 { if err := c.Health.Validate(); err != nil {
return err return err
} }
@ -582,6 +594,7 @@ func (g *GradleBuildCacheConfig) Validate() error {
const ( const (
defaultMetadataTTL = 5 * time.Minute //nolint:mnd // sensible default defaultMetadataTTL = 5 * time.Minute //nolint:mnd // sensible default
defaultDirectServeTTL = 15 * time.Minute //nolint:mnd // sensible default defaultDirectServeTTL = 15 * time.Minute //nolint:mnd // sensible default
defaultMetadataMaxSize = 100 << 20
defaultGradleBuildCacheMaxUploadSize = 100 << 20 defaultGradleBuildCacheMaxUploadSize = 100 << 20
defaultGradleBuildCacheSweepInterval = 10 * time.Minute defaultGradleBuildCacheSweepInterval = 10 * time.Minute
defaultGradleMaxUploadSizeStr = "100MB" defaultGradleMaxUploadSizeStr = "100MB"
@ -601,6 +614,33 @@ func (c *Config) ParseMaxSize() int64 {
return size 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. // ParseMetadataTTL returns the metadata TTL duration.
// Returns 5 minutes if unset, 0 if explicitly disabled. // Returns 5 minutes if unset, 0 if explicitly disabled.
func (c *Config) ParseMetadataTTL() time.Duration { func (c *Config) ParseMetadataTTL() time.Duration {

View file

@ -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) { func TestValidateMetadataTTL(t *testing.T) {
cfg := Default() cfg := Default()
cfg.MetadataTTL = "invalid" cfg.MetadataTTL = "invalid"

View file

@ -161,7 +161,7 @@ func (h *CondaHandler) handleRepodata(w http.ResponseWriter, r *http.Request) {
return return
} }
body, err := ReadMetadata(resp.Body) body, err := h.proxy.ReadMetadata(resp.Body)
if err != nil { if err != nil {
http.Error(w, "failed to read response", http.StatusInternalServerError) http.Error(w, "failed to read response", http.StatusInternalServerError)
return return

View file

@ -52,23 +52,25 @@ const contentTypeJSON = "application/json"
const headerAcceptEncoding = "Accept-Encoding" const headerAcceptEncoding = "Accept-Encoding"
// maxMetadataSize is the maximum size of upstream metadata responses (100 MB). // defaultMetadataMaxSize is used when Proxy.MetadataMaxSize is unset.
// Package metadata (e.g. npm with many versions) can be large, but unbounded const defaultMetadataMaxSize = 100 << 20
// reads risk OOM if an upstream misbehaves.
const maxMetadataSize = 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") var ErrMetadataTooLarge = errors.New("metadata response exceeds size limit")
// ReadMetadata reads an upstream response body with a size limit to prevent OOM // ReadMetadata reads an upstream response body with a size limit to prevent OOM
// from unexpectedly large responses. Returns ErrMetadataTooLarge if the response // from unexpectedly large responses. Returns ErrMetadataTooLarge if the response
// is truncated by the limit. // is truncated by the limit.
func ReadMetadata(r io.Reader) ([]byte, error) { func (p *Proxy) ReadMetadata(r io.Reader) ([]byte, error) {
data, err := io.ReadAll(io.LimitReader(r, maxMetadataSize+1)) limit := p.MetadataMaxSize
if limit <= 0 {
limit = defaultMetadataMaxSize
}
data, err := io.ReadAll(io.LimitReader(r, limit+1))
if err != nil { if err != nil {
return nil, err return nil, err
} }
if int64(len(data)) > maxMetadataSize { if int64(len(data)) > limit {
return nil, ErrMetadataTooLarge return nil, ErrMetadataTooLarge
} }
return data, nil return data, nil
@ -84,6 +86,7 @@ type Proxy struct {
Cooldown *cooldown.Config Cooldown *cooldown.Config
CacheMetadata bool CacheMetadata bool
MetadataTTL time.Duration MetadataTTL time.Duration
MetadataMaxSize int64
GradleReadOnly bool GradleReadOnly bool
GradleMaxUploadSize int64 GradleMaxUploadSize int64
DirectServe bool DirectServe bool
@ -474,7 +477,7 @@ func (p *Proxy) FetchOrCacheMetadata(ctx context.Context, ecosystem, cacheKey, u
cached, readErr := p.Storage.Open(ctx, entry.StoragePath) cached, readErr := p.Storage.Open(ctx, entry.StoragePath)
if readErr == nil { if readErr == nil {
defer func() { _ = cached.Close() }() defer func() { _ = cached.Close() }()
data, readErr := ReadMetadata(cached) data, readErr := p.ReadMetadata(cached)
if readErr == nil { if readErr == nil {
ct := contentTypeJSON ct := contentTypeJSON
if entry.ContentType.Valid { if entry.ContentType.Valid {
@ -519,7 +522,7 @@ func (p *Proxy) FetchOrCacheMetadata(ctx context.Context, ecosystem, cacheKey, u
} }
defer func() { _ = cached.Close() }() defer func() { _ = cached.Close() }()
data, readErr := ReadMetadata(cached) data, readErr := p.ReadMetadata(cached)
if readErr != nil { if readErr != nil {
return nil, "", fmt.Errorf("upstream failed and cached read error: %w", err) 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 return nil, "", "", zeroTime, errStale304
} }
defer func() { _ = cached.Close() }() defer func() { _ = cached.Close() }()
data, readErr := ReadMetadata(cached) data, readErr := p.ReadMetadata(cached)
if readErr != nil { if readErr != nil {
return nil, "", "", zeroTime, errStale304 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) return nil, "", "", zeroTime, fmt.Errorf("upstream returned %d", resp.StatusCode)
} }
body, err := ReadMetadata(resp.Body) body, err := p.ReadMetadata(resp.Body)
if err != nil { if err != nil {
return nil, "", "", zeroTime, fmt.Errorf("reading response: %w", err) return nil, "", "", zeroTime, fmt.Errorf("reading response: %w", err)
} }

View file

@ -193,7 +193,7 @@ func (h *NuGetHandler) handleRegistration(w http.ResponseWriter, r *http.Request
return return
} }
body, err := ReadMetadata(resp.Body) body, err := h.proxy.ReadMetadata(resp.Body)
if err != nil { if err != nil {
http.Error(w, "failed to read response", http.StatusInternalServerError) http.Error(w, "failed to read response", http.StatusInternalServerError)
return return

View file

@ -7,9 +7,12 @@ import (
) )
func TestReadMetadata(t *testing.T) { func TestReadMetadata(t *testing.T) {
const limit = 1024
p := &Proxy{MetadataMaxSize: limit}
t.Run("small body", func(t *testing.T) { t.Run("small body", func(t *testing.T) {
data := []byte("hello world") data := []byte("hello world")
got, err := ReadMetadata(bytes.NewReader(data)) got, err := p.ReadMetadata(bytes.NewReader(data))
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
@ -19,27 +22,39 @@ func TestReadMetadata(t *testing.T) {
}) })
t.Run("exactly at limit", func(t *testing.T) { t.Run("exactly at limit", func(t *testing.T) {
data := make([]byte, maxMetadataSize) data := make([]byte, limit)
for i := range data { for i := range data {
data[i] = 'x' data[i] = 'x'
} }
got, err := ReadMetadata(bytes.NewReader(data)) got, err := p.ReadMetadata(bytes.NewReader(data))
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
if len(got) != int(maxMetadataSize) { if len(got) != limit {
t.Errorf("got length %d, want %d", len(got), maxMetadataSize) t.Errorf("got length %d, want %d", len(got), limit)
} }
}) })
t.Run("over limit returns error", func(t *testing.T) { t.Run("over limit returns error", func(t *testing.T) {
data := make([]byte, maxMetadataSize+100) data := make([]byte, limit+100)
for i := range data { for i := range data {
data[i] = 'x' data[i] = 'x'
} }
_, err := ReadMetadata(bytes.NewReader(data)) _, err := p.ReadMetadata(bytes.NewReader(data))
if !errors.Is(err, ErrMetadataTooLarge) { if !errors.Is(err, ErrMetadataTooLarge) {
t.Errorf("got error %v, want ErrMetadataTooLarge", err) 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))
}
})
} }

View file

@ -119,7 +119,7 @@ type BrowseFileInfo struct {
// @Success 200 {object} BrowseListResponse // @Success 200 {object} BrowseListResponse
// @Failure 404 {object} ErrorResponse // @Failure 404 {object} ErrorResponse
// @Failure 500 {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. // handleBrowsePath dispatches /api/browse/{ecosystem}/* to the appropriate browse handler.
// It resolves namespaced package names by consulting the database. // 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 400 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse // @Failure 404 {object} ErrorResponse
// @Failure 500 {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) { func (s *Server) browseFile(w http.ResponseWriter, r *http.Request, ecosystem, name, version, filePath string) {
if filePath == "" { if filePath == "" {
badRequest(w, "file path required") badRequest(w, "file path required")
@ -498,7 +498,7 @@ type BrowseSourceData struct {
// @Success 200 {object} map[string]any // @Success 200 {object} map[string]any
// @Failure 404 {object} ErrorResponse // @Failure 404 {object} ErrorResponse
// @Failure 500 {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) { func (s *Server) compareDiff(w http.ResponseWriter, r *http.Request, ecosystem, name, fromVersion, toVersion string) {
// Get artifacts for both versions // Get artifacts for both versions
fromPURL := purl.MakePURLString(ecosystem, name, fromVersion) fromPURL := purl.MakePURLString(ecosystem, name, fromVersion)

View file

@ -65,7 +65,7 @@ func TestHandleBrowseList(t *testing.T) {
} }
// Test listing root directory // 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() w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)
@ -83,7 +83,7 @@ func TestHandleBrowseList(t *testing.T) {
} }
// Test listing subdirectory // 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() w = httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)
@ -138,7 +138,7 @@ func TestHandleBrowseFile(t *testing.T) {
} }
// Test fetching a file // 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() w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)
@ -158,7 +158,7 @@ func TestHandleBrowseFile(t *testing.T) {
} }
// Test fetching non-existent file // 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() w = httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)
@ -314,7 +314,7 @@ func TestBrowseNonCachedArtifact(t *testing.T) {
} }
// Try to browse // 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() w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)
@ -368,7 +368,7 @@ func TestHandleBrowseSourcePage(t *testing.T) {
} }
// Test the browse source page loads // 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() w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)
@ -501,7 +501,7 @@ func TestHandleCompareDiff(t *testing.T) {
} }
// Test the compare endpoint // 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() w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)
@ -572,7 +572,7 @@ func TestHandleComparePage(t *testing.T) {
defer ts.close() defer ts.close()
// Test valid format with ... separator // 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() w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)
@ -591,7 +591,7 @@ func TestHandleComparePage(t *testing.T) {
} }
// Test invalid format (missing separator) // 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() w = httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)
@ -600,7 +600,7 @@ func TestHandleComparePage(t *testing.T) {
} }
// Test with only one dot (should fail) // 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() w = httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)

View file

@ -21,20 +21,11 @@
// - /rpm/* - RPM/Yum repository protocol // - /rpm/* - RPM/Yum repository protocol
// //
// Additional endpoints: // Additional endpoints:
// - /health - Health check endpoint // - /health - Health check endpoint
// - /stats - Cache statistics (JSON) // - /stats - Cache statistics (JSON)
// - /openapi.json - OpenAPI spec (JSON) // - /openapi.json - OpenAPI spec (JSON)
// - /metrics - Prometheus metrics // - /packages - List all cached packages (HTML)
// // - /search - Search packages (HTML)
// 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: // API endpoints for enrichment data:
// - GET /api/package/{ecosystem}/{name} - Package metadata // - GET /api/package/{ecosystem}/{name} - Package metadata
@ -169,6 +160,7 @@ func (s *Server) Start() error {
proxy.Cooldown = cd proxy.Cooldown = cd
proxy.CacheMetadata = s.cfg.CacheMetadata proxy.CacheMetadata = s.cfg.CacheMetadata
proxy.MetadataTTL = s.cfg.ParseMetadataTTL() proxy.MetadataTTL = s.cfg.ParseMetadataTTL()
proxy.MetadataMaxSize = s.cfg.ParseMetadataMaxSize()
proxy.GradleReadOnly = s.cfg.Gradle.BuildCache.ReadOnly proxy.GradleReadOnly = s.cfg.Gradle.BuildCache.ReadOnly
proxy.GradleMaxUploadSize = s.cfg.ParseGradleBuildCacheMaxUploadSize() proxy.GradleMaxUploadSize = s.cfg.ParseGradleBuildCacheMaxUploadSize()
proxy.DirectServe = s.cfg.Storage.DirectServe 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("/debian", http.StripPrefix("/debian", debianHandler.Routes()))
r.Mount("/rpm", http.StripPrefix("/rpm", rpmHandler.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("/health", s.handleHealth)
r.Get("/stats", s.handleStats) r.Get("/stats", s.handleStats)
r.Get("/openapi.json", s.handleOpenAPIJSON) r.Get("/openapi.json", s.handleOpenAPIJSON)
r.Get("/metrics", func(w http.ResponseWriter, r *http.Request) { r.Get("/metrics", func(w http.ResponseWriter, r *http.Request) {
metrics.Handler().ServeHTTP(w, r) metrics.Handler().ServeHTTP(w, r)
}) })
r.Mount("/static", http.StripPrefix("/static/", staticHandler()))
// Web UI. Mounted under /ui so a reverse proxy can apply different r.Get("/", s.handleRoot)
// access rules to it than to the package endpoints above (#123). r.Get("/install", s.handleInstall)
r.Route("/ui", func(ui chi.Router) { r.Get("/search", s.handleSearch)
ui.Mount("/static", http.StripPrefix("/ui/static/", staticHandler())) r.Get("/packages", s.handlePackagesList)
ui.Get("/", s.handleRoot) r.Get("/package/{ecosystem}/*", s.handlePackagePath)
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 // API endpoints for enrichment data
enrichSvc := enrichment.New(s.logger) enrichSvc := enrichment.New(s.logger)
@ -273,6 +255,10 @@ func (s *Server) Start() error {
r.Get("/api/search", apiHandler.HandleSearch) r.Get("/api/search", apiHandler.HandleSearch)
r.Get("/api/packages", apiHandler.HandlePackagesList) 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) // Start background context (used by mirror jobs and cleanup)
bgCtx, bgCancel := context.WithCancel(context.Background()) bgCtx, bgCancel := context.WithCancel(context.Background())
s.cancel = bgCancel s.cancel = bgCancel
@ -503,7 +489,7 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
ecosystem := r.URL.Query().Get("ecosystem") ecosystem := r.URL.Query().Get("ecosystem")
if query == "" { if query == "" {
http.Redirect(w, r, "/ui/", http.StatusSeeOther) http.Redirect(w, r, "/", http.StatusSeeOther)
return return
} }

View file

@ -101,19 +101,14 @@ func newTestServer(t *testing.T) *testServer {
r.Get("/health", s.handleHealth) r.Get("/health", s.handleHealth)
r.Get("/stats", s.handleStats) r.Get("/stats", s.handleStats)
r.Get("/openapi.json", s.handleOpenAPIJSON) r.Get("/openapi.json", s.handleOpenAPIJSON)
r.Route("/ui", func(ui chi.Router) { r.Mount("/static", http.StripPrefix("/static/", staticHandler()))
ui.Mount("/static", http.StripPrefix("/ui/static/", staticHandler())) r.Get("/search", s.handleSearch)
ui.Get("/", s.handleRoot) r.Get("/package/{ecosystem}/*", s.handlePackagePath)
ui.Get("/install", s.handleInstall) r.Get("/api/browse/{ecosystem}/*", s.handleBrowsePath)
ui.Get("/search", s.handleSearch) r.Get("/api/compare/{ecosystem}/*", s.handleComparePath)
ui.Get("/packages", s.handlePackagesList) r.Get("/", s.handleRoot)
ui.Get("/package/{ecosystem}/*", s.handlePackagePath) r.Get("/install", s.handleInstall)
ui.Get("/api/browse/{ecosystem}/*", s.handleBrowsePath) r.Get("/packages", s.handlePackagesList)
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{ return &testServer{
handler: r, handler: r,
@ -279,7 +274,7 @@ func TestDashboard(t *testing.T) {
ts := newTestServer(t) ts := newTestServer(t)
defer ts.close() defer ts.close()
req := httptest.NewRequest("GET", "/ui/", nil) req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)
@ -450,8 +445,8 @@ func TestStaticFiles(t *testing.T) {
path string path string
contentTypes []string contentTypes []string
}{ }{
{"/ui/static/tailwind.js", []string{"text/javascript", "application/javascript"}}, {"/static/tailwind.js", []string{"text/javascript", "application/javascript"}},
{"/ui/static/style.css", []string{"text/css"}}, {"/static/style.css", []string{"text/css"}},
} }
for _, tc := range tests { 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) { func TestDashboardWithEnrichmentStats(t *testing.T) {
ts := newTestServer(t) ts := newTestServer(t)
defer ts.close() defer ts.close()
req := httptest.NewRequest("GET", "/ui/", nil) req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)
@ -533,7 +512,7 @@ func TestDashboardWithEnrichmentStats(t *testing.T) {
body := w.Body.String() body := w.Body.String()
// Dashboard should link to Tailwind JS // 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") 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) 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() w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)
@ -626,7 +605,7 @@ func TestSearchWithNullValues(t *testing.T) {
t.Fatalf("failed to upsert artifact: %v", err) 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() w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)
@ -718,7 +697,7 @@ func TestSearchRedirectsWhenEmpty(t *testing.T) {
ts := newTestServer(t) ts := newTestServer(t)
defer ts.close() defer ts.close()
req := httptest.NewRequest("GET", "/ui/search", nil) req := httptest.NewRequest("GET", "/search", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)
@ -727,8 +706,8 @@ func TestSearchRedirectsWhenEmpty(t *testing.T) {
} }
loc := w.Header().Get("Location") loc := w.Header().Get("Location")
if loc != "/ui/" { if loc != "/" {
t.Errorf("expected redirect to /ui/, got %q", loc) t.Errorf("expected redirect to /, got %q", loc)
} }
} }
@ -736,7 +715,7 @@ func TestPackageShowPage_NotFoundServer(t *testing.T) {
ts := newTestServer(t) ts := newTestServer(t)
defer ts.close() 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() w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)
@ -749,7 +728,7 @@ func TestVersionShowPage_NotFoundServer(t *testing.T) {
ts := newTestServer(t) ts := newTestServer(t)
defer ts.close() 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() w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)
@ -780,7 +759,7 @@ func TestPackageShowPage_WithLicense(t *testing.T) {
t.Fatalf("failed to upsert version: %v", err) 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() w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)
@ -822,8 +801,8 @@ func TestComposerNamespacedPackageRoutes(t *testing.T) {
url string url string
want string want string
}{ }{
{"package show", "/ui/package/composer/monolog/monolog", "monolog/monolog"}, {"package show", "/package/composer/monolog/monolog", "monolog/monolog"},
{"version show", "/ui/package/composer/symfony/console/6.0.0", "symfony/console"}, {"version show", "/package/composer/symfony/console/6.0.0", "symfony/console"},
} }
for _, tt := range tests { for _, tt := range tests {
@ -880,11 +859,11 @@ func TestNamespacedPackageRoutes(t *testing.T) {
url string url string
want int want int
}{ }{
{"npm scoped package show", "/ui/package/npm/@babel/core", http.StatusOK}, {"npm scoped package show", "/package/npm/@babel/core", http.StatusOK},
{"golang module show", "/ui/package/golang/github.com/stretchr/testify", http.StatusOK}, {"golang module show", "/package/golang/github.com/stretchr/testify", http.StatusOK},
{"oci image show", "/ui/package/oci/library/nginx", http.StatusOK}, {"oci image show", "/package/oci/library/nginx", http.StatusOK},
{"conda package show", "/ui/package/conda/conda-forge/numpy", http.StatusOK}, {"conda package show", "/package/conda/conda-forge/numpy", http.StatusOK},
{"conan package show", "/ui/package/conan/zlib/1.2.13@demo/stable", http.StatusOK}, {"conan package show", "/package/conan/zlib/1.2.13@demo/stable", http.StatusOK},
} }
for _, tt := range tests { for _, tt := range tests {
@ -907,7 +886,7 @@ func TestSearchPage_WithSeededResults(t *testing.T) {
seedTestPackage(t, ts.db, "searchable-pkg") 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() w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)
@ -955,7 +934,7 @@ func TestSearchPage_PaginationMultiPage(t *testing.T) {
} }
// First page // First page
req := httptest.NewRequest("GET", "/ui/search?q=page-test", nil) req := httptest.NewRequest("GET", "/search?q=page-test", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)
@ -969,7 +948,7 @@ func TestSearchPage_PaginationMultiPage(t *testing.T) {
} }
// Second page // 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() w = httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)
@ -1035,7 +1014,7 @@ func TestSearchPage_EcosystemFilterWithSeededData(t *testing.T) {
} }
// Search with ecosystem filter for npm only // 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() w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)
@ -1058,7 +1037,7 @@ func TestHandlePackagesListPage(t *testing.T) {
seedTestPackage(t, ts.db, "list-test") seedTestPackage(t, ts.db, "list-test")
req := httptest.NewRequest("GET", "/ui/packages", nil) req := httptest.NewRequest("GET", "/packages", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)

View file

@ -5,7 +5,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{block "title" .}}git-pkgs proxy{{end}}</title> <title>{{block "title" .}}git-pkgs proxy{{end}}</title>
<script src="/ui/static/tailwind.js"></script> <script src="/static/tailwind.js"></script>
<script> <script>
tailwind.config = { darkMode: 'class' } tailwind.config = { darkMode: 'class' }
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) { if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {

View file

@ -11,7 +11,7 @@
<div> <div>
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">Resources</h3> <h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">Resources</h3>
<ul class="space-y-2 text-sm"> <ul class="space-y-2 text-sm">
<li><a href="/ui/install" class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100">Configuration Guide</a></li> <li><a href="/install" class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100">Configuration Guide</a></li>
<li><a href="/health" class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100">Health Check</a></li> <li><a href="/health" class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100">Health Check</a></li>
<li><a href="/stats" class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100">API Stats</a></li> <li><a href="/stats" class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100">API Stats</a></li>
<li><a href="/openapi.json" class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100">OpenAPI Spec</a></li> <li><a href="/openapi.json" class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100">OpenAPI Spec</a></li>

View file

@ -6,10 +6,10 @@
<svg class="w-8 h-8 text-primary-600 dark:text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-8 h-8 text-primary-600 dark:text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg> </svg>
<a href="/ui/" class="text-xl font-semibold hover:text-gray-700 dark:hover:text-gray-300">git-pkgs proxy</a> <a href="/" class="text-xl font-semibold hover:text-gray-700 dark:hover:text-gray-300">git-pkgs proxy</a>
</div> </div>
<div class="flex-1 max-w-md mx-8"> <div class="flex-1 max-w-md mx-8">
<form action="/ui/search" method="get" class="relative"> <form action="/search" method="get" class="relative">
<input <input
type="text" type="text"
name="q" name="q"
@ -22,7 +22,7 @@
</form> </form>
</div> </div>
<nav class="flex items-center gap-6"> <nav class="flex items-center gap-6">
<a href="/ui/install" class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100">Install</a> <a href="/install" class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100">Install</a>
<a href="/health" class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100">Health</a> <a href="/health" class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100">Health</a>
<a href="/stats" class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100">API</a> <a href="/stats" class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100">API</a>
<button id="theme-toggle" class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"> <button id="theme-toggle" class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">

View file

@ -3,11 +3,11 @@
{{define "content"}} {{define "content"}}
<div class="mb-6"> <div class="mb-6">
<nav class="text-sm text-gray-600 dark:text-gray-400 mb-4"> <nav class="text-sm text-gray-600 dark:text-gray-400 mb-4">
<a href="/ui/" class="hover:text-gray-900 dark:hover:text-gray-100">Home</a> <a href="/" class="hover:text-gray-900 dark:hover:text-gray-100">Home</a>
<span class="mx-2">/</span> <span class="mx-2">/</span>
<a href="/ui/package/{{.Ecosystem}}/{{.PackageName}}" class="hover:text-gray-900 dark:hover:text-gray-100">{{.PackageName}}</a> <a href="/package/{{.Ecosystem}}/{{.PackageName}}" class="hover:text-gray-900 dark:hover:text-gray-100">{{.PackageName}}</a>
<span class="mx-2">/</span> <span class="mx-2">/</span>
<a href="/ui/package/{{.Ecosystem}}/{{.PackageName}}/{{.Version}}" class="hover:text-gray-900 dark:hover:text-gray-100">{{.Version}}</a> <a href="/package/{{.Ecosystem}}/{{.PackageName}}/{{.Version}}" class="hover:text-gray-900 dark:hover:text-gray-100">{{.Version}}</a>
<span class="mx-2">/</span> <span class="mx-2">/</span>
<span>Browse Source</span> <span>Browse Source</span>
</nav> </nav>
@ -65,7 +65,7 @@ function escapeHTML(str) {
// Load file tree for a directory // Load file tree for a directory
async function loadFileTree(path = '') { async function loadFileTree(path = '') {
try { try {
const url = `/ui/api/browse/${ecosystem}/${packageName}/${version}?path=${encodeURIComponent(path)}`; const url = `/api/browse/${ecosystem}/${packageName}/${version}?path=${encodeURIComponent(path)}`;
const response = await fetch(url); const response = await fetch(url);
if (!response.ok) throw new Error('Failed to load directory'); if (!response.ok) throw new Error('Failed to load directory');
@ -134,7 +134,7 @@ function renderFileTree(files, basePath) {
// Load and display file content // Load and display file content
async function loadFile(path) { async function loadFile(path) {
try { try {
const url = `/ui/api/browse/${ecosystem}/${packageName}/${version}/file/${path}`; const url = `/api/browse/${ecosystem}/${packageName}/${version}/file/${path}`;
const response = await fetch(url); const response = await fetch(url);
if (!response.ok) throw new Error('Failed to load file'); if (!response.ok) throw new Error('Failed to load file');

View file

@ -3,9 +3,9 @@
{{define "content"}} {{define "content"}}
<div class="mb-6"> <div class="mb-6">
<nav class="text-sm text-gray-600 dark:text-gray-400 mb-4"> <nav class="text-sm text-gray-600 dark:text-gray-400 mb-4">
<a href="/ui/" class="hover:text-gray-900 dark:hover:text-gray-100">Home</a> <a href="/" class="hover:text-gray-900 dark:hover:text-gray-100">Home</a>
<span class="mx-2">/</span> <span class="mx-2">/</span>
<a href="/ui/package/{{.Ecosystem}}/{{.PackageName}}" class="hover:text-gray-900 dark:hover:text-gray-100">{{.PackageName}}</a> <a href="/package/{{.Ecosystem}}/{{.PackageName}}" class="hover:text-gray-900 dark:hover:text-gray-100">{{.PackageName}}</a>
<span class="mx-2">/</span> <span class="mx-2">/</span>
<span>Compare Versions</span> <span>Compare Versions</span>
</nav> </nav>
@ -79,7 +79,7 @@ const toVersion = '{{.ToVersion}}';
async function loadDiff() { async function loadDiff() {
try { try {
const url = `/ui/api/compare/${ecosystem}/${packageName}/${fromVersion}/${toVersion}`; const url = `/api/compare/${ecosystem}/${packageName}/${fromVersion}/${toVersion}`;
const response = await fetch(url); const response = await fetch(url);
if (!response.ok) { if (!response.ok) {

View file

@ -67,7 +67,7 @@
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{{template "ecosystem_badge" .Ecosystem}} {{template "ecosystem_badge" .Ecosystem}}
<a href="/ui/package/{{.Ecosystem}}/{{.Name}}" class="font-medium truncate hover:text-blue-600 dark:hover:text-blue-400">{{.Name}}</a> <a href="/package/{{.Ecosystem}}/{{.Name}}" class="font-medium truncate hover:text-blue-600 dark:hover:text-blue-400">{{.Name}}</a>
</div> </div>
<div class="flex items-center gap-2 mt-1"> <div class="flex items-center gap-2 mt-1">
{{if .License}}<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-{{if eq .LicenseCategory "permissive"}}green{{else if eq .LicenseCategory "copyleft"}}pink{{else}}gray{{end}}-100 text-{{if eq .LicenseCategory "permissive"}}green{{else if eq .LicenseCategory "copyleft"}}pink{{else}}gray{{end}}-700 dark:bg-{{if eq .LicenseCategory "permissive"}}green{{else if eq .LicenseCategory "copyleft"}}pink{{else}}gray{{end}}-900 dark:text-{{if eq .LicenseCategory "permissive"}}green{{else if eq .LicenseCategory "copyleft"}}pink{{else}}gray{{end}}-300">{{.License}}</span>{{end}} {{if .License}}<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-{{if eq .LicenseCategory "permissive"}}green{{else if eq .LicenseCategory "copyleft"}}pink{{else}}gray{{end}}-100 text-{{if eq .LicenseCategory "permissive"}}green{{else if eq .LicenseCategory "copyleft"}}pink{{else}}gray{{end}}-700 dark:bg-{{if eq .LicenseCategory "permissive"}}green{{else if eq .LicenseCategory "copyleft"}}pink{{else}}gray{{end}}-900 dark:text-{{if eq .LicenseCategory "permissive"}}green{{else if eq .LicenseCategory "copyleft"}}pink{{else}}gray{{end}}-300">{{.License}}</span>{{end}}
@ -81,7 +81,7 @@
</div> </div>
{{end}} {{end}}
<div class="px-6 py-3 text-center border-t border-gray-200 dark:border-gray-800"> <div class="px-6 py-3 text-center border-t border-gray-200 dark:border-gray-800">
<a href="/ui/packages?sort=hits" class="text-sm text-blue-600 dark:text-blue-400 hover:underline">See all packages →</a> <a href="/packages?sort=hits" class="text-sm text-blue-600 dark:text-blue-400 hover:underline">See all packages →</a>
</div> </div>
{{else}} {{else}}
<div class="px-6 py-12 text-center text-gray-500 dark:text-gray-400">No packages cached yet</div> <div class="px-6 py-12 text-center text-gray-500 dark:text-gray-400">No packages cached yet</div>
@ -101,7 +101,7 @@
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{{template "ecosystem_badge" .Ecosystem}} {{template "ecosystem_badge" .Ecosystem}}
<a href="/ui/package/{{.Ecosystem}}/{{.Name}}" class="font-medium truncate hover:text-blue-600 dark:hover:text-blue-400">{{.Name}}</a> <a href="/package/{{.Ecosystem}}/{{.Name}}" class="font-medium truncate hover:text-blue-600 dark:hover:text-blue-400">{{.Name}}</a>
<span class="text-gray-500 dark:text-gray-400">@{{.Version}}</span> <span class="text-gray-500 dark:text-gray-400">@{{.Version}}</span>
</div> </div>
<div class="flex items-center gap-2 mt-1"> <div class="flex items-center gap-2 mt-1">
@ -117,7 +117,7 @@
</div> </div>
{{end}} {{end}}
<div class="px-6 py-3 text-center border-t border-gray-200 dark:border-gray-800"> <div class="px-6 py-3 text-center border-t border-gray-200 dark:border-gray-800">
<a href="/ui/packages?sort=cached_at" class="text-sm text-blue-600 dark:text-blue-400 hover:underline">See all packages →</a> <a href="/packages?sort=cached_at" class="text-sm text-blue-600 dark:text-blue-400 hover:underline">See all packages →</a>
</div> </div>
{{else}} {{else}}
<div class="px-6 py-12 text-center text-gray-500 dark:text-gray-400">No packages cached yet</div> <div class="px-6 py-12 text-center text-gray-500 dark:text-gray-400">No packages cached yet</div>

View file

@ -37,7 +37,7 @@
<h2 class="text-lg font-semibold mb-2 text-blue-900 dark:text-blue-100">Need Help?</h2> <h2 class="text-lg font-semibold mb-2 text-blue-900 dark:text-blue-100">Need Help?</h2>
<p class="text-sm text-blue-800 dark:text-blue-200"> <p class="text-sm text-blue-800 dark:text-blue-200">
After configuring your package manager, install packages as usual. The proxy will automatically cache them. After configuring your package manager, install packages as usual. The proxy will automatically cache them.
Check the <a href="/ui/" class="underline hover:no-underline">dashboard</a> to see cached packages. Check the <a href="/" class="underline hover:no-underline">dashboard</a> to see cached packages.
</p> </p>
</div> </div>
{{end}} {{end}}

View file

@ -64,7 +64,7 @@
<div class="px-6 py-3 flex items-center justify-between version-row"> <div class="px-6 py-3 flex items-center justify-between version-row">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<input type="checkbox" class="version-checkbox hidden" data-version="{{.Version}}" /> <input type="checkbox" class="version-checkbox hidden" data-version="{{.Version}}" />
<a href="/ui/package/{{$.Package.Ecosystem}}/{{$.Package.Name}}/{{.Version}}" class="font-mono text-sm hover:text-blue-600 dark:hover:text-blue-400">{{.PURL}}</a> <a href="/package/{{$.Package.Ecosystem}}/{{$.Package.Name}}/{{.Version}}" class="font-mono text-sm hover:text-blue-600 dark:hover:text-blue-400">{{.PURL}}</a>
{{if .Yanked}}<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300">yanked</span>{{end}} {{if .Yanked}}<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300">yanked</span>{{end}}
</div> </div>
{{if .PublishedAt.Valid}}<span class="text-sm text-gray-500 dark:text-gray-400">{{.PublishedAt.Time.Format "2006-01-02"}}</span>{{end}} {{if .PublishedAt.Valid}}<span class="text-sm text-gray-500 dark:text-gray-400">{{.PublishedAt.Time.Format "2006-01-02"}}</span>{{end}}
@ -123,7 +123,7 @@ document.addEventListener('change', function(e) {
// Navigate to compare page // Navigate to compare page
const v1 = checked[0].dataset.version; const v1 = checked[0].dataset.version;
const v2 = checked[1].dataset.version; const v2 = checked[1].dataset.version;
window.location.href = `/ui/package/${ecosystem}/${packageName}/compare/${v1}...${v2}`; window.location.href = `/package/${ecosystem}/${packageName}/compare/${v1}...${v2}`;
} else if (checked.length > 2) { } else if (checked.length > 2) {
// Uncheck the oldest selection // Uncheck the oldest selection
checked[0].checked = false; checked[0].checked = false;

View file

@ -39,7 +39,7 @@
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{{template "ecosystem_badge" .Ecosystem}} {{template "ecosystem_badge" .Ecosystem}}
<a href="/ui/package/{{.Ecosystem}}/{{.Name}}" class="font-medium truncate hover:text-blue-600 dark:hover:text-blue-400">{{.Name}}</a> <a href="/package/{{.Ecosystem}}/{{.Name}}" class="font-medium truncate hover:text-blue-600 dark:hover:text-blue-400">{{.Name}}</a>
{{if .LatestVersion}} {{if .LatestVersion}}
<span class="text-gray-500 dark:text-gray-400">@{{.LatestVersion}}</span> <span class="text-gray-500 dark:text-gray-400">@{{.LatestVersion}}</span>
{{end}} {{end}}
@ -67,7 +67,7 @@
{{else}} {{else}}
<div class="bg-white dark:bg-gray-900 rounded-xl shadow-sm border border-gray-200 dark:border-gray-800 p-12 text-center"> <div class="bg-white dark:bg-gray-900 rounded-xl shadow-sm border border-gray-200 dark:border-gray-800 p-12 text-center">
<p class="text-gray-500 dark:text-gray-400">No cached packages found{{if .Ecosystem}} in {{.Ecosystem}}{{end}}</p> <p class="text-gray-500 dark:text-gray-400">No cached packages found{{if .Ecosystem}} in {{.Ecosystem}}{{end}}</p>
<a href="/ui/" class="mt-4 inline-block text-sm text-blue-600 dark:text-blue-400 hover:underline">Return to dashboard</a> <a href="/" class="mt-4 inline-block text-sm text-blue-600 dark:text-blue-400 hover:underline">Return to dashboard</a>
</div> </div>
{{end}} {{end}}

View file

@ -28,7 +28,7 @@
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{{template "ecosystem_badge" .Ecosystem}} {{template "ecosystem_badge" .Ecosystem}}
<a href="/ui/package/{{.Ecosystem}}/{{.Name}}" class="font-medium truncate hover:text-blue-600 dark:hover:text-blue-400">{{.Name}}</a> <a href="/package/{{.Ecosystem}}/{{.Name}}" class="font-medium truncate hover:text-blue-600 dark:hover:text-blue-400">{{.Name}}</a>
{{if .LatestVersion}} {{if .LatestVersion}}
<span class="text-gray-500 dark:text-gray-400">@{{.LatestVersion}}</span> <span class="text-gray-500 dark:text-gray-400">@{{.LatestVersion}}</span>
{{end}} {{end}}
@ -50,7 +50,7 @@
{{else}} {{else}}
<div class="bg-white dark:bg-gray-900 rounded-xl shadow-sm border border-gray-200 dark:border-gray-800 p-12 text-center"> <div class="bg-white dark:bg-gray-900 rounded-xl shadow-sm border border-gray-200 dark:border-gray-800 p-12 text-center">
<p class="text-gray-500 dark:text-gray-400">No packages found matching "{{.Query}}"</p> <p class="text-gray-500 dark:text-gray-400">No packages found matching "{{.Query}}"</p>
<a href="/ui/" class="mt-4 inline-block text-sm text-blue-600 dark:text-blue-400 hover:underline">Return to dashboard</a> <a href="/" class="mt-4 inline-block text-sm text-blue-600 dark:text-blue-400 hover:underline">Return to dashboard</a>
</div> </div>
{{end}} {{end}}

View file

@ -3,9 +3,9 @@
{{define "content"}} {{define "content"}}
<div class="mb-6"> <div class="mb-6">
<nav class="text-sm text-gray-600 dark:text-gray-400 mb-4"> <nav class="text-sm text-gray-600 dark:text-gray-400 mb-4">
<a href="/ui/" class="hover:text-gray-900 dark:hover:text-gray-100">Home</a> <a href="/" class="hover:text-gray-900 dark:hover:text-gray-100">Home</a>
<span class="mx-2">/</span> <span class="mx-2">/</span>
<a href="/ui/package/{{.Package.Ecosystem}}/{{.Package.Name}}" class="hover:text-gray-900 dark:hover:text-gray-100">{{.Package.Name}}</a> <a href="/package/{{.Package.Ecosystem}}/{{.Package.Name}}" class="hover:text-gray-900 dark:hover:text-gray-100">{{.Package.Name}}</a>
</nav> </nav>
<div class="flex items-center gap-3 mb-2"> <div class="flex items-center gap-3 mb-2">
{{template "ecosystem_badge" .Package.Ecosystem}} {{template "ecosystem_badge" .Package.Ecosystem}}
@ -22,7 +22,7 @@
{{end}} {{end}}
{{if .HasCachedArtifact}} {{if .HasCachedArtifact}}
<div class="mt-4"> <div class="mt-4">
<a href="/ui/package/{{.Package.Ecosystem}}/{{.Package.Name}}/{{.Version.Version}}/browse" <a href="/package/{{.Package.Ecosystem}}/{{.Package.Name}}/{{.Version.Version}}/browse"
class="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"> class="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"></path>

View file

@ -182,7 +182,7 @@ func TestInstallPage(t *testing.T) {
ts := newTestServer(t) ts := newTestServer(t)
defer ts.close() defer ts.close()
req := httptest.NewRequest("GET", "/ui/install", nil) req := httptest.NewRequest("GET", "/install", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)
@ -221,7 +221,7 @@ func TestPackageShowPage(t *testing.T) {
t.Fatalf("failed to upsert version: %v", err) t.Fatalf("failed to upsert version: %v", err)
} }
req := httptest.NewRequest("GET", "/ui/package/npm/test-show", nil) req := httptest.NewRequest("GET", "/package/npm/test-show", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)
@ -245,7 +245,7 @@ func TestPackageShowPage_NotFound(t *testing.T) {
ts := newTestServer(t) ts := newTestServer(t)
defer ts.close() defer ts.close()
req := httptest.NewRequest("GET", "/ui/package/npm/nonexistent", nil) req := httptest.NewRequest("GET", "/package/npm/nonexistent", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)
@ -258,7 +258,7 @@ func TestVersionShowPage_NotFound(t *testing.T) {
ts := newTestServer(t) ts := newTestServer(t)
defer ts.close() defer ts.close()
req := httptest.NewRequest("GET", "/ui/package/npm/nonexistent/1.0.0", nil) req := httptest.NewRequest("GET", "/package/npm/nonexistent/1.0.0", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)
@ -271,7 +271,7 @@ func TestSearchPage_EmptyQuery(t *testing.T) {
ts := newTestServer(t) ts := newTestServer(t)
defer ts.close() defer ts.close()
req := httptest.NewRequest("GET", "/ui/search", nil) req := httptest.NewRequest("GET", "/search", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)
@ -285,7 +285,7 @@ func TestSearchPage_WithQuery(t *testing.T) {
ts := newTestServer(t) ts := newTestServer(t)
defer ts.close() defer ts.close()
req := httptest.NewRequest("GET", "/ui/search?q=test", nil) req := httptest.NewRequest("GET", "/search?q=test", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)
@ -304,7 +304,7 @@ func TestSearchPage_Pagination(t *testing.T) {
defer ts.close() defer ts.close()
// Page 0 or negative should default to page 1 // Page 0 or negative should default to page 1
req := httptest.NewRequest("GET", "/ui/search?q=test&page=0", nil) req := httptest.NewRequest("GET", "/search?q=test&page=0", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)
@ -313,7 +313,7 @@ func TestSearchPage_Pagination(t *testing.T) {
} }
// Non-numeric page should default to page 1 // Non-numeric page should default to page 1
req = httptest.NewRequest("GET", "/ui/search?q=test&page=abc", nil) req = httptest.NewRequest("GET", "/search?q=test&page=abc", nil)
w = httptest.NewRecorder() w = httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)
@ -326,7 +326,7 @@ func TestSearchPage_EcosystemFilter(t *testing.T) {
ts := newTestServer(t) ts := newTestServer(t)
defer ts.close() defer ts.close()
req := httptest.NewRequest("GET", "/ui/search?q=test&ecosystem=npm", nil) req := httptest.NewRequest("GET", "/search?q=test&ecosystem=npm", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req) ts.handler.ServeHTTP(w, req)