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

Compare commits

..

1 commit

Author SHA1 Message Date
Andrew Nesbitt
4ea4d47b13
Mount web UI under /ui (closes #123)
The UI now lives under /ui so reverse proxies can apply different
access rules to it (e.g. require auth) while leaving the package
endpoints (/npm, /pypi, /v2, ...) open to build machines.

- GET / redirects to /ui/
- /api/browse and /api/compare move to /ui/api/browse and
  /ui/api/compare since only the browser JS calls them
- /health, /stats, /metrics, /openapi.json and /api/* stay at root
2026-05-23 18:16:28 +01:00
31 changed files with 550 additions and 630 deletions

View file

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

View file

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

View file

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

View file

@ -819,16 +819,16 @@ Response:
## Web Interface
The proxy serves a web UI at the root URL. No separate frontend build is needed -- templates and assets are embedded in the binary.
The proxy serves a web UI under `/ui`. No separate frontend build is needed -- templates and assets are embedded in the binary. `GET /` redirects to `/ui/`. The UI is mounted under its own prefix so a reverse proxy can apply different access rules to it than to the package endpoints (for example, requiring auth for `PathPrefix(/ui)` while leaving `/npm`, `/pypi` etc. open to build machines).
- **Dashboard** (`/`) -- cache stats, popular packages, recently cached artifacts, and vulnerability overview.
- **Install guide** (`/install`) -- per-ecosystem configuration instructions, so you don't have to look them up here.
- **Package browser** (`/packages`) -- browse all cached packages with filtering by ecosystem and sorting by hits, size, name, or vulnerability count.
- **Search** (`/search?q=...`) -- search cached packages by name.
- **Package detail** (`/package/{ecosystem}/{name}`) -- metadata, license, vulnerabilities, and version list for a package. You can select two versions to compare.
- **Version detail** (`/package/{ecosystem}/{name}/{version}`) -- per-version metadata, integrity hash, artifact cache status, and hit counts.
- **Source browser** (`/package/{ecosystem}/{name}/{version}/browse`) -- browse files inside cached archives with syntax highlighting for text files and image previews.
- **Version diff** (`/package/{ecosystem}/{name}/compare/{v1}...{v2}`) -- side-by-side diff of two cached versions showing added, removed, and changed files.
- **Dashboard** (`/ui/`) -- cache stats, popular packages, recently cached artifacts, and vulnerability overview.
- **Install guide** (`/ui/install`) -- per-ecosystem configuration instructions, so you don't have to look them up here.
- **Package browser** (`/ui/packages`) -- browse all cached packages with filtering by ecosystem and sorting by hits, size, name, or vulnerability count.
- **Search** (`/ui/search?q=...`) -- search cached packages by name.
- **Package detail** (`/ui/package/{ecosystem}/{name}`) -- metadata, license, vulnerabilities, and version list for a package. You can select two versions to compare.
- **Version detail** (`/ui/package/{ecosystem}/{name}/{version}`) -- per-version metadata, integrity hash, artifact cache status, and hit counts.
- **Source browser** (`/ui/package/{ecosystem}/{name}/{version}/browse`) -- browse files inside cached archives with syntax highlighting for text files and image previews.
- **Version diff** (`/ui/package/{ecosystem}/{name}/compare/{v1}...{v2}`) -- side-by-side diff of two cached versions showing added, removed, and changed files.
## Monitoring

View file

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

View file

@ -15,7 +15,7 @@ The proxy is a caching HTTP server that sits between package manager clients and
│ │ /cargo/* -> CargoHandler /stats -> statsHandler │ │
│ │ /gem/* -> GemHandler /metrics -> prometheus │ │
│ │ ...17 ecosystems /api/* -> APIHandler │ │
│ │ / -> Web UI │ │
│ │ /ui/* -> Web UI │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
@ -274,7 +274,7 @@ HTTP server setup, web UI, and API handlers.
- Creates and wires together all components
- Mounts protocol handlers at ecosystem-specific paths
- Middleware: request ID, real IP, logging, panic recovery, active request tracking
- Web UI: dashboard, package browser, source browser, version comparison
- Web UI under `/ui`: dashboard, package browser, source browser, version comparison
- Templates are embedded in the binary via `//go:embed`
- Enrichment API for package metadata, vulnerability scanning, and outdated detection
- Health, stats, and Prometheus metrics endpoints. `/health` runs an active write → size-check → read → verify → delete probe against the storage backend and returns a structured JSON response (`HealthResponse`) with `"ok"` / `"error"` status per subsystem. Probe results are cached (default 30 s, configurable via `health.storage_probe_interval`) to avoid overwhelming remote backends.

View file

@ -265,16 +265,6 @@ Set to `"0"` to always revalidate with upstream (ETag-based conditional requests
When upstream is unreachable and the cached entry is past its TTL, the proxy serves the stale cached copy with a `Warning: 110 - "Response is Stale"` header so clients can tell the data may be outdated.
### Metadata size limit
Upstream metadata responses are buffered in memory before being rewritten and served. `metadata_max_size` caps that buffer to protect against OOM from a misbehaving upstream. Some npm packages with thousands of versions (for example `renovate`) exceed the 100 MB default, so raise this if you see `metadata response exceeds size limit` in the logs.
```yaml
metadata_max_size: "100MB" # default
```
Or via environment variable: `PROXY_METADATA_MAX_SIZE=250MB`.
## Mirror API
The `/api/mirror` endpoints are disabled by default. Enable them to allow starting mirror jobs via HTTP:

View file

@ -15,135 +15,6 @@ const docTemplate = `{
"host": "{{.Host}}",
"basePath": "{{.BasePath}}",
"paths": {
"/api/browse/{ecosystem}/{name}/{version}": {
"get": {
"description": "Lists files from the first cached artifact for a package version.",
"produces": [
"application/json"
],
"tags": [
"browse"
],
"summary": "List files inside a cached artifact",
"parameters": [
{
"type": "string",
"description": "Ecosystem",
"name": "ecosystem",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Package name",
"name": "name",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Version",
"name": "version",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Directory path inside the archive",
"name": "path",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/server.BrowseListResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
}
}
}
},
"/api/browse/{ecosystem}/{name}/{version}/file/{filepath}": {
"get": {
"description": "Streams a single file from the cached artifact. The file path may contain slashes.",
"produces": [
"application/octet-stream"
],
"tags": [
"browse"
],
"summary": "Fetch a file inside a cached artifact",
"parameters": [
{
"type": "string",
"description": "Ecosystem",
"name": "ecosystem",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Package name",
"name": "name",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Version",
"name": "version",
"in": "path",
"required": true
},
{
"type": "string",
"description": "File path inside the archive",
"name": "filepath",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "file"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
}
}
}
},
"/api/bulk": {
"post": {
"consumes": [
@ -189,69 +60,6 @@ const docTemplate = `{
}
}
},
"/api/compare/{ecosystem}/{name}/{fromVersion}/{toVersion}": {
"get": {
"description": "Returns a structured diff for two cached versions.",
"produces": [
"application/json"
],
"tags": [
"browse"
],
"summary": "Compare two cached versions",
"parameters": [
{
"type": "string",
"description": "Ecosystem",
"name": "ecosystem",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Package name",
"name": "name",
"in": "path",
"required": true
},
{
"type": "string",
"description": "From version",
"name": "fromVersion",
"in": "path",
"required": true
},
{
"type": "string",
"description": "To version",
"name": "toVersion",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
}
}
}
},
"/api/outdated": {
"post": {
"consumes": [
@ -445,6 +253,198 @@ const docTemplate = `{
}
}
}
},
"/ui/api/browse/{ecosystem}/{name}/{version}": {
"get": {
"description": "Lists files from the first cached artifact for a package version.",
"produces": [
"application/json"
],
"tags": [
"browse"
],
"summary": "List files inside a cached artifact",
"parameters": [
{
"type": "string",
"description": "Ecosystem",
"name": "ecosystem",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Package name",
"name": "name",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Version",
"name": "version",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Directory path inside the archive",
"name": "path",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/server.BrowseListResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
}
}
}
},
"/ui/api/browse/{ecosystem}/{name}/{version}/file/{filepath}": {
"get": {
"description": "Streams a single file from the cached artifact. The file path may contain slashes.",
"produces": [
"application/octet-stream"
],
"tags": [
"browse"
],
"summary": "Fetch a file inside a cached artifact",
"parameters": [
{
"type": "string",
"description": "Ecosystem",
"name": "ecosystem",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Package name",
"name": "name",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Version",
"name": "version",
"in": "path",
"required": true
},
{
"type": "string",
"description": "File path inside the archive",
"name": "filepath",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "file"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
}
}
}
},
"/ui/api/compare/{ecosystem}/{name}/{fromVersion}/{toVersion}": {
"get": {
"description": "Returns a structured diff for two cached versions.",
"produces": [
"application/json"
],
"tags": [
"browse"
],
"summary": "Compare two cached versions",
"parameters": [
{
"type": "string",
"description": "Ecosystem",
"name": "ecosystem",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Package name",
"name": "name",
"in": "path",
"required": true
},
{
"type": "string",
"description": "From version",
"name": "fromVersion",
"in": "path",
"required": true
},
{
"type": "string",
"description": "To version",
"name": "toVersion",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
}
}
}
}
},
"definitions": {

View file

@ -8,135 +8,6 @@
},
"basePath": "/",
"paths": {
"/api/browse/{ecosystem}/{name}/{version}": {
"get": {
"description": "Lists files from the first cached artifact for a package version.",
"produces": [
"application/json"
],
"tags": [
"browse"
],
"summary": "List files inside a cached artifact",
"parameters": [
{
"type": "string",
"description": "Ecosystem",
"name": "ecosystem",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Package name",
"name": "name",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Version",
"name": "version",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Directory path inside the archive",
"name": "path",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/server.BrowseListResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
}
}
}
},
"/api/browse/{ecosystem}/{name}/{version}/file/{filepath}": {
"get": {
"description": "Streams a single file from the cached artifact. The file path may contain slashes.",
"produces": [
"application/octet-stream"
],
"tags": [
"browse"
],
"summary": "Fetch a file inside a cached artifact",
"parameters": [
{
"type": "string",
"description": "Ecosystem",
"name": "ecosystem",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Package name",
"name": "name",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Version",
"name": "version",
"in": "path",
"required": true
},
{
"type": "string",
"description": "File path inside the archive",
"name": "filepath",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "file"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
}
}
}
},
"/api/bulk": {
"post": {
"consumes": [
@ -182,69 +53,6 @@
}
}
},
"/api/compare/{ecosystem}/{name}/{fromVersion}/{toVersion}": {
"get": {
"description": "Returns a structured diff for two cached versions.",
"produces": [
"application/json"
],
"tags": [
"browse"
],
"summary": "Compare two cached versions",
"parameters": [
{
"type": "string",
"description": "Ecosystem",
"name": "ecosystem",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Package name",
"name": "name",
"in": "path",
"required": true
},
{
"type": "string",
"description": "From version",
"name": "fromVersion",
"in": "path",
"required": true
},
{
"type": "string",
"description": "To version",
"name": "toVersion",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
}
}
}
},
"/api/outdated": {
"post": {
"consumes": [
@ -438,6 +246,198 @@
}
}
}
},
"/ui/api/browse/{ecosystem}/{name}/{version}": {
"get": {
"description": "Lists files from the first cached artifact for a package version.",
"produces": [
"application/json"
],
"tags": [
"browse"
],
"summary": "List files inside a cached artifact",
"parameters": [
{
"type": "string",
"description": "Ecosystem",
"name": "ecosystem",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Package name",
"name": "name",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Version",
"name": "version",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Directory path inside the archive",
"name": "path",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/server.BrowseListResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
}
}
}
},
"/ui/api/browse/{ecosystem}/{name}/{version}/file/{filepath}": {
"get": {
"description": "Streams a single file from the cached artifact. The file path may contain slashes.",
"produces": [
"application/octet-stream"
],
"tags": [
"browse"
],
"summary": "Fetch a file inside a cached artifact",
"parameters": [
{
"type": "string",
"description": "Ecosystem",
"name": "ecosystem",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Package name",
"name": "name",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Version",
"name": "version",
"in": "path",
"required": true
},
{
"type": "string",
"description": "File path inside the archive",
"name": "filepath",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "file"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
}
}
}
},
"/ui/api/compare/{ecosystem}/{name}/{fromVersion}/{toVersion}": {
"get": {
"description": "Returns a structured diff for two cached versions.",
"produces": [
"application/json"
],
"tags": [
"browse"
],
"summary": "Compare two cached versions",
"parameters": [
{
"type": "string",
"description": "Ecosystem",
"name": "ecosystem",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Package name",
"name": "name",
"in": "path",
"required": true
},
{
"type": "string",
"description": "From version",
"name": "fromVersion",
"in": "path",
"required": true
},
{
"type": "string",
"description": "To version",
"name": "toVersion",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": true
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
}
}
}
}
},
"definitions": {

View file

@ -96,11 +96,6 @@ type Config struct {
// Default: "5m". Set to "0" to always revalidate.
MetadataTTL string `json:"metadata_ttl" yaml:"metadata_ttl"`
// MetadataMaxSize is the maximum size of an upstream metadata response
// the proxy will buffer (e.g. "100MB", "250MB"). Responses over this
// size return ErrMetadataTooLarge. Default: "100MB".
MetadataMaxSize string `json:"metadata_max_size" yaml:"metadata_max_size"`
// MirrorAPI enables the /api/mirror endpoints for starting mirror jobs via HTTP.
// Disabled by default to prevent unauthenticated users from triggering downloads.
MirrorAPI bool `json:"mirror_api" yaml:"mirror_api"`
@ -429,9 +424,6 @@ func (c *Config) LoadFromEnv() {
if v := os.Getenv("PROXY_METADATA_TTL"); v != "" {
c.MetadataTTL = v
}
if v := os.Getenv("PROXY_METADATA_MAX_SIZE"); v != "" {
c.MetadataMaxSize = v
}
if v := os.Getenv("PROXY_GRADLE_BUILD_CACHE_READ_ONLY"); v != "" {
c.Gradle.BuildCache.ReadOnly = v == "true" || v == "1"
}
@ -521,10 +513,6 @@ func (c *Config) Validate() error {
}
}
if err := validateMetadataMaxSize(c.MetadataMaxSize); err != nil {
return err
}
if err := c.Health.Validate(); err != nil {
return err
}
@ -594,7 +582,6 @@ func (g *GradleBuildCacheConfig) Validate() error {
const (
defaultMetadataTTL = 5 * time.Minute //nolint:mnd // sensible default
defaultDirectServeTTL = 15 * time.Minute //nolint:mnd // sensible default
defaultMetadataMaxSize = 100 << 20
defaultGradleBuildCacheMaxUploadSize = 100 << 20
defaultGradleBuildCacheSweepInterval = 10 * time.Minute
defaultGradleMaxUploadSizeStr = "100MB"
@ -614,33 +601,6 @@ func (c *Config) ParseMaxSize() int64 {
return size
}
func validateMetadataMaxSize(s string) error {
if s == "" {
return nil
}
size, err := ParseSize(s)
if err != nil {
return fmt.Errorf("invalid metadata_max_size: %w", err)
}
if size <= 0 {
return fmt.Errorf("invalid metadata_max_size %q: must be positive", s)
}
return nil
}
// ParseMetadataMaxSize returns the maximum metadata response size in bytes.
// Returns 100MB if unset or invalid.
func (c *Config) ParseMetadataMaxSize() int64 {
if c.MetadataMaxSize == "" {
return defaultMetadataMaxSize
}
size, err := ParseSize(c.MetadataMaxSize)
if err != nil || size <= 0 {
return defaultMetadataMaxSize
}
return size
}
// ParseMetadataTTL returns the metadata TTL duration.
// Returns 5 minutes if unset, 0 if explicitly disabled.
func (c *Config) ParseMetadataTTL() time.Duration {

View file

@ -428,52 +428,6 @@ func TestParseMetadataTTL(t *testing.T) {
}
}
func TestParseMetadataMaxSize(t *testing.T) {
tests := []struct {
name string
size string
want int64
}{
{"unset uses default", "", defaultMetadataMaxSize},
{"explicit value", "250MB", 250 << 20},
{"bytes", "1024", 1024},
{"invalid uses default", "lots", defaultMetadataMaxSize},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := Default()
cfg.MetadataMaxSize = tt.size
got := cfg.ParseMetadataMaxSize()
if got != tt.want {
t.Errorf("ParseMetadataMaxSize() = %d, want %d", got, tt.want)
}
})
}
}
func TestValidateMetadataMaxSize(t *testing.T) {
cfg := Default()
cfg.MetadataMaxSize = "not-a-size"
if err := cfg.Validate(); err == nil {
t.Error("expected validation error for invalid metadata_max_size")
}
cfg.MetadataMaxSize = "0"
if err := cfg.Validate(); err == nil {
t.Error("expected validation error for zero metadata_max_size")
}
cfg.MetadataMaxSize = "250MB"
if err := cfg.Validate(); err != nil {
t.Errorf("unexpected error for valid metadata_max_size: %v", err)
}
cfg.MetadataMaxSize = ""
if err := cfg.Validate(); err != nil {
t.Errorf("unexpected error for unset metadata_max_size: %v", err)
}
}
func TestValidateMetadataTTL(t *testing.T) {
cfg := Default()
cfg.MetadataTTL = "invalid"

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -65,7 +65,7 @@ func TestHandleBrowseList(t *testing.T) {
}
// Test listing root directory
req := httptest.NewRequest("GET", "/api/browse/npm/test-browse/1.0.0", nil)
req := httptest.NewRequest("GET", "/ui/api/browse/npm/test-browse/1.0.0", nil)
w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req)
@ -83,7 +83,7 @@ func TestHandleBrowseList(t *testing.T) {
}
// Test listing subdirectory
req = httptest.NewRequest("GET", "/api/browse/npm/test-browse/1.0.0?path=lib", nil)
req = httptest.NewRequest("GET", "/ui/api/browse/npm/test-browse/1.0.0?path=lib", nil)
w = httptest.NewRecorder()
ts.handler.ServeHTTP(w, req)
@ -138,7 +138,7 @@ func TestHandleBrowseFile(t *testing.T) {
}
// Test fetching a file
req := httptest.NewRequest("GET", "/api/browse/npm/test-browse/1.0.0/file/README.md", nil)
req := httptest.NewRequest("GET", "/ui/api/browse/npm/test-browse/1.0.0/file/README.md", nil)
w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req)
@ -158,7 +158,7 @@ func TestHandleBrowseFile(t *testing.T) {
}
// Test fetching non-existent file
req = httptest.NewRequest("GET", "/api/browse/npm/test-browse/1.0.0/file/nonexistent.txt", nil)
req = httptest.NewRequest("GET", "/ui/api/browse/npm/test-browse/1.0.0/file/nonexistent.txt", nil)
w = httptest.NewRecorder()
ts.handler.ServeHTTP(w, req)
@ -314,7 +314,7 @@ func TestBrowseNonCachedArtifact(t *testing.T) {
}
// Try to browse
req := httptest.NewRequest("GET", "/api/browse/npm/not-cached/1.0.0", nil)
req := httptest.NewRequest("GET", "/ui/api/browse/npm/not-cached/1.0.0", nil)
w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req)
@ -368,7 +368,7 @@ func TestHandleBrowseSourcePage(t *testing.T) {
}
// Test the browse source page loads
req := httptest.NewRequest("GET", "/package/npm/test-browse/1.0.0/browse", nil)
req := httptest.NewRequest("GET", "/ui/package/npm/test-browse/1.0.0/browse", nil)
w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req)
@ -501,7 +501,7 @@ func TestHandleCompareDiff(t *testing.T) {
}
// Test the compare endpoint
req := httptest.NewRequest("GET", "/api/compare/npm/test-compare/1.0.0/2.0.0", nil)
req := httptest.NewRequest("GET", "/ui/api/compare/npm/test-compare/1.0.0/2.0.0", nil)
w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req)
@ -572,7 +572,7 @@ func TestHandleComparePage(t *testing.T) {
defer ts.close()
// Test valid format with ... separator
req := httptest.NewRequest("GET", "/package/npm/test/compare/1.0.0...2.0.0", nil)
req := httptest.NewRequest("GET", "/ui/package/npm/test/compare/1.0.0...2.0.0", nil)
w := httptest.NewRecorder()
ts.handler.ServeHTTP(w, req)
@ -591,7 +591,7 @@ func TestHandleComparePage(t *testing.T) {
}
// Test invalid format (missing separator)
req = httptest.NewRequest("GET", "/package/npm/test/compare/invalid", nil)
req = httptest.NewRequest("GET", "/ui/package/npm/test/compare/invalid", nil)
w = httptest.NewRecorder()
ts.handler.ServeHTTP(w, req)
@ -600,7 +600,7 @@ func TestHandleComparePage(t *testing.T) {
}
// Test with only one dot (should fail)
req = httptest.NewRequest("GET", "/package/npm/test/compare/1.0.0.2.0.0", nil)
req = httptest.NewRequest("GET", "/ui/package/npm/test/compare/1.0.0.2.0.0", nil)
w = httptest.NewRecorder()
ts.handler.ServeHTTP(w, req)

View file

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

View file

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

View file

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

View file

@ -11,7 +11,7 @@
<div>
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">Resources</h3>
<ul class="space-y-2 text-sm">
<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="/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="/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="/openapi.json" class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100">OpenAPI Spec</a></li>

View file

@ -6,10 +6,10 @@
<svg class="w-8 h-8 text-primary-600 dark:text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
<a href="/" class="text-xl font-semibold hover:text-gray-700 dark:hover:text-gray-300">git-pkgs proxy</a>
<a href="/ui/" class="text-xl font-semibold hover:text-gray-700 dark:hover:text-gray-300">git-pkgs proxy</a>
</div>
<div class="flex-1 max-w-md mx-8">
<form action="/search" method="get" class="relative">
<form action="/ui/search" method="get" class="relative">
<input
type="text"
name="q"
@ -22,7 +22,7 @@
</form>
</div>
<nav class="flex items-center gap-6">
<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="/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="/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>
<button id="theme-toggle" class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">

View file

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

View file

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

View file

@ -67,7 +67,7 @@
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
{{template "ecosystem_badge" .Ecosystem}}
<a href="/package/{{.Ecosystem}}/{{.Name}}" class="font-medium truncate hover:text-blue-600 dark:hover:text-blue-400">{{.Name}}</a>
<a href="/ui/package/{{.Ecosystem}}/{{.Name}}" class="font-medium truncate hover:text-blue-600 dark:hover:text-blue-400">{{.Name}}</a>
</div>
<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}}
@ -81,7 +81,7 @@
</div>
{{end}}
<div class="px-6 py-3 text-center border-t border-gray-200 dark:border-gray-800">
<a href="/packages?sort=hits" class="text-sm text-blue-600 dark:text-blue-400 hover:underline">See all packages →</a>
<a href="/ui/packages?sort=hits" class="text-sm text-blue-600 dark:text-blue-400 hover:underline">See all packages →</a>
</div>
{{else}}
<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="flex items-center gap-2">
{{template "ecosystem_badge" .Ecosystem}}
<a href="/package/{{.Ecosystem}}/{{.Name}}" class="font-medium truncate hover:text-blue-600 dark:hover:text-blue-400">{{.Name}}</a>
<a href="/ui/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>
</div>
<div class="flex items-center gap-2 mt-1">
@ -117,7 +117,7 @@
</div>
{{end}}
<div class="px-6 py-3 text-center border-t border-gray-200 dark:border-gray-800">
<a href="/packages?sort=cached_at" class="text-sm text-blue-600 dark:text-blue-400 hover:underline">See all packages →</a>
<a href="/ui/packages?sort=cached_at" class="text-sm text-blue-600 dark:text-blue-400 hover:underline">See all packages →</a>
</div>
{{else}}
<div class="px-6 py-12 text-center text-gray-500 dark:text-gray-400">No packages cached yet</div>

View file

@ -37,7 +37,7 @@
<h2 class="text-lg font-semibold mb-2 text-blue-900 dark:text-blue-100">Need Help?</h2>
<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.
Check the <a href="/" class="underline hover:no-underline">dashboard</a> to see cached packages.
Check the <a href="/ui/" class="underline hover:no-underline">dashboard</a> to see cached packages.
</p>
</div>
{{end}}

View file

@ -64,7 +64,7 @@
<div class="px-6 py-3 flex items-center justify-between version-row">
<div class="flex items-center gap-3">
<input type="checkbox" class="version-checkbox hidden" data-version="{{.Version}}" />
<a href="/package/{{$.Package.Ecosystem}}/{{$.Package.Name}}/{{.Version}}" class="font-mono text-sm hover:text-blue-600 dark:hover:text-blue-400">{{.PURL}}</a>
<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>
{{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>
{{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
const v1 = checked[0].dataset.version;
const v2 = checked[1].dataset.version;
window.location.href = `/package/${ecosystem}/${packageName}/compare/${v1}...${v2}`;
window.location.href = `/ui/package/${ecosystem}/${packageName}/compare/${v1}...${v2}`;
} else if (checked.length > 2) {
// Uncheck the oldest selection
checked[0].checked = false;

View file

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

View file

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

View file

@ -3,9 +3,9 @@
{{define "content"}}
<div class="mb-6">
<nav class="text-sm text-gray-600 dark:text-gray-400 mb-4">
<a href="/" class="hover:text-gray-900 dark:hover:text-gray-100">Home</a>
<a href="/ui/" class="hover:text-gray-900 dark:hover:text-gray-100">Home</a>
<span class="mx-2">/</span>
<a href="/package/{{.Package.Ecosystem}}/{{.Package.Name}}" class="hover:text-gray-900 dark:hover:text-gray-100">{{.Package.Name}}</a>
<a href="/ui/package/{{.Package.Ecosystem}}/{{.Package.Name}}" class="hover:text-gray-900 dark:hover:text-gray-100">{{.Package.Name}}</a>
</nav>
<div class="flex items-center gap-3 mb-2">
{{template "ecosystem_badge" .Package.Ecosystem}}
@ -22,7 +22,7 @@
{{end}}
{{if .HasCachedArtifact}}
<div class="mt-4">
<a href="/package/{{.Package.Ecosystem}}/{{.Package.Name}}/{{.Version.Version}}/browse"
<a href="/ui/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">
<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>

View file

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