2026-01-20 21:52:44 +00:00
|
|
|
// Package server provides the HTTP server and router for the proxy.
|
|
|
|
|
//
|
|
|
|
|
// The server mounts protocol handlers at their respective paths:
|
|
|
|
|
// - /npm/* - npm registry protocol
|
|
|
|
|
// - /cargo/* - Cargo registry protocol (sparse index)
|
|
|
|
|
// - /gem/* - RubyGems registry protocol
|
|
|
|
|
// - /go/* - Go module proxy protocol
|
|
|
|
|
// - /hex/* - Hex.pm registry protocol
|
|
|
|
|
// - /pub/* - pub.dev registry protocol
|
|
|
|
|
// - /pypi/* - PyPI registry protocol
|
|
|
|
|
// - /maven/* - Maven repository protocol
|
2026-05-04 12:15:16 +02:00
|
|
|
// - /gradle/* - Gradle HttpBuildCache protocol
|
2026-01-20 21:52:44 +00:00
|
|
|
// - /nuget/* - NuGet V3 API protocol
|
|
|
|
|
// - /composer/* - Composer/Packagist protocol
|
|
|
|
|
// - /conan/* - Conan C/C++ protocol
|
|
|
|
|
// - /conda/* - Conda/Anaconda protocol
|
|
|
|
|
// - /cran/* - CRAN (R) protocol
|
2026-05-13 06:46:35 +01:00
|
|
|
// - /julia/* - Julia Pkg server protocol
|
2026-01-29 19:35:15 +00:00
|
|
|
// - /v2/* - OCI/Docker container registry protocol
|
|
|
|
|
// - /debian/* - Debian/APT repository protocol
|
|
|
|
|
// - /rpm/* - RPM/Yum repository protocol
|
2026-01-20 21:52:44 +00:00
|
|
|
//
|
|
|
|
|
// Additional endpoints:
|
2026-05-23 18:16:28 +01:00
|
|
|
// - /health - Health check endpoint
|
|
|
|
|
// - /stats - Cache statistics (JSON)
|
2026-03-11 17:18:29 +00:00
|
|
|
// - /openapi.json - OpenAPI spec (JSON)
|
2026-05-23 18:16:28 +01:00
|
|
|
// - /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)
|
2026-01-29 19:35:15 +00:00
|
|
|
//
|
|
|
|
|
// API endpoints for enrichment data:
|
|
|
|
|
// - GET /api/package/{ecosystem}/{name} - Package metadata
|
|
|
|
|
// - GET /api/package/{ecosystem}/{name}/{version} - Version metadata with vulns
|
|
|
|
|
// - GET /api/vulns/{ecosystem}/{name} - Package vulnerabilities
|
|
|
|
|
// - GET /api/vulns/{ecosystem}/{name}/{version} - Version vulnerabilities
|
|
|
|
|
// - POST /api/outdated - Check outdated packages
|
|
|
|
|
// - POST /api/bulk - Bulk package lookup
|
2026-02-03 22:40:23 +00:00
|
|
|
// - GET /api/packages - List cached packages (JSON)
|
2026-01-20 21:52:44 +00:00
|
|
|
package server
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
2026-02-03 22:40:23 +00:00
|
|
|
"database/sql"
|
2026-01-20 21:52:44 +00:00
|
|
|
"encoding/json"
|
Add storage backend probe to /health (closes #73) (#119)
* config: add Health.StorageProbeInterval
* metrics: add proxy_health_probe_failures_total counter
* server: add storageProbe with happy-path test
* server: add storageProbe failure-mode tests
* server: add healthCache with TTL, single-flight, transition logging
* server: wire storage probe into /health
* server: update TestHealthEndpoint for JSON; wire healthCache into newTestServer
Also fix Windows file-locking issue in storageProbe: close the reader
explicitly before Delete so the file handle is released prior to os.Remove.
* server: clean up stale comment in storageProbe
* docs: document storage health probe and new metric
* docs: regenerate Swagger for /health JSON response
* server: simplify rc.Close error handling in storageProbe
* server: defer probe cleanup so size/open/read/verify failures don't leak objects
Previously, storageProbe only called Delete on the success path. Any
failure between Store and the final Delete (size mismatch, Open error,
mid-stream read failure, content mismatch) left the probe object orphaned
in the storage backend. With caching disabled and Kubernetes-rate probing,
the leak could accumulate noticeably on backends like S3.
Use a named return + defer to attempt Delete after every successful Store.
The earlier-step failure remains the primary error; Delete failure only
surfaces as step="delete" when nothing else went wrong. Add a table-driven
test that asserts cleanup runs for each non-delete failure path.
Reported by Copilot on #119.
* config: validate health.storage_probe_interval in Config.Validate
The new duration field was only validated at use time in newHealthCache.
The existing codebase already validates other duration fields
(MetadataTTL, DirectServeTTL, Gradle.MaxAge, Gradle.SweepInterval) in
Config.Validate() so misconfiguration fails fast at startup with a
config-key-specific error.
Match that pattern. The parse-at-use code in newHealthCache stays as
a safety net, mirroring the MetadataTTL precedent.
Reported by Copilot on #119.
* docs: lowercase "counter" in metrics table for consistency
Other rows in the table use lowercase type names (counter/gauge/histogram).
Match that style.
Reported by Copilot on #119.
* docs: include size-check step in /health probe description
The probe is write → size-check → read → verify → delete; the
architecture note was missing the size-check step.
Reported by Copilot on #119.
* server: address andrew's review on #119
- Drop unused callerCtx parameter from healthCache.Check (Check is now
parameter-less; the comment-only "accepted for symmetry" justification
wasn't carrying its weight).
- Emit "storage": {"status": "skipped"} on DB short-circuit instead of
omitting the key, so monitors expecting a fixed key set keep working.
- Reject negative storage_probe_interval at config validation time
(previously parsed and silently behaved like "0").
- Extract HealthConfig.Validate to keep Config.Validate under the
gocognit threshold and match the existing GradleBuildCacheConfig pattern.
- README Health Check section: note that /health is intended as a
readiness probe rather than a liveness probe (Check holds a mutex
for up to the 10s probe timeout).
- cmd/proxy/main.go godoc: column-align the new env var with the
surrounding Gradle entries.
Reported by andrew on #119.
2026-05-22 14:14:01 +03:00
|
|
|
"errors"
|
2026-01-20 21:52:44 +00:00
|
|
|
"fmt"
|
|
|
|
|
"log/slog"
|
|
|
|
|
"net/http"
|
2026-02-03 22:40:23 +00:00
|
|
|
"strconv"
|
Fix Composer minified metadata expansion and namespaced package routing (#63)
* Fix Composer minified metadata expansion and namespaced package routing
Packagist serves metadata in a minified format where only the first version
entry has all fields and subsequent entries inherit from the previous one.
The proxy was passing this through without expanding it, which meant cooldown
filtering could break the inheritance chain (losing fields like `name`) and
`~dev` sentinel markers were silently dropped.
The proxy now expands the minified format before filtering and rewriting,
ensuring every version entry is self-contained.
Web UI and API routes used single-segment chi URL params for package names,
which broke for Composer's `vendor/name` format. `/package/composer/monolog/monolog`
would match the version show route instead of the package show route.
All `/package/` and related API routes now use wildcard paths with a
`resolvePackageName` helper that tries increasingly longer path prefixes as
package names via DB lookup, correctly handling namespaced packages across
all endpoints (show, version, browse, compare, vulns).
Fixes #61, fixes #62
* Add namespaced package routing tests for all affected ecosystems
Verifies the wildcard routing handles slashes in package names for
npm (@babel/core), Go modules (github.com/stretchr/testify),
OCI images (library/nginx), Conda (conda-forge/numpy), and
Conan (zlib/1.2.13@demo/stable).
* Regenerate swagger docs after route refactor
The swagger annotations for the old per-endpoint handlers were removed
during the wildcard routing refactor. Regenerate to match current state.
2026-04-06 13:07:02 +01:00
|
|
|
"strings"
|
2026-01-20 21:52:44 +00:00
|
|
|
"time"
|
|
|
|
|
|
2026-03-11 17:18:29 +00:00
|
|
|
swaggerdoc "github.com/git-pkgs/proxy/docs/swagger"
|
2026-01-20 21:52:44 +00:00
|
|
|
"github.com/git-pkgs/proxy/internal/config"
|
2026-05-13 06:45:33 +01:00
|
|
|
"github.com/git-pkgs/cooldown"
|
2026-01-20 21:52:44 +00:00
|
|
|
"github.com/git-pkgs/proxy/internal/database"
|
2026-01-29 19:35:15 +00:00
|
|
|
"github.com/git-pkgs/proxy/internal/enrichment"
|
2026-01-20 21:52:44 +00:00
|
|
|
"github.com/git-pkgs/proxy/internal/handler"
|
2026-02-03 22:40:23 +00:00
|
|
|
"github.com/git-pkgs/proxy/internal/metrics"
|
2026-03-19 21:06:02 +00:00
|
|
|
"github.com/git-pkgs/proxy/internal/mirror"
|
2026-01-20 21:52:44 +00:00
|
|
|
"github.com/git-pkgs/proxy/internal/storage"
|
2026-03-04 09:20:16 +00:00
|
|
|
"github.com/git-pkgs/purl"
|
2026-02-20 08:47:14 +00:00
|
|
|
"github.com/git-pkgs/registries/fetch"
|
2026-01-29 19:35:15 +00:00
|
|
|
"github.com/git-pkgs/spdx"
|
2026-03-04 09:20:16 +00:00
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
|
"github.com/go-chi/chi/v5/middleware"
|
2026-01-20 21:52:44 +00:00
|
|
|
)
|
|
|
|
|
|
2026-03-18 10:59:29 +00:00
|
|
|
const (
|
|
|
|
|
serverReadTimeout = 30 * time.Second
|
|
|
|
|
serverWriteTimeout = 5 * time.Minute
|
|
|
|
|
serverIdleTimeout = 60 * time.Second
|
|
|
|
|
dashboardTopN = 10
|
|
|
|
|
hoursPerDay = 24
|
|
|
|
|
)
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
// Server is the main proxy server.
|
|
|
|
|
type Server struct {
|
2026-02-03 22:40:23 +00:00
|
|
|
cfg *config.Config
|
|
|
|
|
db *database.DB
|
|
|
|
|
storage storage.Storage
|
|
|
|
|
logger *slog.Logger
|
|
|
|
|
http *http.Server
|
|
|
|
|
templates *Templates
|
Add storage backend probe to /health (closes #73) (#119)
* config: add Health.StorageProbeInterval
* metrics: add proxy_health_probe_failures_total counter
* server: add storageProbe with happy-path test
* server: add storageProbe failure-mode tests
* server: add healthCache with TTL, single-flight, transition logging
* server: wire storage probe into /health
* server: update TestHealthEndpoint for JSON; wire healthCache into newTestServer
Also fix Windows file-locking issue in storageProbe: close the reader
explicitly before Delete so the file handle is released prior to os.Remove.
* server: clean up stale comment in storageProbe
* docs: document storage health probe and new metric
* docs: regenerate Swagger for /health JSON response
* server: simplify rc.Close error handling in storageProbe
* server: defer probe cleanup so size/open/read/verify failures don't leak objects
Previously, storageProbe only called Delete on the success path. Any
failure between Store and the final Delete (size mismatch, Open error,
mid-stream read failure, content mismatch) left the probe object orphaned
in the storage backend. With caching disabled and Kubernetes-rate probing,
the leak could accumulate noticeably on backends like S3.
Use a named return + defer to attempt Delete after every successful Store.
The earlier-step failure remains the primary error; Delete failure only
surfaces as step="delete" when nothing else went wrong. Add a table-driven
test that asserts cleanup runs for each non-delete failure path.
Reported by Copilot on #119.
* config: validate health.storage_probe_interval in Config.Validate
The new duration field was only validated at use time in newHealthCache.
The existing codebase already validates other duration fields
(MetadataTTL, DirectServeTTL, Gradle.MaxAge, Gradle.SweepInterval) in
Config.Validate() so misconfiguration fails fast at startup with a
config-key-specific error.
Match that pattern. The parse-at-use code in newHealthCache stays as
a safety net, mirroring the MetadataTTL precedent.
Reported by Copilot on #119.
* docs: lowercase "counter" in metrics table for consistency
Other rows in the table use lowercase type names (counter/gauge/histogram).
Match that style.
Reported by Copilot on #119.
* docs: include size-check step in /health probe description
The probe is write → size-check → read → verify → delete; the
architecture note was missing the size-check step.
Reported by Copilot on #119.
* server: address andrew's review on #119
- Drop unused callerCtx parameter from healthCache.Check (Check is now
parameter-less; the comment-only "accepted for symmetry" justification
wasn't carrying its weight).
- Emit "storage": {"status": "skipped"} on DB short-circuit instead of
omitting the key, so monitors expecting a fixed key set keep working.
- Reject negative storage_probe_interval at config validation time
(previously parsed and silently behaved like "0").
- Extract HealthConfig.Validate to keep Config.Validate under the
gocognit threshold and match the existing GradleBuildCacheConfig pattern.
- README Health Check section: note that /health is intended as a
readiness probe rather than a liveness probe (Check holds a mutex
for up to the 10s probe timeout).
- cmd/proxy/main.go godoc: column-align the new env var with the
surrounding Gradle entries.
Reported by andrew on #119.
2026-05-22 14:14:01 +03:00
|
|
|
cancel context.CancelFunc
|
|
|
|
|
healthCache *healthCache
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// New creates a new Server with the given configuration.
|
|
|
|
|
func New(cfg *config.Config, logger *slog.Logger) (*Server, error) {
|
|
|
|
|
// Initialize database
|
2026-01-29 16:06:56 +00:00
|
|
|
var db *database.DB
|
|
|
|
|
var err error
|
|
|
|
|
|
|
|
|
|
switch cfg.Database.Driver {
|
|
|
|
|
case "postgres":
|
|
|
|
|
db, err = database.OpenPostgresOrCreate(cfg.Database.URL)
|
|
|
|
|
default:
|
|
|
|
|
db, err = database.OpenOrCreate(cfg.Database.Path)
|
|
|
|
|
}
|
2026-01-20 21:52:44 +00:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("opening database: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-03 22:40:23 +00:00
|
|
|
// Run schema migration to add missing columns
|
|
|
|
|
if err := db.MigrateSchema(); err != nil {
|
|
|
|
|
_ = db.Close()
|
|
|
|
|
return nil, fmt.Errorf("migrating database schema: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
// Initialize storage
|
2026-01-29 16:13:16 +00:00
|
|
|
storageURL := cfg.Storage.URL
|
|
|
|
|
if storageURL == "" {
|
|
|
|
|
// Fall back to file:// with Path
|
2026-03-18 10:59:29 +00:00
|
|
|
storageURL = "file://" + cfg.Storage.Path //nolint:staticcheck // backwards compat
|
2026-01-29 16:13:16 +00:00
|
|
|
}
|
|
|
|
|
store, err := storage.OpenBucket(context.Background(), storageURL)
|
2026-01-20 21:52:44 +00:00
|
|
|
if err != nil {
|
|
|
|
|
_ = db.Close()
|
|
|
|
|
return nil, fmt.Errorf("initializing storage: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 14:06:51 +01:00
|
|
|
// Verify storage is accessible (catches bad S3 credentials/endpoints early).
|
|
|
|
|
// Exists returns (false, nil) for a missing key, so only real connectivity
|
|
|
|
|
// or permission errors surface here.
|
|
|
|
|
if _, err := store.Exists(context.Background(), ".health-check"); err != nil {
|
|
|
|
|
_ = store.Close()
|
|
|
|
|
_ = db.Close()
|
|
|
|
|
return nil, fmt.Errorf("verifying storage connectivity: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
Add storage backend probe to /health (closes #73) (#119)
* config: add Health.StorageProbeInterval
* metrics: add proxy_health_probe_failures_total counter
* server: add storageProbe with happy-path test
* server: add storageProbe failure-mode tests
* server: add healthCache with TTL, single-flight, transition logging
* server: wire storage probe into /health
* server: update TestHealthEndpoint for JSON; wire healthCache into newTestServer
Also fix Windows file-locking issue in storageProbe: close the reader
explicitly before Delete so the file handle is released prior to os.Remove.
* server: clean up stale comment in storageProbe
* docs: document storage health probe and new metric
* docs: regenerate Swagger for /health JSON response
* server: simplify rc.Close error handling in storageProbe
* server: defer probe cleanup so size/open/read/verify failures don't leak objects
Previously, storageProbe only called Delete on the success path. Any
failure between Store and the final Delete (size mismatch, Open error,
mid-stream read failure, content mismatch) left the probe object orphaned
in the storage backend. With caching disabled and Kubernetes-rate probing,
the leak could accumulate noticeably on backends like S3.
Use a named return + defer to attempt Delete after every successful Store.
The earlier-step failure remains the primary error; Delete failure only
surfaces as step="delete" when nothing else went wrong. Add a table-driven
test that asserts cleanup runs for each non-delete failure path.
Reported by Copilot on #119.
* config: validate health.storage_probe_interval in Config.Validate
The new duration field was only validated at use time in newHealthCache.
The existing codebase already validates other duration fields
(MetadataTTL, DirectServeTTL, Gradle.MaxAge, Gradle.SweepInterval) in
Config.Validate() so misconfiguration fails fast at startup with a
config-key-specific error.
Match that pattern. The parse-at-use code in newHealthCache stays as
a safety net, mirroring the MetadataTTL precedent.
Reported by Copilot on #119.
* docs: lowercase "counter" in metrics table for consistency
Other rows in the table use lowercase type names (counter/gauge/histogram).
Match that style.
Reported by Copilot on #119.
* docs: include size-check step in /health probe description
The probe is write → size-check → read → verify → delete; the
architecture note was missing the size-check step.
Reported by Copilot on #119.
* server: address andrew's review on #119
- Drop unused callerCtx parameter from healthCache.Check (Check is now
parameter-less; the comment-only "accepted for symmetry" justification
wasn't carrying its weight).
- Emit "storage": {"status": "skipped"} on DB short-circuit instead of
omitting the key, so monitors expecting a fixed key set keep working.
- Reject negative storage_probe_interval at config validation time
(previously parsed and silently behaved like "0").
- Extract HealthConfig.Validate to keep Config.Validate under the
gocognit threshold and match the existing GradleBuildCacheConfig pattern.
- README Health Check section: note that /health is intended as a
readiness probe rather than a liveness probe (Check holds a mutex
for up to the 10s probe timeout).
- cmd/proxy/main.go godoc: column-align the new env var with the
surrounding Gradle entries.
Reported by andrew on #119.
2026-05-22 14:14:01 +03:00
|
|
|
hc, err := newHealthCache(store, cfg.Health.StorageProbeInterval, logger)
|
|
|
|
|
if err != nil {
|
|
|
|
|
_ = store.Close()
|
|
|
|
|
_ = db.Close()
|
|
|
|
|
return nil, fmt.Errorf("initializing health cache: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
return &Server{
|
Add storage backend probe to /health (closes #73) (#119)
* config: add Health.StorageProbeInterval
* metrics: add proxy_health_probe_failures_total counter
* server: add storageProbe with happy-path test
* server: add storageProbe failure-mode tests
* server: add healthCache with TTL, single-flight, transition logging
* server: wire storage probe into /health
* server: update TestHealthEndpoint for JSON; wire healthCache into newTestServer
Also fix Windows file-locking issue in storageProbe: close the reader
explicitly before Delete so the file handle is released prior to os.Remove.
* server: clean up stale comment in storageProbe
* docs: document storage health probe and new metric
* docs: regenerate Swagger for /health JSON response
* server: simplify rc.Close error handling in storageProbe
* server: defer probe cleanup so size/open/read/verify failures don't leak objects
Previously, storageProbe only called Delete on the success path. Any
failure between Store and the final Delete (size mismatch, Open error,
mid-stream read failure, content mismatch) left the probe object orphaned
in the storage backend. With caching disabled and Kubernetes-rate probing,
the leak could accumulate noticeably on backends like S3.
Use a named return + defer to attempt Delete after every successful Store.
The earlier-step failure remains the primary error; Delete failure only
surfaces as step="delete" when nothing else went wrong. Add a table-driven
test that asserts cleanup runs for each non-delete failure path.
Reported by Copilot on #119.
* config: validate health.storage_probe_interval in Config.Validate
The new duration field was only validated at use time in newHealthCache.
The existing codebase already validates other duration fields
(MetadataTTL, DirectServeTTL, Gradle.MaxAge, Gradle.SweepInterval) in
Config.Validate() so misconfiguration fails fast at startup with a
config-key-specific error.
Match that pattern. The parse-at-use code in newHealthCache stays as
a safety net, mirroring the MetadataTTL precedent.
Reported by Copilot on #119.
* docs: lowercase "counter" in metrics table for consistency
Other rows in the table use lowercase type names (counter/gauge/histogram).
Match that style.
Reported by Copilot on #119.
* docs: include size-check step in /health probe description
The probe is write → size-check → read → verify → delete; the
architecture note was missing the size-check step.
Reported by Copilot on #119.
* server: address andrew's review on #119
- Drop unused callerCtx parameter from healthCache.Check (Check is now
parameter-less; the comment-only "accepted for symmetry" justification
wasn't carrying its weight).
- Emit "storage": {"status": "skipped"} on DB short-circuit instead of
omitting the key, so monitors expecting a fixed key set keep working.
- Reject negative storage_probe_interval at config validation time
(previously parsed and silently behaved like "0").
- Extract HealthConfig.Validate to keep Config.Validate under the
gocognit threshold and match the existing GradleBuildCacheConfig pattern.
- README Health Check section: note that /health is intended as a
readiness probe rather than a liveness probe (Check holds a mutex
for up to the 10s probe timeout).
- cmd/proxy/main.go godoc: column-align the new env var with the
surrounding Gradle entries.
Reported by andrew on #119.
2026-05-22 14:14:01 +03:00
|
|
|
cfg: cfg,
|
|
|
|
|
db: db,
|
|
|
|
|
storage: store,
|
|
|
|
|
logger: logger,
|
|
|
|
|
templates: &Templates{},
|
|
|
|
|
healthCache: hc,
|
2026-01-20 21:52:44 +00:00
|
|
|
}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Start starts the HTTP server.
|
|
|
|
|
func (s *Server) Start() error {
|
2026-02-03 22:40:23 +00:00
|
|
|
// Create shared components with circuit breaker
|
2026-02-20 08:47:14 +00:00
|
|
|
baseFetcher := fetch.NewFetcher(fetch.WithAuthFunc(s.authForURL))
|
|
|
|
|
fetcher := fetch.NewCircuitBreakerFetcher(baseFetcher)
|
|
|
|
|
resolver := fetch.NewResolver()
|
2026-03-04 19:00:31 +00:00
|
|
|
cd := &cooldown.Config{
|
|
|
|
|
Default: s.cfg.Cooldown.Default,
|
|
|
|
|
Ecosystems: s.cfg.Cooldown.Ecosystems,
|
|
|
|
|
Packages: s.cfg.Cooldown.Packages,
|
|
|
|
|
}
|
2026-01-20 21:52:44 +00:00
|
|
|
proxy := handler.NewProxy(s.db, s.storage, fetcher, resolver, s.logger)
|
2026-03-04 19:00:31 +00:00
|
|
|
proxy.Cooldown = cd
|
2026-03-19 21:06:02 +00:00
|
|
|
proxy.CacheMetadata = s.cfg.CacheMetadata
|
2026-04-06 19:30:59 +01:00
|
|
|
proxy.MetadataTTL = s.cfg.ParseMetadataTTL()
|
2026-05-04 12:15:16 +02:00
|
|
|
proxy.GradleReadOnly = s.cfg.Gradle.BuildCache.ReadOnly
|
|
|
|
|
proxy.GradleMaxUploadSize = s.cfg.ParseGradleBuildCacheMaxUploadSize()
|
2026-04-27 12:04:38 +01:00
|
|
|
proxy.DirectServe = s.cfg.Storage.DirectServe
|
|
|
|
|
proxy.DirectServeTTL = s.cfg.ParseDirectServeTTL()
|
2026-04-27 12:14:37 +01:00
|
|
|
proxy.DirectServeBaseURL = s.cfg.Storage.DirectServeBaseURL
|
2026-01-20 21:52:44 +00:00
|
|
|
|
2026-02-03 22:40:23 +00:00
|
|
|
// Create router with Chi
|
|
|
|
|
r := chi.NewRouter()
|
|
|
|
|
|
|
|
|
|
// Add middleware
|
|
|
|
|
r.Use(middleware.RequestID)
|
|
|
|
|
r.Use(RequestIDMiddleware)
|
|
|
|
|
r.Use(middleware.RealIP)
|
|
|
|
|
r.Use(s.LoggerMiddleware)
|
|
|
|
|
r.Use(middleware.Recoverer)
|
|
|
|
|
r.Use(func(next http.Handler) http.Handler {
|
|
|
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if r.URL.Path != "/metrics" {
|
|
|
|
|
metrics.IncrementActiveRequests()
|
|
|
|
|
defer metrics.DecrementActiveRequests()
|
|
|
|
|
}
|
|
|
|
|
next.ServeHTTP(w, r)
|
|
|
|
|
})
|
|
|
|
|
})
|
2026-01-20 21:52:44 +00:00
|
|
|
|
|
|
|
|
// Mount protocol handlers
|
|
|
|
|
npmHandler := handler.NewNPMHandler(proxy, s.cfg.BaseURL)
|
|
|
|
|
cargoHandler := handler.NewCargoHandler(proxy, s.cfg.BaseURL)
|
|
|
|
|
gemHandler := handler.NewGemHandler(proxy, s.cfg.BaseURL)
|
|
|
|
|
goHandler := handler.NewGoHandler(proxy, s.cfg.BaseURL)
|
|
|
|
|
hexHandler := handler.NewHexHandler(proxy, s.cfg.BaseURL)
|
|
|
|
|
pubHandler := handler.NewPubHandler(proxy, s.cfg.BaseURL)
|
|
|
|
|
pypiHandler := handler.NewPyPIHandler(proxy, s.cfg.BaseURL)
|
2026-05-22 18:05:20 +02:00
|
|
|
mavenHandler := handler.NewMavenHandler(
|
|
|
|
|
proxy,
|
|
|
|
|
s.cfg.BaseURL,
|
|
|
|
|
s.cfg.Upstream.Maven,
|
|
|
|
|
s.cfg.Upstream.GradlePluginPortal,
|
|
|
|
|
)
|
2026-05-04 12:15:16 +02:00
|
|
|
gradleHandler := handler.NewGradleBuildCacheHandler(proxy)
|
2026-01-20 21:52:44 +00:00
|
|
|
nugetHandler := handler.NewNuGetHandler(proxy, s.cfg.BaseURL)
|
|
|
|
|
composerHandler := handler.NewComposerHandler(proxy, s.cfg.BaseURL)
|
|
|
|
|
conanHandler := handler.NewConanHandler(proxy, s.cfg.BaseURL)
|
|
|
|
|
condaHandler := handler.NewCondaHandler(proxy, s.cfg.BaseURL)
|
|
|
|
|
cranHandler := handler.NewCRANHandler(proxy, s.cfg.BaseURL)
|
2026-05-13 06:46:35 +01:00
|
|
|
juliaHandler := handler.NewJuliaHandler(proxy, s.cfg.BaseURL)
|
2026-01-29 19:35:15 +00:00
|
|
|
containerHandler := handler.NewContainerHandler(proxy, s.cfg.BaseURL)
|
|
|
|
|
debianHandler := handler.NewDebianHandler(proxy, s.cfg.BaseURL)
|
|
|
|
|
rpmHandler := handler.NewRPMHandler(proxy, s.cfg.BaseURL)
|
2026-01-20 21:52:44 +00:00
|
|
|
|
2026-02-03 22:40:23 +00:00
|
|
|
r.Mount("/npm", http.StripPrefix("/npm", npmHandler.Routes()))
|
|
|
|
|
r.Mount("/cargo", http.StripPrefix("/cargo", cargoHandler.Routes()))
|
|
|
|
|
r.Mount("/gem", http.StripPrefix("/gem", gemHandler.Routes()))
|
|
|
|
|
r.Mount("/go", http.StripPrefix("/go", goHandler.Routes()))
|
|
|
|
|
r.Mount("/hex", http.StripPrefix("/hex", hexHandler.Routes()))
|
|
|
|
|
r.Mount("/pub", http.StripPrefix("/pub", pubHandler.Routes()))
|
|
|
|
|
r.Mount("/pypi", http.StripPrefix("/pypi", pypiHandler.Routes()))
|
|
|
|
|
r.Mount("/maven", http.StripPrefix("/maven", mavenHandler.Routes()))
|
2026-05-04 12:15:16 +02:00
|
|
|
r.Mount("/gradle", http.StripPrefix("/gradle", gradleHandler.Routes()))
|
2026-02-03 22:40:23 +00:00
|
|
|
r.Mount("/nuget", http.StripPrefix("/nuget", nugetHandler.Routes()))
|
|
|
|
|
r.Mount("/composer", http.StripPrefix("/composer", composerHandler.Routes()))
|
|
|
|
|
r.Mount("/conan", http.StripPrefix("/conan", conanHandler.Routes()))
|
|
|
|
|
r.Mount("/conda", http.StripPrefix("/conda", condaHandler.Routes()))
|
|
|
|
|
r.Mount("/cran", http.StripPrefix("/cran", cranHandler.Routes()))
|
2026-05-13 06:46:35 +01:00
|
|
|
r.Mount("/julia", http.StripPrefix("/julia", juliaHandler.Routes()))
|
2026-02-03 22:40:23 +00:00
|
|
|
r.Mount("/v2", http.StripPrefix("/v2", containerHandler.Routes()))
|
|
|
|
|
r.Mount("/debian", http.StripPrefix("/debian", debianHandler.Routes()))
|
|
|
|
|
r.Mount("/rpm", http.StripPrefix("/rpm", rpmHandler.Routes()))
|
2026-01-29 19:35:15 +00:00
|
|
|
|
2026-05-23 18:16:28 +01:00
|
|
|
// Health, stats, and metrics endpoints
|
2026-02-03 22:40:23 +00:00
|
|
|
r.Get("/health", s.handleHealth)
|
|
|
|
|
r.Get("/stats", s.handleStats)
|
2026-03-11 17:18:29 +00:00
|
|
|
r.Get("/openapi.json", s.handleOpenAPIJSON)
|
2026-02-03 22:40:23 +00:00
|
|
|
r.Get("/metrics", func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
metrics.Handler().ServeHTTP(w, r)
|
|
|
|
|
})
|
2026-05-23 18:16:28 +01:00
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
})
|
2026-01-20 21:52:44 +00:00
|
|
|
|
2026-01-29 19:35:15 +00:00
|
|
|
// API endpoints for enrichment data
|
|
|
|
|
enrichSvc := enrichment.New(s.logger)
|
2026-02-03 22:40:23 +00:00
|
|
|
apiHandler := NewAPIHandler(enrichSvc, s.db)
|
|
|
|
|
|
Fix Composer minified metadata expansion and namespaced package routing (#63)
* Fix Composer minified metadata expansion and namespaced package routing
Packagist serves metadata in a minified format where only the first version
entry has all fields and subsequent entries inherit from the previous one.
The proxy was passing this through without expanding it, which meant cooldown
filtering could break the inheritance chain (losing fields like `name`) and
`~dev` sentinel markers were silently dropped.
The proxy now expands the minified format before filtering and rewriting,
ensuring every version entry is self-contained.
Web UI and API routes used single-segment chi URL params for package names,
which broke for Composer's `vendor/name` format. `/package/composer/monolog/monolog`
would match the version show route instead of the package show route.
All `/package/` and related API routes now use wildcard paths with a
`resolvePackageName` helper that tries increasingly longer path prefixes as
package names via DB lookup, correctly handling namespaced packages across
all endpoints (show, version, browse, compare, vulns).
Fixes #61, fixes #62
* Add namespaced package routing tests for all affected ecosystems
Verifies the wildcard routing handles slashes in package names for
npm (@babel/core), Go modules (github.com/stretchr/testify),
OCI images (library/nginx), Conda (conda-forge/numpy), and
Conan (zlib/1.2.13@demo/stable).
* Regenerate swagger docs after route refactor
The swagger annotations for the old per-endpoint handlers were removed
during the wildcard routing refactor. Regenerate to match current state.
2026-04-06 13:07:02 +01:00
|
|
|
r.Get("/api/package/{ecosystem}/*", apiHandler.HandlePackagePath)
|
|
|
|
|
r.Get("/api/vulns/{ecosystem}/*", apiHandler.HandleVulnsPath)
|
2026-02-03 22:40:23 +00:00
|
|
|
r.Post("/api/outdated", apiHandler.HandleOutdated)
|
|
|
|
|
r.Post("/api/bulk", apiHandler.HandleBulkLookup)
|
|
|
|
|
r.Get("/api/search", apiHandler.HandleSearch)
|
|
|
|
|
r.Get("/api/packages", apiHandler.HandlePackagesList)
|
2026-01-29 19:35:15 +00:00
|
|
|
|
2026-04-01 15:40:18 +01:00
|
|
|
// Start background context (used by mirror jobs and cleanup)
|
|
|
|
|
bgCtx, bgCancel := context.WithCancel(context.Background())
|
|
|
|
|
s.cancel = bgCancel
|
2026-05-04 12:15:16 +02:00
|
|
|
s.startGradleBuildCacheEviction(bgCtx)
|
2026-04-01 15:40:18 +01:00
|
|
|
|
2026-04-01 16:14:07 +01:00
|
|
|
// Mirror API endpoints (opt-in via mirror_api config or PROXY_MIRROR_API env)
|
|
|
|
|
if s.cfg.MirrorAPI {
|
|
|
|
|
mirrorSvc := mirror.New(proxy, s.db, s.storage, s.logger, 4) //nolint:mnd // default concurrency
|
|
|
|
|
jobStore := mirror.NewJobStore(bgCtx, mirrorSvc)
|
|
|
|
|
mirrorAPI := NewMirrorAPIHandler(jobStore)
|
|
|
|
|
r.Post("/api/mirror", mirrorAPI.HandleCreate)
|
|
|
|
|
r.Get("/api/mirror/{id}", mirrorAPI.HandleGet)
|
|
|
|
|
r.Delete("/api/mirror/{id}", mirrorAPI.HandleCancel)
|
|
|
|
|
go jobStore.StartCleanup(bgCtx)
|
|
|
|
|
}
|
2026-03-19 21:06:02 +00:00
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
s.http = &http.Server{
|
|
|
|
|
Addr: s.cfg.Listen,
|
2026-02-03 22:40:23 +00:00
|
|
|
Handler: r,
|
2026-03-18 10:59:29 +00:00
|
|
|
ReadTimeout: serverReadTimeout,
|
|
|
|
|
WriteTimeout: serverWriteTimeout, // Large artifacts need time
|
|
|
|
|
IdleTimeout: serverIdleTimeout,
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
s.logger.Info("starting server",
|
|
|
|
|
"listen", s.cfg.Listen,
|
|
|
|
|
"base_url", s.cfg.BaseURL,
|
2026-04-03 14:06:51 +01:00
|
|
|
"storage", s.storage.URL(),
|
2026-01-20 21:52:44 +00:00
|
|
|
"database", s.cfg.Database.Path)
|
2026-02-03 22:40:23 +00:00
|
|
|
go s.updateCacheStatsMetrics()
|
2026-04-30 18:09:01 +01:00
|
|
|
go s.startEvictionLoop(bgCtx)
|
2026-02-03 22:40:23 +00:00
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
return s.http.ListenAndServe()
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-03 22:40:23 +00:00
|
|
|
// updateCacheStatsMetrics periodically updates cache statistics in Prometheus metrics.
|
|
|
|
|
func (s *Server) updateCacheStatsMetrics() {
|
|
|
|
|
ticker := time.NewTicker(1 * time.Minute)
|
|
|
|
|
defer ticker.Stop()
|
|
|
|
|
|
|
|
|
|
// Update once immediately
|
|
|
|
|
s.updateCacheStats()
|
|
|
|
|
|
|
|
|
|
for range ticker.C {
|
|
|
|
|
s.updateCacheStats()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) updateCacheStats() {
|
|
|
|
|
stats, err := s.db.GetCacheStats()
|
|
|
|
|
if err != nil {
|
|
|
|
|
s.logger.Warn("failed to get cache stats for metrics", "error", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
metrics.UpdateCacheStats(stats.TotalSize, stats.TotalArtifacts)
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
// Shutdown gracefully shuts down the server.
|
|
|
|
|
func (s *Server) Shutdown(ctx context.Context) error {
|
|
|
|
|
s.logger.Info("shutting down server")
|
|
|
|
|
|
2026-03-19 21:06:02 +00:00
|
|
|
if s.cancel != nil {
|
|
|
|
|
s.cancel()
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
var errs []error
|
|
|
|
|
|
|
|
|
|
if s.http != nil {
|
|
|
|
|
if err := s.http.Shutdown(ctx); err != nil {
|
|
|
|
|
errs = append(errs, fmt.Errorf("http shutdown: %w", err))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 14:06:51 +01:00
|
|
|
if s.storage != nil {
|
|
|
|
|
if err := s.storage.Close(); err != nil {
|
|
|
|
|
errs = append(errs, fmt.Errorf("storage close: %w", err))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
if s.db != nil {
|
|
|
|
|
if err := s.db.Close(); err != nil {
|
|
|
|
|
errs = append(errs, fmt.Errorf("database close: %w", err))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if len(errs) > 0 {
|
|
|
|
|
return errs[0]
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-29 16:33:09 +00:00
|
|
|
// authForURL returns the authentication header for a given URL based on config.
|
|
|
|
|
func (s *Server) authForURL(url string) (headerName, headerValue string) {
|
|
|
|
|
auth := s.cfg.Upstream.AuthForURL(url)
|
|
|
|
|
if auth == nil {
|
|
|
|
|
return "", ""
|
|
|
|
|
}
|
|
|
|
|
return auth.Header()
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
// Get cache statistics
|
|
|
|
|
stats, err := s.db.GetCacheStats()
|
|
|
|
|
if err != nil {
|
|
|
|
|
s.logger.Error("failed to get cache stats", "error", err)
|
|
|
|
|
stats = &database.CacheStats{}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-29 19:35:15 +00:00
|
|
|
// Get enrichment statistics
|
|
|
|
|
enrichStats, err := s.db.GetEnrichmentStats()
|
|
|
|
|
if err != nil {
|
|
|
|
|
s.logger.Error("failed to get enrichment stats", "error", err)
|
|
|
|
|
enrichStats = &database.EnrichmentStats{}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
// Get popular packages
|
2026-03-18 10:59:29 +00:00
|
|
|
popular, err := s.db.GetMostPopularPackages(dashboardTopN)
|
2026-01-20 21:52:44 +00:00
|
|
|
if err != nil {
|
|
|
|
|
s.logger.Error("failed to get popular packages", "error", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get recent packages
|
2026-03-18 10:59:29 +00:00
|
|
|
recent, err := s.db.GetRecentlyCachedPackages(dashboardTopN)
|
2026-01-20 21:52:44 +00:00
|
|
|
if err != nil {
|
|
|
|
|
s.logger.Error("failed to get recent packages", "error", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Build dashboard data
|
|
|
|
|
data := DashboardData{
|
|
|
|
|
Stats: DashboardStats{
|
|
|
|
|
CachedArtifacts: stats.TotalArtifacts,
|
|
|
|
|
TotalSize: formatSize(stats.TotalSize),
|
|
|
|
|
TotalPackages: stats.TotalPackages,
|
|
|
|
|
TotalVersions: stats.TotalVersions,
|
|
|
|
|
},
|
2026-01-29 19:35:15 +00:00
|
|
|
EnrichmentStats: EnrichmentStatsView{
|
|
|
|
|
EnrichedPackages: enrichStats.EnrichedPackages,
|
|
|
|
|
VulnSyncedPackages: enrichStats.VulnSyncedPackages,
|
|
|
|
|
TotalVulnerabilities: enrichStats.TotalVulnerabilities,
|
|
|
|
|
CriticalVulns: enrichStats.CriticalVulns,
|
|
|
|
|
HighVulns: enrichStats.HighVulns,
|
|
|
|
|
MediumVulns: enrichStats.MediumVulns,
|
|
|
|
|
LowVulns: enrichStats.LowVulns,
|
|
|
|
|
HasVulns: enrichStats.TotalVulnerabilities > 0,
|
|
|
|
|
},
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, p := range popular {
|
2026-01-29 19:35:15 +00:00
|
|
|
pkgInfo := PackageInfo{
|
2026-01-20 21:52:44 +00:00
|
|
|
Ecosystem: p.Ecosystem,
|
|
|
|
|
Name: p.Name,
|
|
|
|
|
Hits: p.Hits,
|
|
|
|
|
Size: formatSize(p.Size),
|
2026-01-29 19:35:15 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fetch enrichment data for this package
|
|
|
|
|
if pkg, err := s.db.GetPackageByEcosystemName(p.Ecosystem, p.Name); err == nil && pkg != nil {
|
|
|
|
|
if pkg.License.Valid {
|
|
|
|
|
pkgInfo.License = pkg.License.String
|
|
|
|
|
pkgInfo.LicenseCategory = categorizeLicenseCSS(pkg.License.String)
|
|
|
|
|
}
|
|
|
|
|
if pkg.LatestVersion.Valid {
|
|
|
|
|
pkgInfo.LatestVersion = pkg.LatestVersion.String
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get vulnerability count
|
|
|
|
|
if vulnCount, err := s.db.GetVulnCountForPackage(p.Ecosystem, p.Name); err == nil {
|
|
|
|
|
pkgInfo.VulnCount = vulnCount
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
data.PopularPackages = append(data.PopularPackages, pkgInfo)
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, p := range recent {
|
2026-01-29 19:35:15 +00:00
|
|
|
pkgInfo := PackageInfo{
|
2026-01-20 21:52:44 +00:00
|
|
|
Ecosystem: p.Ecosystem,
|
|
|
|
|
Name: p.Name,
|
|
|
|
|
Version: p.Version,
|
|
|
|
|
Size: formatSize(p.Size),
|
|
|
|
|
CachedAt: formatTimeAgo(p.CachedAt),
|
2026-01-29 19:35:15 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fetch enrichment data for this package
|
|
|
|
|
if pkg, err := s.db.GetPackageByEcosystemName(p.Ecosystem, p.Name); err == nil && pkg != nil {
|
|
|
|
|
if pkg.License.Valid {
|
|
|
|
|
pkgInfo.License = pkg.License.String
|
|
|
|
|
pkgInfo.LicenseCategory = categorizeLicenseCSS(pkg.License.String)
|
|
|
|
|
}
|
|
|
|
|
if pkg.LatestVersion.Valid {
|
|
|
|
|
pkgInfo.LatestVersion = pkg.LatestVersion.String
|
|
|
|
|
pkgInfo.IsOutdated = p.Version != "" && pkg.LatestVersion.String != "" && p.Version != pkg.LatestVersion.String
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get vulnerability count
|
|
|
|
|
if vulnCount, err := s.db.GetVulnCountForPackage(p.Ecosystem, p.Name); err == nil {
|
|
|
|
|
pkgInfo.VulnCount = vulnCount
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
data.RecentPackages = append(data.RecentPackages, pkgInfo)
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-03 22:40:23 +00:00
|
|
|
if err := s.templates.Render(w, "dashboard", data); err != nil {
|
2026-01-20 21:52:44 +00:00
|
|
|
s.logger.Error("failed to render dashboard", "error", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-11 17:18:29 +00:00
|
|
|
func (s *Server) handleOpenAPIJSON(w http.ResponseWriter, _ *http.Request) {
|
|
|
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
|
|
|
_, _ = w.Write([]byte(swaggerdoc.SwaggerInfo.ReadDoc()))
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-03 22:40:23 +00:00
|
|
|
func (s *Server) handleInstall(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
data := struct {
|
|
|
|
|
Registries []RegistryConfig
|
|
|
|
|
}{
|
|
|
|
|
Registries: getRegistryConfigs(s.cfg.BaseURL),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := s.templates.Render(w, "install", data); err != nil {
|
|
|
|
|
s.logger.Error("failed to render install page", "error", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
query := r.URL.Query().Get("q")
|
|
|
|
|
ecosystem := r.URL.Query().Get("ecosystem")
|
|
|
|
|
|
|
|
|
|
if query == "" {
|
2026-05-23 18:16:28 +01:00
|
|
|
http.Redirect(w, r, "/ui/", http.StatusSeeOther)
|
2026-02-03 22:40:23 +00:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
page := 1
|
|
|
|
|
if pageStr := r.URL.Query().Get("page"); pageStr != "" {
|
|
|
|
|
if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
|
|
|
|
|
page = p
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
limit := 50
|
|
|
|
|
|
|
|
|
|
results, err := s.db.SearchPackages(query, ecosystem, limit, (page-1)*limit)
|
|
|
|
|
if err != nil {
|
|
|
|
|
s.logger.Error("search failed", "error", err)
|
|
|
|
|
http.Error(w, "search failed", http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
total, err := s.db.CountSearchResults(query, ecosystem)
|
|
|
|
|
if err != nil {
|
|
|
|
|
s.logger.Error("failed to count search results", "error", err)
|
|
|
|
|
total = 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
items := make([]SearchResultItem, len(results))
|
|
|
|
|
for i, result := range results {
|
|
|
|
|
latestVersion := ""
|
|
|
|
|
if result.LatestVersion.Valid {
|
|
|
|
|
latestVersion = result.LatestVersion.String
|
|
|
|
|
}
|
|
|
|
|
license := ""
|
|
|
|
|
if result.License.Valid {
|
|
|
|
|
license = result.License.String
|
|
|
|
|
}
|
|
|
|
|
items[i] = SearchResultItem{
|
|
|
|
|
Ecosystem: result.Ecosystem,
|
|
|
|
|
Name: result.Name,
|
|
|
|
|
LatestVersion: latestVersion,
|
|
|
|
|
License: license,
|
|
|
|
|
Hits: result.Hits,
|
|
|
|
|
Size: result.Size,
|
|
|
|
|
SizeFormatted: formatSize(result.Size),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
totalPages := int((total + int64(limit) - 1) / int64(limit))
|
|
|
|
|
|
|
|
|
|
data := SearchPageData{
|
|
|
|
|
Query: query,
|
|
|
|
|
Ecosystem: ecosystem,
|
|
|
|
|
Results: items,
|
|
|
|
|
Count: int(total),
|
|
|
|
|
Page: page,
|
|
|
|
|
PerPage: limit,
|
|
|
|
|
TotalPages: totalPages,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := s.templates.Render(w, "search", data); err != nil {
|
|
|
|
|
s.logger.Error("failed to render search page", "error", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) handlePackagesList(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
ecosystem := r.URL.Query().Get("ecosystem")
|
|
|
|
|
sortBy := r.URL.Query().Get("sort")
|
|
|
|
|
if sortBy == "" {
|
2026-03-18 10:59:29 +00:00
|
|
|
sortBy = defaultSortBy
|
2026-02-03 22:40:23 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
page := 1
|
|
|
|
|
if pageStr := r.URL.Query().Get("page"); pageStr != "" {
|
|
|
|
|
if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
|
|
|
|
|
page = p
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
limit := 50
|
|
|
|
|
|
|
|
|
|
packages, err := s.db.ListCachedPackages(ecosystem, sortBy, limit, (page-1)*limit)
|
|
|
|
|
if err != nil {
|
|
|
|
|
s.logger.Error("failed to list packages", "error", err)
|
|
|
|
|
http.Error(w, "failed to list packages", http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
total, err := s.db.CountCachedPackages(ecosystem)
|
|
|
|
|
if err != nil {
|
|
|
|
|
s.logger.Error("failed to count packages", "error", err)
|
|
|
|
|
total = 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
items := make([]SearchResultItem, len(packages))
|
|
|
|
|
for i, pkg := range packages {
|
|
|
|
|
latestVersion := ""
|
|
|
|
|
if pkg.LatestVersion.Valid {
|
|
|
|
|
latestVersion = pkg.LatestVersion.String
|
|
|
|
|
}
|
|
|
|
|
license := ""
|
|
|
|
|
if pkg.License.Valid {
|
|
|
|
|
license = pkg.License.String
|
|
|
|
|
}
|
|
|
|
|
cachedAt := ""
|
|
|
|
|
if pkg.CachedAt.Valid && pkg.CachedAt.String != "" {
|
|
|
|
|
if t, err := time.Parse("2006-01-02 15:04:05.999999999-07:00", pkg.CachedAt.String); err == nil {
|
|
|
|
|
cachedAt = formatTimeAgo(t)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
items[i] = SearchResultItem{
|
2026-03-11 17:18:29 +00:00
|
|
|
Ecosystem: pkg.Ecosystem,
|
|
|
|
|
Name: pkg.Name,
|
|
|
|
|
LatestVersion: latestVersion,
|
|
|
|
|
License: license,
|
2026-02-03 22:40:23 +00:00
|
|
|
LicenseCategory: categorizeLicenseCSS(license),
|
2026-03-11 17:18:29 +00:00
|
|
|
Hits: pkg.Hits,
|
|
|
|
|
Size: pkg.Size,
|
|
|
|
|
SizeFormatted: formatSize(pkg.Size),
|
|
|
|
|
CachedAt: cachedAt,
|
|
|
|
|
VulnCount: pkg.VulnCount,
|
2026-02-03 22:40:23 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
totalPages := int((total + int64(limit) - 1) / int64(limit))
|
|
|
|
|
|
|
|
|
|
data := PackagesListPageData{
|
|
|
|
|
Ecosystem: ecosystem,
|
|
|
|
|
SortBy: sortBy,
|
|
|
|
|
Results: items,
|
|
|
|
|
Count: int(total),
|
|
|
|
|
Page: page,
|
|
|
|
|
PerPage: limit,
|
|
|
|
|
TotalPages: totalPages,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := s.templates.Render(w, "packages_list", data); err != nil {
|
|
|
|
|
s.logger.Error("failed to render packages list page", "error", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
Fix Composer minified metadata expansion and namespaced package routing (#63)
* Fix Composer minified metadata expansion and namespaced package routing
Packagist serves metadata in a minified format where only the first version
entry has all fields and subsequent entries inherit from the previous one.
The proxy was passing this through without expanding it, which meant cooldown
filtering could break the inheritance chain (losing fields like `name`) and
`~dev` sentinel markers were silently dropped.
The proxy now expands the minified format before filtering and rewriting,
ensuring every version entry is self-contained.
Web UI and API routes used single-segment chi URL params for package names,
which broke for Composer's `vendor/name` format. `/package/composer/monolog/monolog`
would match the version show route instead of the package show route.
All `/package/` and related API routes now use wildcard paths with a
`resolvePackageName` helper that tries increasingly longer path prefixes as
package names via DB lookup, correctly handling namespaced packages across
all endpoints (show, version, browse, compare, vulns).
Fixes #61, fixes #62
* Add namespaced package routing tests for all affected ecosystems
Verifies the wildcard routing handles slashes in package names for
npm (@babel/core), Go modules (github.com/stretchr/testify),
OCI images (library/nginx), Conda (conda-forge/numpy), and
Conan (zlib/1.2.13@demo/stable).
* Regenerate swagger docs after route refactor
The swagger annotations for the old per-endpoint handlers were removed
during the wildcard routing refactor. Regenerate to match current state.
2026-04-06 13:07:02 +01:00
|
|
|
// handlePackagePath dispatches wildcard package routes to the appropriate handler.
|
|
|
|
|
// It resolves namespaced package names (e.g., Composer vendor/name) by consulting
|
|
|
|
|
// the database to determine which path segments are part of the package name.
|
|
|
|
|
//
|
|
|
|
|
// Supported paths:
|
|
|
|
|
//
|
|
|
|
|
// {name} -> package show
|
|
|
|
|
// {name}/{version} -> version show
|
|
|
|
|
// {name}/{version}/browse -> browse source
|
|
|
|
|
// {name}/compare/{v1}...{v2} -> compare versions
|
|
|
|
|
func (s *Server) handlePackagePath(w http.ResponseWriter, r *http.Request) {
|
2026-02-03 22:40:23 +00:00
|
|
|
ecosystem := chi.URLParam(r, "ecosystem")
|
Fix Composer minified metadata expansion and namespaced package routing (#63)
* Fix Composer minified metadata expansion and namespaced package routing
Packagist serves metadata in a minified format where only the first version
entry has all fields and subsequent entries inherit from the previous one.
The proxy was passing this through without expanding it, which meant cooldown
filtering could break the inheritance chain (losing fields like `name`) and
`~dev` sentinel markers were silently dropped.
The proxy now expands the minified format before filtering and rewriting,
ensuring every version entry is self-contained.
Web UI and API routes used single-segment chi URL params for package names,
which broke for Composer's `vendor/name` format. `/package/composer/monolog/monolog`
would match the version show route instead of the package show route.
All `/package/` and related API routes now use wildcard paths with a
`resolvePackageName` helper that tries increasingly longer path prefixes as
package names via DB lookup, correctly handling namespaced packages across
all endpoints (show, version, browse, compare, vulns).
Fixes #61, fixes #62
* Add namespaced package routing tests for all affected ecosystems
Verifies the wildcard routing handles slashes in package names for
npm (@babel/core), Go modules (github.com/stretchr/testify),
OCI images (library/nginx), Conda (conda-forge/numpy), and
Conan (zlib/1.2.13@demo/stable).
* Regenerate swagger docs after route refactor
The swagger annotations for the old per-endpoint handlers were removed
during the wildcard routing refactor. Regenerate to match current state.
2026-04-06 13:07:02 +01:00
|
|
|
wildcard := chi.URLParam(r, "*")
|
2026-05-03 09:14:18 +01:00
|
|
|
if err := validatePackagePath(wildcard); err != nil {
|
|
|
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
|
|
|
return
|
|
|
|
|
}
|
Fix Composer minified metadata expansion and namespaced package routing (#63)
* Fix Composer minified metadata expansion and namespaced package routing
Packagist serves metadata in a minified format where only the first version
entry has all fields and subsequent entries inherit from the previous one.
The proxy was passing this through without expanding it, which meant cooldown
filtering could break the inheritance chain (losing fields like `name`) and
`~dev` sentinel markers were silently dropped.
The proxy now expands the minified format before filtering and rewriting,
ensuring every version entry is self-contained.
Web UI and API routes used single-segment chi URL params for package names,
which broke for Composer's `vendor/name` format. `/package/composer/monolog/monolog`
would match the version show route instead of the package show route.
All `/package/` and related API routes now use wildcard paths with a
`resolvePackageName` helper that tries increasingly longer path prefixes as
package names via DB lookup, correctly handling namespaced packages across
all endpoints (show, version, browse, compare, vulns).
Fixes #61, fixes #62
* Add namespaced package routing tests for all affected ecosystems
Verifies the wildcard routing handles slashes in package names for
npm (@babel/core), Go modules (github.com/stretchr/testify),
OCI images (library/nginx), Conda (conda-forge/numpy), and
Conan (zlib/1.2.13@demo/stable).
* Regenerate swagger docs after route refactor
The swagger annotations for the old per-endpoint handlers were removed
during the wildcard routing refactor. Regenerate to match current state.
2026-04-06 13:07:02 +01:00
|
|
|
segments := splitWildcardPath(wildcard)
|
2026-02-03 22:40:23 +00:00
|
|
|
|
Fix Composer minified metadata expansion and namespaced package routing (#63)
* Fix Composer minified metadata expansion and namespaced package routing
Packagist serves metadata in a minified format where only the first version
entry has all fields and subsequent entries inherit from the previous one.
The proxy was passing this through without expanding it, which meant cooldown
filtering could break the inheritance chain (losing fields like `name`) and
`~dev` sentinel markers were silently dropped.
The proxy now expands the minified format before filtering and rewriting,
ensuring every version entry is self-contained.
Web UI and API routes used single-segment chi URL params for package names,
which broke for Composer's `vendor/name` format. `/package/composer/monolog/monolog`
would match the version show route instead of the package show route.
All `/package/` and related API routes now use wildcard paths with a
`resolvePackageName` helper that tries increasingly longer path prefixes as
package names via DB lookup, correctly handling namespaced packages across
all endpoints (show, version, browse, compare, vulns).
Fixes #61, fixes #62
* Add namespaced package routing tests for all affected ecosystems
Verifies the wildcard routing handles slashes in package names for
npm (@babel/core), Go modules (github.com/stretchr/testify),
OCI images (library/nginx), Conda (conda-forge/numpy), and
Conan (zlib/1.2.13@demo/stable).
* Regenerate swagger docs after route refactor
The swagger annotations for the old per-endpoint handlers were removed
during the wildcard routing refactor. Regenerate to match current state.
2026-04-06 13:07:02 +01:00
|
|
|
if ecosystem == "" || len(segments) == 0 {
|
|
|
|
|
http.Error(w, "ecosystem and package name required", http.StatusBadRequest)
|
2026-02-03 22:40:23 +00:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
Fix Composer minified metadata expansion and namespaced package routing (#63)
* Fix Composer minified metadata expansion and namespaced package routing
Packagist serves metadata in a minified format where only the first version
entry has all fields and subsequent entries inherit from the previous one.
The proxy was passing this through without expanding it, which meant cooldown
filtering could break the inheritance chain (losing fields like `name`) and
`~dev` sentinel markers were silently dropped.
The proxy now expands the minified format before filtering and rewriting,
ensuring every version entry is self-contained.
Web UI and API routes used single-segment chi URL params for package names,
which broke for Composer's `vendor/name` format. `/package/composer/monolog/monolog`
would match the version show route instead of the package show route.
All `/package/` and related API routes now use wildcard paths with a
`resolvePackageName` helper that tries increasingly longer path prefixes as
package names via DB lookup, correctly handling namespaced packages across
all endpoints (show, version, browse, compare, vulns).
Fixes #61, fixes #62
* Add namespaced package routing tests for all affected ecosystems
Verifies the wildcard routing handles slashes in package names for
npm (@babel/core), Go modules (github.com/stretchr/testify),
OCI images (library/nginx), Conda (conda-forge/numpy), and
Conan (zlib/1.2.13@demo/stable).
* Regenerate swagger docs after route refactor
The swagger annotations for the old per-endpoint handlers were removed
during the wildcard routing refactor. Regenerate to match current state.
2026-04-06 13:07:02 +01:00
|
|
|
// Check for compare route: {name}/compare/{versions}
|
|
|
|
|
for i, seg := range segments {
|
|
|
|
|
if seg == "compare" && i > 0 && i < len(segments)-1 {
|
|
|
|
|
name := strings.Join(segments[:i], "/")
|
|
|
|
|
versions := strings.Join(segments[i+1:], "/")
|
|
|
|
|
s.showComparePage(w, ecosystem, name, versions)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check for browse suffix
|
|
|
|
|
browse := false
|
|
|
|
|
if len(segments) > 1 && segments[len(segments)-1] == "browse" {
|
|
|
|
|
browse = true
|
|
|
|
|
segments = segments[:len(segments)-1]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Resolve package name from the remaining segments using DB lookup.
|
|
|
|
|
name, rest := resolvePackageName(s.db, ecosystem, segments)
|
|
|
|
|
|
|
|
|
|
if name == "" {
|
|
|
|
|
// No package found in DB. Fall back to heuristic: assume the last
|
|
|
|
|
// segment is a version (if present) and everything else is the name.
|
|
|
|
|
if len(segments) == 1 {
|
|
|
|
|
// Single segment, no DB match: try package show (will 404).
|
|
|
|
|
s.showPackage(w, ecosystem, segments[0])
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
name = strings.Join(segments[:len(segments)-1], "/")
|
|
|
|
|
rest = segments[len(segments)-1:]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
switch {
|
|
|
|
|
case len(rest) == 0 && !browse:
|
|
|
|
|
s.showPackage(w, ecosystem, name)
|
|
|
|
|
case len(rest) == 1 && browse:
|
|
|
|
|
s.showBrowseSource(w, ecosystem, name, rest[0])
|
|
|
|
|
case len(rest) == 1:
|
|
|
|
|
s.showVersion(w, ecosystem, name, rest[0])
|
|
|
|
|
default:
|
|
|
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) showPackage(w http.ResponseWriter, ecosystem, name string) {
|
2026-02-03 22:40:23 +00:00
|
|
|
pkg, err := s.db.GetPackageByEcosystemName(ecosystem, name)
|
|
|
|
|
if err != nil {
|
|
|
|
|
s.logger.Error("failed to get package", "error", err, "ecosystem", ecosystem, "name", name)
|
|
|
|
|
http.Error(w, "package not found", http.StatusNotFound)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if pkg == nil {
|
|
|
|
|
http.Error(w, "package not found", http.StatusNotFound)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
versions, err := s.db.GetVersionsByPackagePURL(pkg.PURL)
|
|
|
|
|
if err != nil {
|
|
|
|
|
s.logger.Error("failed to get versions", "error", err)
|
|
|
|
|
versions = []database.Version{}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
vulns, err := s.db.GetVulnerabilitiesForPackage(ecosystem, name)
|
|
|
|
|
if err != nil {
|
|
|
|
|
s.logger.Error("failed to get vulnerabilities", "error", err)
|
|
|
|
|
vulns = []database.Vulnerability{}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
data := PackageShowData{
|
|
|
|
|
Package: pkg,
|
|
|
|
|
Versions: versions,
|
|
|
|
|
Vulnerabilities: vulns,
|
|
|
|
|
LicenseCategory: categorizeLicense(pkg.License),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := s.templates.Render(w, "package_show", data); err != nil {
|
|
|
|
|
s.logger.Error("failed to render package show", "error", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
Fix Composer minified metadata expansion and namespaced package routing (#63)
* Fix Composer minified metadata expansion and namespaced package routing
Packagist serves metadata in a minified format where only the first version
entry has all fields and subsequent entries inherit from the previous one.
The proxy was passing this through without expanding it, which meant cooldown
filtering could break the inheritance chain (losing fields like `name`) and
`~dev` sentinel markers were silently dropped.
The proxy now expands the minified format before filtering and rewriting,
ensuring every version entry is self-contained.
Web UI and API routes used single-segment chi URL params for package names,
which broke for Composer's `vendor/name` format. `/package/composer/monolog/monolog`
would match the version show route instead of the package show route.
All `/package/` and related API routes now use wildcard paths with a
`resolvePackageName` helper that tries increasingly longer path prefixes as
package names via DB lookup, correctly handling namespaced packages across
all endpoints (show, version, browse, compare, vulns).
Fixes #61, fixes #62
* Add namespaced package routing tests for all affected ecosystems
Verifies the wildcard routing handles slashes in package names for
npm (@babel/core), Go modules (github.com/stretchr/testify),
OCI images (library/nginx), Conda (conda-forge/numpy), and
Conan (zlib/1.2.13@demo/stable).
* Regenerate swagger docs after route refactor
The swagger annotations for the old per-endpoint handlers were removed
during the wildcard routing refactor. Regenerate to match current state.
2026-04-06 13:07:02 +01:00
|
|
|
func (s *Server) showVersion(w http.ResponseWriter, ecosystem, name, version string) {
|
2026-02-03 22:40:23 +00:00
|
|
|
pkg, err := s.db.GetPackageByEcosystemName(ecosystem, name)
|
|
|
|
|
if err != nil || pkg == nil {
|
|
|
|
|
s.logger.Error("failed to get package", "error", err)
|
|
|
|
|
http.Error(w, "package not found", http.StatusNotFound)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-04 09:20:16 +00:00
|
|
|
versionPURL := purl.MakePURLString(ecosystem, name, version)
|
|
|
|
|
ver, err := s.db.GetVersionByPURL(versionPURL)
|
2026-02-03 22:40:23 +00:00
|
|
|
if err != nil || ver == nil {
|
|
|
|
|
s.logger.Error("failed to get version", "error", err)
|
|
|
|
|
http.Error(w, "version not found", http.StatusNotFound)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-04 09:20:16 +00:00
|
|
|
artifacts, err := s.db.GetArtifactsByVersionPURL(versionPURL)
|
2026-02-03 22:40:23 +00:00
|
|
|
if err != nil {
|
|
|
|
|
s.logger.Error("failed to get artifacts", "error", err)
|
|
|
|
|
artifacts = []database.Artifact{}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
vulns, err := s.db.GetVulnerabilitiesForPackage(ecosystem, name)
|
|
|
|
|
if err != nil {
|
|
|
|
|
s.logger.Error("failed to get vulnerabilities", "error", err)
|
|
|
|
|
vulns = []database.Vulnerability{}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-16 10:53:21 +00:00
|
|
|
isOutdated := pkg.LatestVersion.Valid && pkg.LatestVersion.String != version
|
2026-02-03 22:40:23 +00:00
|
|
|
|
|
|
|
|
hasCached := false
|
|
|
|
|
for _, art := range artifacts {
|
|
|
|
|
if art.StoragePath.Valid {
|
|
|
|
|
hasCached = true
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
data := VersionShowData{
|
|
|
|
|
Package: pkg,
|
|
|
|
|
Version: ver,
|
|
|
|
|
Artifacts: artifacts,
|
|
|
|
|
Vulnerabilities: vulns,
|
|
|
|
|
IsOutdated: isOutdated,
|
|
|
|
|
LicenseCategory: categorizeLicense(ver.License),
|
|
|
|
|
HasCachedArtifact: hasCached,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := s.templates.Render(w, "version_show", data); err != nil {
|
|
|
|
|
s.logger.Error("failed to render version show", "error", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
Fix Composer minified metadata expansion and namespaced package routing (#63)
* Fix Composer minified metadata expansion and namespaced package routing
Packagist serves metadata in a minified format where only the first version
entry has all fields and subsequent entries inherit from the previous one.
The proxy was passing this through without expanding it, which meant cooldown
filtering could break the inheritance chain (losing fields like `name`) and
`~dev` sentinel markers were silently dropped.
The proxy now expands the minified format before filtering and rewriting,
ensuring every version entry is self-contained.
Web UI and API routes used single-segment chi URL params for package names,
which broke for Composer's `vendor/name` format. `/package/composer/monolog/monolog`
would match the version show route instead of the package show route.
All `/package/` and related API routes now use wildcard paths with a
`resolvePackageName` helper that tries increasingly longer path prefixes as
package names via DB lookup, correctly handling namespaced packages across
all endpoints (show, version, browse, compare, vulns).
Fixes #61, fixes #62
* Add namespaced package routing tests for all affected ecosystems
Verifies the wildcard routing handles slashes in package names for
npm (@babel/core), Go modules (github.com/stretchr/testify),
OCI images (library/nginx), Conda (conda-forge/numpy), and
Conan (zlib/1.2.13@demo/stable).
* Regenerate swagger docs after route refactor
The swagger annotations for the old per-endpoint handlers were removed
during the wildcard routing refactor. Regenerate to match current state.
2026-04-06 13:07:02 +01:00
|
|
|
func (s *Server) showBrowseSource(w http.ResponseWriter, ecosystem, name, version string) {
|
|
|
|
|
data := BrowseSourceData{
|
|
|
|
|
Ecosystem: ecosystem,
|
|
|
|
|
PackageName: name,
|
|
|
|
|
Version: version,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := s.templates.Render(w, "browse_source", data); err != nil {
|
|
|
|
|
s.logger.Error("failed to render browse source page", "error", err)
|
|
|
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) showComparePage(w http.ResponseWriter, ecosystem, name, versions string) {
|
|
|
|
|
const compareVersionParts = 2
|
|
|
|
|
parts := strings.Split(versions, "...")
|
|
|
|
|
if len(parts) != compareVersionParts {
|
|
|
|
|
http.Error(w, "invalid version format, use: version1...version2", http.StatusBadRequest)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
data := ComparePageData{
|
|
|
|
|
Ecosystem: ecosystem,
|
|
|
|
|
PackageName: name,
|
|
|
|
|
FromVersion: parts[0],
|
|
|
|
|
ToVersion: parts[1],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := s.templates.Render(w, "compare_versions", data); err != nil {
|
|
|
|
|
s.logger.Error("failed to render compare page", "error", err)
|
|
|
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
Add storage backend probe to /health (closes #73) (#119)
* config: add Health.StorageProbeInterval
* metrics: add proxy_health_probe_failures_total counter
* server: add storageProbe with happy-path test
* server: add storageProbe failure-mode tests
* server: add healthCache with TTL, single-flight, transition logging
* server: wire storage probe into /health
* server: update TestHealthEndpoint for JSON; wire healthCache into newTestServer
Also fix Windows file-locking issue in storageProbe: close the reader
explicitly before Delete so the file handle is released prior to os.Remove.
* server: clean up stale comment in storageProbe
* docs: document storage health probe and new metric
* docs: regenerate Swagger for /health JSON response
* server: simplify rc.Close error handling in storageProbe
* server: defer probe cleanup so size/open/read/verify failures don't leak objects
Previously, storageProbe only called Delete on the success path. Any
failure between Store and the final Delete (size mismatch, Open error,
mid-stream read failure, content mismatch) left the probe object orphaned
in the storage backend. With caching disabled and Kubernetes-rate probing,
the leak could accumulate noticeably on backends like S3.
Use a named return + defer to attempt Delete after every successful Store.
The earlier-step failure remains the primary error; Delete failure only
surfaces as step="delete" when nothing else went wrong. Add a table-driven
test that asserts cleanup runs for each non-delete failure path.
Reported by Copilot on #119.
* config: validate health.storage_probe_interval in Config.Validate
The new duration field was only validated at use time in newHealthCache.
The existing codebase already validates other duration fields
(MetadataTTL, DirectServeTTL, Gradle.MaxAge, Gradle.SweepInterval) in
Config.Validate() so misconfiguration fails fast at startup with a
config-key-specific error.
Match that pattern. The parse-at-use code in newHealthCache stays as
a safety net, mirroring the MetadataTTL precedent.
Reported by Copilot on #119.
* docs: lowercase "counter" in metrics table for consistency
Other rows in the table use lowercase type names (counter/gauge/histogram).
Match that style.
Reported by Copilot on #119.
* docs: include size-check step in /health probe description
The probe is write → size-check → read → verify → delete; the
architecture note was missing the size-check step.
Reported by Copilot on #119.
* server: address andrew's review on #119
- Drop unused callerCtx parameter from healthCache.Check (Check is now
parameter-less; the comment-only "accepted for symmetry" justification
wasn't carrying its weight).
- Emit "storage": {"status": "skipped"} on DB short-circuit instead of
omitting the key, so monitors expecting a fixed key set keep working.
- Reject negative storage_probe_interval at config validation time
(previously parsed and silently behaved like "0").
- Extract HealthConfig.Validate to keep Config.Validate under the
gocognit threshold and match the existing GradleBuildCacheConfig pattern.
- README Health Check section: note that /health is intended as a
readiness probe rather than a liveness probe (Check holds a mutex
for up to the 10s probe timeout).
- cmd/proxy/main.go godoc: column-align the new env var with the
surrounding Gradle entries.
Reported by andrew on #119.
2026-05-22 14:14:01 +03:00
|
|
|
// handleHealth responds with a structured JSON health report.
|
|
|
|
|
//
|
2026-03-11 17:18:29 +00:00
|
|
|
// @Summary Health check
|
|
|
|
|
// @Tags meta
|
Add storage backend probe to /health (closes #73) (#119)
* config: add Health.StorageProbeInterval
* metrics: add proxy_health_probe_failures_total counter
* server: add storageProbe with happy-path test
* server: add storageProbe failure-mode tests
* server: add healthCache with TTL, single-flight, transition logging
* server: wire storage probe into /health
* server: update TestHealthEndpoint for JSON; wire healthCache into newTestServer
Also fix Windows file-locking issue in storageProbe: close the reader
explicitly before Delete so the file handle is released prior to os.Remove.
* server: clean up stale comment in storageProbe
* docs: document storage health probe and new metric
* docs: regenerate Swagger for /health JSON response
* server: simplify rc.Close error handling in storageProbe
* server: defer probe cleanup so size/open/read/verify failures don't leak objects
Previously, storageProbe only called Delete on the success path. Any
failure between Store and the final Delete (size mismatch, Open error,
mid-stream read failure, content mismatch) left the probe object orphaned
in the storage backend. With caching disabled and Kubernetes-rate probing,
the leak could accumulate noticeably on backends like S3.
Use a named return + defer to attempt Delete after every successful Store.
The earlier-step failure remains the primary error; Delete failure only
surfaces as step="delete" when nothing else went wrong. Add a table-driven
test that asserts cleanup runs for each non-delete failure path.
Reported by Copilot on #119.
* config: validate health.storage_probe_interval in Config.Validate
The new duration field was only validated at use time in newHealthCache.
The existing codebase already validates other duration fields
(MetadataTTL, DirectServeTTL, Gradle.MaxAge, Gradle.SweepInterval) in
Config.Validate() so misconfiguration fails fast at startup with a
config-key-specific error.
Match that pattern. The parse-at-use code in newHealthCache stays as
a safety net, mirroring the MetadataTTL precedent.
Reported by Copilot on #119.
* docs: lowercase "counter" in metrics table for consistency
Other rows in the table use lowercase type names (counter/gauge/histogram).
Match that style.
Reported by Copilot on #119.
* docs: include size-check step in /health probe description
The probe is write → size-check → read → verify → delete; the
architecture note was missing the size-check step.
Reported by Copilot on #119.
* server: address andrew's review on #119
- Drop unused callerCtx parameter from healthCache.Check (Check is now
parameter-less; the comment-only "accepted for symmetry" justification
wasn't carrying its weight).
- Emit "storage": {"status": "skipped"} on DB short-circuit instead of
omitting the key, so monitors expecting a fixed key set keep working.
- Reject negative storage_probe_interval at config validation time
(previously parsed and silently behaved like "0").
- Extract HealthConfig.Validate to keep Config.Validate under the
gocognit threshold and match the existing GradleBuildCacheConfig pattern.
- README Health Check section: note that /health is intended as a
readiness probe rather than a liveness probe (Check holds a mutex
for up to the 10s probe timeout).
- cmd/proxy/main.go godoc: column-align the new env var with the
surrounding Gradle entries.
Reported by andrew on #119.
2026-05-22 14:14:01 +03:00
|
|
|
// @Produce json
|
|
|
|
|
// @Success 200 {object} HealthResponse
|
|
|
|
|
// @Failure 503 {object} HealthResponse
|
2026-03-11 17:18:29 +00:00
|
|
|
// @Router /health [get]
|
2026-01-20 21:52:44 +00:00
|
|
|
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
Add storage backend probe to /health (closes #73) (#119)
* config: add Health.StorageProbeInterval
* metrics: add proxy_health_probe_failures_total counter
* server: add storageProbe with happy-path test
* server: add storageProbe failure-mode tests
* server: add healthCache with TTL, single-flight, transition logging
* server: wire storage probe into /health
* server: update TestHealthEndpoint for JSON; wire healthCache into newTestServer
Also fix Windows file-locking issue in storageProbe: close the reader
explicitly before Delete so the file handle is released prior to os.Remove.
* server: clean up stale comment in storageProbe
* docs: document storage health probe and new metric
* docs: regenerate Swagger for /health JSON response
* server: simplify rc.Close error handling in storageProbe
* server: defer probe cleanup so size/open/read/verify failures don't leak objects
Previously, storageProbe only called Delete on the success path. Any
failure between Store and the final Delete (size mismatch, Open error,
mid-stream read failure, content mismatch) left the probe object orphaned
in the storage backend. With caching disabled and Kubernetes-rate probing,
the leak could accumulate noticeably on backends like S3.
Use a named return + defer to attempt Delete after every successful Store.
The earlier-step failure remains the primary error; Delete failure only
surfaces as step="delete" when nothing else went wrong. Add a table-driven
test that asserts cleanup runs for each non-delete failure path.
Reported by Copilot on #119.
* config: validate health.storage_probe_interval in Config.Validate
The new duration field was only validated at use time in newHealthCache.
The existing codebase already validates other duration fields
(MetadataTTL, DirectServeTTL, Gradle.MaxAge, Gradle.SweepInterval) in
Config.Validate() so misconfiguration fails fast at startup with a
config-key-specific error.
Match that pattern. The parse-at-use code in newHealthCache stays as
a safety net, mirroring the MetadataTTL precedent.
Reported by Copilot on #119.
* docs: lowercase "counter" in metrics table for consistency
Other rows in the table use lowercase type names (counter/gauge/histogram).
Match that style.
Reported by Copilot on #119.
* docs: include size-check step in /health probe description
The probe is write → size-check → read → verify → delete; the
architecture note was missing the size-check step.
Reported by Copilot on #119.
* server: address andrew's review on #119
- Drop unused callerCtx parameter from healthCache.Check (Check is now
parameter-less; the comment-only "accepted for symmetry" justification
wasn't carrying its weight).
- Emit "storage": {"status": "skipped"} on DB short-circuit instead of
omitting the key, so monitors expecting a fixed key set keep working.
- Reject negative storage_probe_interval at config validation time
(previously parsed and silently behaved like "0").
- Extract HealthConfig.Validate to keep Config.Validate under the
gocognit threshold and match the existing GradleBuildCacheConfig pattern.
- README Health Check section: note that /health is intended as a
readiness probe rather than a liveness probe (Check holds a mutex
for up to the 10s probe timeout).
- cmd/proxy/main.go godoc: column-align the new env var with the
surrounding Gradle entries.
Reported by andrew on #119.
2026-05-22 14:14:01 +03:00
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
|
|
|
|
|
resp := HealthResponse{Status: "ok", Checks: map[string]HealthCheck{}}
|
|
|
|
|
|
|
|
|
|
// Database check (short-circuit; do not waste a storage probe call when DB is down).
|
|
|
|
|
// On DB failure the storage entry reports "skipped" rather than being omitted so
|
|
|
|
|
// the response always carries the same key set for monitors that expect it.
|
2026-01-20 21:52:44 +00:00
|
|
|
if _, err := s.db.SchemaVersion(); err != nil {
|
Add storage backend probe to /health (closes #73) (#119)
* config: add Health.StorageProbeInterval
* metrics: add proxy_health_probe_failures_total counter
* server: add storageProbe with happy-path test
* server: add storageProbe failure-mode tests
* server: add healthCache with TTL, single-flight, transition logging
* server: wire storage probe into /health
* server: update TestHealthEndpoint for JSON; wire healthCache into newTestServer
Also fix Windows file-locking issue in storageProbe: close the reader
explicitly before Delete so the file handle is released prior to os.Remove.
* server: clean up stale comment in storageProbe
* docs: document storage health probe and new metric
* docs: regenerate Swagger for /health JSON response
* server: simplify rc.Close error handling in storageProbe
* server: defer probe cleanup so size/open/read/verify failures don't leak objects
Previously, storageProbe only called Delete on the success path. Any
failure between Store and the final Delete (size mismatch, Open error,
mid-stream read failure, content mismatch) left the probe object orphaned
in the storage backend. With caching disabled and Kubernetes-rate probing,
the leak could accumulate noticeably on backends like S3.
Use a named return + defer to attempt Delete after every successful Store.
The earlier-step failure remains the primary error; Delete failure only
surfaces as step="delete" when nothing else went wrong. Add a table-driven
test that asserts cleanup runs for each non-delete failure path.
Reported by Copilot on #119.
* config: validate health.storage_probe_interval in Config.Validate
The new duration field was only validated at use time in newHealthCache.
The existing codebase already validates other duration fields
(MetadataTTL, DirectServeTTL, Gradle.MaxAge, Gradle.SweepInterval) in
Config.Validate() so misconfiguration fails fast at startup with a
config-key-specific error.
Match that pattern. The parse-at-use code in newHealthCache stays as
a safety net, mirroring the MetadataTTL precedent.
Reported by Copilot on #119.
* docs: lowercase "counter" in metrics table for consistency
Other rows in the table use lowercase type names (counter/gauge/histogram).
Match that style.
Reported by Copilot on #119.
* docs: include size-check step in /health probe description
The probe is write → size-check → read → verify → delete; the
architecture note was missing the size-check step.
Reported by Copilot on #119.
* server: address andrew's review on #119
- Drop unused callerCtx parameter from healthCache.Check (Check is now
parameter-less; the comment-only "accepted for symmetry" justification
wasn't carrying its weight).
- Emit "storage": {"status": "skipped"} on DB short-circuit instead of
omitting the key, so monitors expecting a fixed key set keep working.
- Reject negative storage_probe_interval at config validation time
(previously parsed and silently behaved like "0").
- Extract HealthConfig.Validate to keep Config.Validate under the
gocognit threshold and match the existing GradleBuildCacheConfig pattern.
- README Health Check section: note that /health is intended as a
readiness probe rather than a liveness probe (Check holds a mutex
for up to the 10s probe timeout).
- cmd/proxy/main.go godoc: column-align the new env var with the
surrounding Gradle entries.
Reported by andrew on #119.
2026-05-22 14:14:01 +03:00
|
|
|
resp.Status = "error"
|
|
|
|
|
resp.Checks["database"] = HealthCheck{Status: "error", Error: err.Error()}
|
|
|
|
|
resp.Checks["storage"] = HealthCheck{Status: "skipped"}
|
|
|
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
|
|
|
_ = json.NewEncoder(w).Encode(resp)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
resp.Checks["database"] = HealthCheck{Status: "ok"}
|
|
|
|
|
|
|
|
|
|
// Storage probe (via cache).
|
|
|
|
|
if err := s.healthCache.Check(); err != nil {
|
|
|
|
|
resp.Status = "error"
|
|
|
|
|
sc := HealthCheck{Status: "error", Error: err.Error()}
|
|
|
|
|
var pe *probeError
|
|
|
|
|
if errors.As(err, &pe) {
|
|
|
|
|
sc.Step = pe.step
|
|
|
|
|
}
|
|
|
|
|
resp.Checks["storage"] = sc
|
2026-01-20 21:52:44 +00:00
|
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
Add storage backend probe to /health (closes #73) (#119)
* config: add Health.StorageProbeInterval
* metrics: add proxy_health_probe_failures_total counter
* server: add storageProbe with happy-path test
* server: add storageProbe failure-mode tests
* server: add healthCache with TTL, single-flight, transition logging
* server: wire storage probe into /health
* server: update TestHealthEndpoint for JSON; wire healthCache into newTestServer
Also fix Windows file-locking issue in storageProbe: close the reader
explicitly before Delete so the file handle is released prior to os.Remove.
* server: clean up stale comment in storageProbe
* docs: document storage health probe and new metric
* docs: regenerate Swagger for /health JSON response
* server: simplify rc.Close error handling in storageProbe
* server: defer probe cleanup so size/open/read/verify failures don't leak objects
Previously, storageProbe only called Delete on the success path. Any
failure between Store and the final Delete (size mismatch, Open error,
mid-stream read failure, content mismatch) left the probe object orphaned
in the storage backend. With caching disabled and Kubernetes-rate probing,
the leak could accumulate noticeably on backends like S3.
Use a named return + defer to attempt Delete after every successful Store.
The earlier-step failure remains the primary error; Delete failure only
surfaces as step="delete" when nothing else went wrong. Add a table-driven
test that asserts cleanup runs for each non-delete failure path.
Reported by Copilot on #119.
* config: validate health.storage_probe_interval in Config.Validate
The new duration field was only validated at use time in newHealthCache.
The existing codebase already validates other duration fields
(MetadataTTL, DirectServeTTL, Gradle.MaxAge, Gradle.SweepInterval) in
Config.Validate() so misconfiguration fails fast at startup with a
config-key-specific error.
Match that pattern. The parse-at-use code in newHealthCache stays as
a safety net, mirroring the MetadataTTL precedent.
Reported by Copilot on #119.
* docs: lowercase "counter" in metrics table for consistency
Other rows in the table use lowercase type names (counter/gauge/histogram).
Match that style.
Reported by Copilot on #119.
* docs: include size-check step in /health probe description
The probe is write → size-check → read → verify → delete; the
architecture note was missing the size-check step.
Reported by Copilot on #119.
* server: address andrew's review on #119
- Drop unused callerCtx parameter from healthCache.Check (Check is now
parameter-less; the comment-only "accepted for symmetry" justification
wasn't carrying its weight).
- Emit "storage": {"status": "skipped"} on DB short-circuit instead of
omitting the key, so monitors expecting a fixed key set keep working.
- Reject negative storage_probe_interval at config validation time
(previously parsed and silently behaved like "0").
- Extract HealthConfig.Validate to keep Config.Validate under the
gocognit threshold and match the existing GradleBuildCacheConfig pattern.
- README Health Check section: note that /health is intended as a
readiness probe rather than a liveness probe (Check holds a mutex
for up to the 10s probe timeout).
- cmd/proxy/main.go godoc: column-align the new env var with the
surrounding Gradle entries.
Reported by andrew on #119.
2026-05-22 14:14:01 +03:00
|
|
|
_ = json.NewEncoder(w).Encode(resp)
|
2026-01-20 21:52:44 +00:00
|
|
|
return
|
|
|
|
|
}
|
Add storage backend probe to /health (closes #73) (#119)
* config: add Health.StorageProbeInterval
* metrics: add proxy_health_probe_failures_total counter
* server: add storageProbe with happy-path test
* server: add storageProbe failure-mode tests
* server: add healthCache with TTL, single-flight, transition logging
* server: wire storage probe into /health
* server: update TestHealthEndpoint for JSON; wire healthCache into newTestServer
Also fix Windows file-locking issue in storageProbe: close the reader
explicitly before Delete so the file handle is released prior to os.Remove.
* server: clean up stale comment in storageProbe
* docs: document storage health probe and new metric
* docs: regenerate Swagger for /health JSON response
* server: simplify rc.Close error handling in storageProbe
* server: defer probe cleanup so size/open/read/verify failures don't leak objects
Previously, storageProbe only called Delete on the success path. Any
failure between Store and the final Delete (size mismatch, Open error,
mid-stream read failure, content mismatch) left the probe object orphaned
in the storage backend. With caching disabled and Kubernetes-rate probing,
the leak could accumulate noticeably on backends like S3.
Use a named return + defer to attempt Delete after every successful Store.
The earlier-step failure remains the primary error; Delete failure only
surfaces as step="delete" when nothing else went wrong. Add a table-driven
test that asserts cleanup runs for each non-delete failure path.
Reported by Copilot on #119.
* config: validate health.storage_probe_interval in Config.Validate
The new duration field was only validated at use time in newHealthCache.
The existing codebase already validates other duration fields
(MetadataTTL, DirectServeTTL, Gradle.MaxAge, Gradle.SweepInterval) in
Config.Validate() so misconfiguration fails fast at startup with a
config-key-specific error.
Match that pattern. The parse-at-use code in newHealthCache stays as
a safety net, mirroring the MetadataTTL precedent.
Reported by Copilot on #119.
* docs: lowercase "counter" in metrics table for consistency
Other rows in the table use lowercase type names (counter/gauge/histogram).
Match that style.
Reported by Copilot on #119.
* docs: include size-check step in /health probe description
The probe is write → size-check → read → verify → delete; the
architecture note was missing the size-check step.
Reported by Copilot on #119.
* server: address andrew's review on #119
- Drop unused callerCtx parameter from healthCache.Check (Check is now
parameter-less; the comment-only "accepted for symmetry" justification
wasn't carrying its weight).
- Emit "storage": {"status": "skipped"} on DB short-circuit instead of
omitting the key, so monitors expecting a fixed key set keep working.
- Reject negative storage_probe_interval at config validation time
(previously parsed and silently behaved like "0").
- Extract HealthConfig.Validate to keep Config.Validate under the
gocognit threshold and match the existing GradleBuildCacheConfig pattern.
- README Health Check section: note that /health is intended as a
readiness probe rather than a liveness probe (Check holds a mutex
for up to the 10s probe timeout).
- cmd/proxy/main.go godoc: column-align the new env var with the
surrounding Gradle entries.
Reported by andrew on #119.
2026-05-22 14:14:01 +03:00
|
|
|
resp.Checks["storage"] = HealthCheck{Status: "ok"}
|
2026-01-20 21:52:44 +00:00
|
|
|
|
|
|
|
|
w.WriteHeader(http.StatusOK)
|
Add storage backend probe to /health (closes #73) (#119)
* config: add Health.StorageProbeInterval
* metrics: add proxy_health_probe_failures_total counter
* server: add storageProbe with happy-path test
* server: add storageProbe failure-mode tests
* server: add healthCache with TTL, single-flight, transition logging
* server: wire storage probe into /health
* server: update TestHealthEndpoint for JSON; wire healthCache into newTestServer
Also fix Windows file-locking issue in storageProbe: close the reader
explicitly before Delete so the file handle is released prior to os.Remove.
* server: clean up stale comment in storageProbe
* docs: document storage health probe and new metric
* docs: regenerate Swagger for /health JSON response
* server: simplify rc.Close error handling in storageProbe
* server: defer probe cleanup so size/open/read/verify failures don't leak objects
Previously, storageProbe only called Delete on the success path. Any
failure between Store and the final Delete (size mismatch, Open error,
mid-stream read failure, content mismatch) left the probe object orphaned
in the storage backend. With caching disabled and Kubernetes-rate probing,
the leak could accumulate noticeably on backends like S3.
Use a named return + defer to attempt Delete after every successful Store.
The earlier-step failure remains the primary error; Delete failure only
surfaces as step="delete" when nothing else went wrong. Add a table-driven
test that asserts cleanup runs for each non-delete failure path.
Reported by Copilot on #119.
* config: validate health.storage_probe_interval in Config.Validate
The new duration field was only validated at use time in newHealthCache.
The existing codebase already validates other duration fields
(MetadataTTL, DirectServeTTL, Gradle.MaxAge, Gradle.SweepInterval) in
Config.Validate() so misconfiguration fails fast at startup with a
config-key-specific error.
Match that pattern. The parse-at-use code in newHealthCache stays as
a safety net, mirroring the MetadataTTL precedent.
Reported by Copilot on #119.
* docs: lowercase "counter" in metrics table for consistency
Other rows in the table use lowercase type names (counter/gauge/histogram).
Match that style.
Reported by Copilot on #119.
* docs: include size-check step in /health probe description
The probe is write → size-check → read → verify → delete; the
architecture note was missing the size-check step.
Reported by Copilot on #119.
* server: address andrew's review on #119
- Drop unused callerCtx parameter from healthCache.Check (Check is now
parameter-less; the comment-only "accepted for symmetry" justification
wasn't carrying its weight).
- Emit "storage": {"status": "skipped"} on DB short-circuit instead of
omitting the key, so monitors expecting a fixed key set keep working.
- Reject negative storage_probe_interval at config validation time
(previously parsed and silently behaved like "0").
- Extract HealthConfig.Validate to keep Config.Validate under the
gocognit threshold and match the existing GradleBuildCacheConfig pattern.
- README Health Check section: note that /health is intended as a
readiness probe rather than a liveness probe (Check holds a mutex
for up to the 10s probe timeout).
- cmd/proxy/main.go godoc: column-align the new env var with the
surrounding Gradle entries.
Reported by andrew on #119.
2026-05-22 14:14:01 +03:00
|
|
|
_ = json.NewEncoder(w).Encode(resp)
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// StatsResponse contains cache statistics.
|
|
|
|
|
type StatsResponse struct {
|
|
|
|
|
CachedArtifacts int64 `json:"cached_artifacts"`
|
|
|
|
|
TotalSize int64 `json:"total_size_bytes"`
|
|
|
|
|
TotalSizeHuman string `json:"total_size"`
|
2026-04-03 14:06:51 +01:00
|
|
|
StorageURL string `json:"storage_url"`
|
2026-01-20 21:52:44 +00:00
|
|
|
DatabasePath string `json:"database_path"`
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-11 17:18:29 +00:00
|
|
|
// handleStats returns cache statistics.
|
|
|
|
|
// @Summary Cache statistics
|
|
|
|
|
// @Tags meta
|
|
|
|
|
// @Produce json
|
|
|
|
|
// @Success 200 {object} StatsResponse
|
2026-05-03 09:42:03 +01:00
|
|
|
// @Failure 500 {object} ErrorResponse
|
2026-03-11 17:18:29 +00:00
|
|
|
// @Router /stats [get]
|
2026-01-20 21:52:44 +00:00
|
|
|
func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
ctx := r.Context()
|
|
|
|
|
|
|
|
|
|
count, err := s.db.GetCachedArtifactCount()
|
|
|
|
|
if err != nil {
|
2026-05-03 09:42:03 +01:00
|
|
|
internalError(w, "failed to get artifact count")
|
2026-01-20 21:52:44 +00:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
size, err := s.db.GetTotalCacheSize()
|
|
|
|
|
if err != nil {
|
2026-05-03 09:42:03 +01:00
|
|
|
internalError(w, "failed to get cache size")
|
2026-01-20 21:52:44 +00:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_ = ctx // Could use for storage.UsedSpace if needed
|
|
|
|
|
|
|
|
|
|
stats := StatsResponse{
|
|
|
|
|
CachedArtifacts: count,
|
|
|
|
|
TotalSize: size,
|
|
|
|
|
TotalSizeHuman: formatSize(size),
|
2026-04-03 14:06:51 +01:00
|
|
|
StorageURL: s.storage.URL(),
|
2026-01-20 21:52:44 +00:00
|
|
|
DatabasePath: s.cfg.Database.Path,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
_ = json.NewEncoder(w).Encode(stats)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func formatSize(bytes int64) string {
|
|
|
|
|
const unit = 1024
|
|
|
|
|
if bytes < unit {
|
|
|
|
|
return fmt.Sprintf("%d B", bytes)
|
|
|
|
|
}
|
|
|
|
|
div, exp := int64(unit), 0
|
|
|
|
|
for n := bytes / unit; n >= unit; n /= unit {
|
|
|
|
|
div *= unit
|
|
|
|
|
exp++
|
|
|
|
|
}
|
|
|
|
|
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func formatTimeAgo(t time.Time) string {
|
|
|
|
|
if t.IsZero() {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
d := time.Since(t)
|
|
|
|
|
switch {
|
|
|
|
|
case d < time.Minute:
|
|
|
|
|
return "just now"
|
|
|
|
|
case d < time.Hour:
|
|
|
|
|
m := int(d.Minutes())
|
|
|
|
|
if m == 1 {
|
|
|
|
|
return "1 min ago"
|
|
|
|
|
}
|
|
|
|
|
return fmt.Sprintf("%d mins ago", m)
|
2026-03-18 10:59:29 +00:00
|
|
|
case d < hoursPerDay*time.Hour:
|
2026-01-20 21:52:44 +00:00
|
|
|
h := int(d.Hours())
|
|
|
|
|
if h == 1 {
|
|
|
|
|
return "1 hour ago"
|
|
|
|
|
}
|
|
|
|
|
return fmt.Sprintf("%d hours ago", h)
|
2026-03-18 10:59:29 +00:00
|
|
|
case d < 7*hoursPerDay*time.Hour:
|
|
|
|
|
days := int(d.Hours() / hoursPerDay)
|
2026-01-20 21:52:44 +00:00
|
|
|
if days == 1 {
|
|
|
|
|
return "1 day ago"
|
|
|
|
|
}
|
|
|
|
|
return fmt.Sprintf("%d days ago", days)
|
|
|
|
|
default:
|
|
|
|
|
return t.Format("Jan 2")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-29 19:35:15 +00:00
|
|
|
// categorizeLicenseCSS returns the CSS class suffix for a license category using the spdx module.
|
|
|
|
|
func categorizeLicenseCSS(license string) string {
|
|
|
|
|
if license == "" {
|
2026-03-18 10:59:29 +00:00
|
|
|
return licenseCategoryUnknown
|
2026-01-29 19:35:15 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if spdx.HasCopyleft(license) {
|
|
|
|
|
return "copyleft"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if spdx.IsFullyPermissive(license) {
|
|
|
|
|
return "permissive"
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 10:59:29 +00:00
|
|
|
return licenseCategoryUnknown
|
2026-01-29 19:35:15 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-03 22:40:23 +00:00
|
|
|
// categorizeLicense is a helper that handles sql.NullString.
|
|
|
|
|
func categorizeLicense(license sql.NullString) string {
|
|
|
|
|
if !license.Valid {
|
2026-03-18 10:59:29 +00:00
|
|
|
return licenseCategoryUnknown
|
2026-02-03 22:40:23 +00:00
|
|
|
}
|
|
|
|
|
return categorizeLicenseCSS(license.String)
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
// responseWriter wraps http.ResponseWriter to capture status code.
|
|
|
|
|
type responseWriter struct {
|
|
|
|
|
http.ResponseWriter
|
|
|
|
|
status int
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (rw *responseWriter) WriteHeader(code int) {
|
|
|
|
|
rw.status = code
|
|
|
|
|
rw.ResponseWriter.WriteHeader(code)
|
|
|
|
|
}
|