pkg-proxy/internal/server/server.go

930 lines
27 KiB
Go
Raw Permalink Normal View History

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
// - /nuget/* - NuGet V3 API protocol
// - /composer/* - Composer/Packagist protocol
// - /conan/* - Conan C/C++ protocol
// - /conda/* - Conda/Anaconda protocol
// - /cran/* - CRAN (R) protocol
// - /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:
// - /health - Health check endpoint
// - /stats - Cache statistics (JSON)
2026-03-11 17:18:29 +00:00
// - /openapi.json - OpenAPI spec (JSON)
2026-02-03 22:40:23 +00:00
// - /packages - List all cached packages (HTML)
// - /search - Search packages (HTML)
//
// 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"
"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"
"github.com/git-pkgs/proxy/internal/cooldown"
2026-01-20 21:52:44 +00:00
"github.com/git-pkgs/proxy/internal/database"
"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"
"github.com/git-pkgs/proxy/internal/mirror"
2026-01-20 21:52:44 +00:00
"github.com/git-pkgs/proxy/internal/storage"
"github.com/git-pkgs/purl"
"github.com/git-pkgs/registries/fetch"
"github.com/git-pkgs/spdx"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
2026-01-20 21:52:44 +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
cancel context.CancelFunc
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
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
storageURL := cfg.Storage.URL
if storageURL == "" {
// Fall back to file:// with Path
storageURL = "file://" + cfg.Storage.Path //nolint:staticcheck // backwards compat
}
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)
}
// 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)
}
2026-01-20 21:52:44 +00:00
return &Server{
2026-02-03 22:40:23 +00:00
cfg: cfg,
db: db,
storage: store,
logger: logger,
templates: &Templates{},
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
baseFetcher := fetch.NewFetcher(fetch.WithAuthFunc(s.authForURL))
fetcher := fetch.NewCircuitBreakerFetcher(baseFetcher)
resolver := fetch.NewResolver()
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)
proxy.Cooldown = cd
proxy.CacheMetadata = s.cfg.CacheMetadata
proxy.MetadataTTL = s.cfg.ParseMetadataTTL()
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)
mavenHandler := handler.NewMavenHandler(proxy, s.cfg.BaseURL)
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)
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()))
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()))
r.Mount("/v2", http.StripPrefix("/v2", containerHandler.Routes()))
r.Mount("/debian", http.StripPrefix("/debian", debianHandler.Routes()))
r.Mount("/rpm", http.StripPrefix("/rpm", rpmHandler.Routes()))
// Health, stats, and static 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)
})
r.Mount("/static", http.StripPrefix("/static/", staticHandler()))
r.Get("/", s.handleRoot)
r.Get("/install", s.handleInstall)
r.Get("/search", s.handleSearch)
r.Get("/packages", s.handlePackagesList)
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("/package/{ecosystem}/*", s.handlePackagePath)
2026-01-20 21:52:44 +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)
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
// Archive browsing and comparison endpoints also use wildcard for namespaced packages
r.Get("/api/browse/{ecosystem}/*", s.handleBrowsePath)
r.Get("/api/compare/{ecosystem}/*", s.handleComparePath)
2026-01-20 21:52:44 +00:00
// Start background context (used by mirror jobs and cleanup)
bgCtx, bgCancel := context.WithCancel(context.Background())
s.cancel = bgCancel
// 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-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,
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,
"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-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")
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))
}
}
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
}
// 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{}
}
// 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
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
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,
},
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 {
pkgInfo := PackageInfo{
2026-01-20 21:52:44 +00:00
Ecosystem: p.Ecosystem,
Name: p.Name,
Hits: p.Hits,
Size: formatSize(p.Size),
}
// 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 {
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),
}
// 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 == "" {
http.Redirect(w, r, "/", http.StatusSeeOther)
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 == "" {
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, "*")
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
}
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
}
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{}
}
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)
}
}
2026-03-11 17:18:29 +00:00
// handleHealth responds with a simple health check.
// @Summary Health check
// @Tags meta
// @Produce plain
// @Success 200 {string} string
// @Failure 503 {string} string
// @Router /health [get]
2026-01-20 21:52:44 +00:00
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
// Check database connectivity
if _, err := s.db.SchemaVersion(); err != nil {
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = fmt.Fprint(w, "database error")
2026-01-20 21:52:44 +00:00
return
}
w.WriteHeader(http.StatusOK)
_, _ = fmt.Fprint(w, "ok")
}
// StatsResponse contains cache statistics.
type StatsResponse struct {
CachedArtifacts int64 `json:"cached_artifacts"`
TotalSize int64 `json:"total_size_bytes"`
TotalSizeHuman string `json:"total_size"`
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
// @Failure 500 {string} string
// @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 {
http.Error(w, "failed to get artifact count", http.StatusInternalServerError)
return
}
size, err := s.db.GetTotalCacheSize()
if err != nil {
http.Error(w, "failed to get cache size", http.StatusInternalServerError)
return
}
_ = ctx // Could use for storage.UsedSpace if needed
stats := StatsResponse{
CachedArtifacts: count,
TotalSize: size,
TotalSizeHuman: formatSize(size),
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)
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)
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")
}
}
// categorizeLicenseCSS returns the CSS class suffix for a license category using the spdx module.
func categorizeLicenseCSS(license string) string {
if license == "" {
return licenseCategoryUnknown
}
if spdx.HasCopyleft(license) {
return "copyleft"
}
if spdx.IsFullyPermissive(license) {
return "permissive"
}
return licenseCategoryUnknown
}
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 {
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)
}