mirror of
https://github.com/git-pkgs/proxy.git
synced 2026-06-02 08:38:17 -04:00
Compare commits
4 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e7af4aed6 |
||
|
|
65474c77e8 |
||
|
|
946d39f193 |
||
|
|
ee57878386 |
31 changed files with 630 additions and 550 deletions
2
.github/workflows/publish.yml
vendored
2
.github/workflows/publish.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
2
.github/workflows/zizmor.yml
vendored
2
.github/workflows/zizmor.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
18
README.md
18
README.md
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)) {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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}}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue