This commit is contained in:
2026-01-02 04:02:02 +00:00
commit 3b8e171fdb
117 changed files with 21570 additions and 0 deletions
+393
View File
@@ -0,0 +1,393 @@
package app
import (
"context"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/lukaszraczylo/gohoarder/pkg/analytics"
"github.com/lukaszraczylo/gohoarder/pkg/auth"
"github.com/lukaszraczylo/gohoarder/pkg/cache"
"github.com/lukaszraczylo/gohoarder/pkg/cdn"
"github.com/lukaszraczylo/gohoarder/pkg/config"
"github.com/lukaszraczylo/gohoarder/pkg/health"
"github.com/lukaszraczylo/gohoarder/pkg/lock"
"github.com/lukaszraczylo/gohoarder/pkg/logger"
"github.com/lukaszraczylo/gohoarder/pkg/metadata"
metafile "github.com/lukaszraczylo/gohoarder/pkg/metadata/file"
metasqlite "github.com/lukaszraczylo/gohoarder/pkg/metadata/sqlite"
"github.com/lukaszraczylo/gohoarder/pkg/metrics"
"github.com/lukaszraczylo/gohoarder/pkg/network"
"github.com/lukaszraczylo/gohoarder/pkg/prewarming"
"github.com/lukaszraczylo/gohoarder/pkg/proxy/goproxy"
"github.com/lukaszraczylo/gohoarder/pkg/proxy/npm"
"github.com/lukaszraczylo/gohoarder/pkg/proxy/pypi"
"github.com/lukaszraczylo/gohoarder/pkg/scanner"
"github.com/lukaszraczylo/gohoarder/pkg/storage"
"github.com/lukaszraczylo/gohoarder/pkg/storage/filesystem"
"github.com/lukaszraczylo/gohoarder/pkg/websocket"
"github.com/rs/zerolog/log"
)
// App represents the main application
type App struct {
config *config.Config
server *http.Server
healthChecker *health.Checker
cache *cache.Manager
storage storage.StorageBackend
metadata metadata.Store
authManager *auth.Manager
networkClient *network.Client
scanManager *scanner.Manager
rescanWorker *scanner.RescanWorker
analyticsEngine *analytics.Engine
wsServer *websocket.Server
prewarmWorker *prewarming.Worker
lockManager *lock.Manager
cdnMiddleware *cdn.Middleware
}
// New creates a new application instance
func New(cfg *config.Config) (*App, error) {
app := &App{
config: cfg,
}
// Initialize components
if err := app.initializeComponents(); err != nil {
return nil, err
}
// Setup HTTP server and routes
if err := app.setupServer(); err != nil {
return nil, err
}
return app, nil
}
// initializeComponents initializes all application components
func (a *App) initializeComponents() error {
var err error
// Initialize storage backend
log.Info().Str("backend", a.config.Storage.Backend).Msg("Initializing storage backend")
switch a.config.Storage.Backend {
case "filesystem":
a.storage, err = filesystem.New(a.config.Storage.Path, a.config.Cache.MaxSizeBytes)
default:
a.storage, err = filesystem.New(a.config.Storage.Path, a.config.Cache.MaxSizeBytes)
}
if err != nil {
return fmt.Errorf("failed to initialize storage: %w", err)
}
// Initialize metadata store
log.Info().Str("backend", a.config.Metadata.Backend).Msg("Initializing metadata store")
switch a.config.Metadata.Backend {
case "sqlite":
a.metadata, err = metasqlite.New(metasqlite.Config{
Path: a.config.Metadata.Connection,
})
case "file":
a.metadata, err = metafile.New(metafile.Config{
Path: a.config.Metadata.Connection,
})
default:
a.metadata, err = metasqlite.New(metasqlite.Config{
Path: "gohoarder.db",
})
}
if err != nil {
return fmt.Errorf("failed to initialize metadata: %w", err)
}
// Initialize scanner manager first (before cache)
log.Info().Msg("Initializing security scanner")
a.scanManager, err = scanner.New(a.config.Security, a.metadata)
if err != nil {
return fmt.Errorf("failed to initialize scanner: %w", err)
}
// Initialize cache manager with scanner
log.Info().Msg("Initializing cache manager")
a.cache, err = cache.New(a.storage, a.metadata, a.scanManager, cache.Config{
DefaultTTL: a.config.Cache.DefaultTTL,
CleanupInterval: 5 * time.Minute,
})
if err != nil {
return fmt.Errorf("failed to initialize cache: %w", err)
}
// Initialize network client
log.Info().Msg("Initializing network client")
a.networkClient = network.NewClient(network.Config{
Timeout: 5 * time.Minute,
MaxRetries: 3,
RetryDelay: 1 * time.Second,
RateLimit: 100,
RateBurst: 10,
CircuitBreaker: network.CircuitBreakerConfig{
Enabled: true,
FailureThreshold: 5,
SuccessThreshold: 2,
Timeout: 30 * time.Second,
},
UserAgent: "GoHoarder/1.0",
})
// Initialize authentication manager
log.Info().Msg("Initializing authentication manager")
a.authManager = auth.New()
// Initialize rescan worker if enabled
if a.config.Security.Enabled && a.config.Security.RescanInterval > 0 {
log.Info().Dur("interval", a.config.Security.RescanInterval).Msg("Initializing package rescan worker")
a.rescanWorker = scanner.NewRescanWorker(a.scanManager, a.metadata, a.config.Security.RescanInterval)
}
// Initialize analytics engine
log.Info().Msg("Initializing analytics engine")
a.analyticsEngine = analytics.NewEngine(analytics.Config{
MaxEvents: 10000,
FlushInterval: 5 * time.Minute,
})
// Initialize WebSocket server
log.Info().Msg("Initializing WebSocket server")
a.wsServer = websocket.NewServer(websocket.Config{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
return true // Allow all origins in development
},
})
// Initialize pre-warming worker
log.Info().Msg("Initializing pre-warming worker")
a.prewarmWorker = prewarming.NewWorker(prewarming.Config{
Enabled: false, // Disabled by default
Interval: 1 * time.Hour,
MaxConcurrent: 5,
CacheManager: a.cache,
Analytics: a.analyticsEngine,
NetworkClient: a.networkClient,
})
// Initialize CDN middleware
log.Info().Msg("Initializing CDN middleware")
a.cdnMiddleware = cdn.NewMiddleware(cdn.Config{
DefaultCacheControl: cdn.CacheControl{
Public: true,
MaxAge: 3600,
SMaxAge: 7200,
},
EnableETag: true,
EnableVary: true,
})
// Initialize health checker
a.healthChecker = health.New()
a.healthChecker.AddCheck("storage", func(ctx context.Context) (health.Status, string) {
if err := a.storage.Health(ctx); err != nil {
return health.StatusUnhealthy, err.Error()
}
return health.StatusHealthy, ""
})
a.healthChecker.AddCheck("metadata", func(ctx context.Context) (health.Status, string) {
if err := a.metadata.Health(ctx); err != nil {
return health.StatusUnhealthy, err.Error()
}
return health.StatusHealthy, ""
})
a.healthChecker.AddCheck("cache", func(ctx context.Context) (health.Status, string) {
return health.StatusHealthy, "" // Cache is always healthy if initialized
})
a.healthChecker.AddCheck("scanner", func(ctx context.Context) (health.Status, string) {
if a.config.Security.Enabled {
if err := a.scanManager.Health(ctx); err != nil {
return health.StatusUnhealthy, err.Error()
}
}
return health.StatusHealthy, ""
})
log.Info().Msg("All components initialized successfully")
return nil
}
// setupServer sets up the HTTP server and routes
func (a *App) setupServer() error {
mux := http.NewServeMux()
// Health and metrics endpoints
mux.HandleFunc("/health", a.healthChecker.HealthHandler())
mux.HandleFunc("/health/ready", a.healthChecker.ReadyHandler())
mux.Handle("/metrics", metrics.Handler())
// WebSocket endpoint
mux.HandleFunc("/ws", a.wsServer.HandleWebSocket)
// API endpoints
mux.HandleFunc("/api/packages/", a.handlePackages) // Handles packages and vulnerabilities
mux.HandleFunc("/api/stats", a.handleStats)
mux.HandleFunc("/api/info", a.handleInfo)
// Admin endpoints (bypass management)
mux.HandleFunc("/api/admin/bypasses/", a.handleBypassByID) // Must come before /api/admin/bypasses
mux.HandleFunc("/api/admin/bypasses", a.handleAdminBypasses)
// Proxy handlers
goProxyHandler := goproxy.New(a.cache, a.networkClient, goproxy.Config{
Upstream: "https://proxy.golang.org",
SumDBURL: "https://sum.golang.org",
})
mux.Handle("/go/", http.StripPrefix("/go", goProxyHandler))
npmProxyHandler := npm.New(a.cache, a.networkClient, npm.Config{
Upstream: "https://registry.npmjs.org",
})
mux.Handle("/npm/", http.StripPrefix("/npm", npmProxyHandler))
pypiProxyHandler := pypi.New(a.cache, a.networkClient, pypi.Config{
Upstream: "https://pypi.org/simple",
})
mux.Handle("/pypi/", http.StripPrefix("/pypi", pypiProxyHandler))
// Serve frontend static files
frontendDir := "frontend/dist"
if _, err := os.Stat(frontendDir); err == nil {
log.Info().Str("dir", frontendDir).Msg("Serving frontend static files")
fs := http.FileServer(http.Dir(frontendDir))
mux.Handle("/", fs)
} else {
log.Warn().Msg("Frontend dist directory not found, frontend won't be served")
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, `
<html>
<head><title>GoHoarder</title></head>
<body>
<h1>GoHoarder Package Cache Proxy</h1>
<p>Frontend not built. Build with: <code>cd frontend && npm run build</code></p>
<h2>Available Endpoints:</h2>
<ul>
<li><a href="/health">Health Check</a></li>
<li><a href="/metrics">Metrics</a></li>
<li><a href="/api/stats">Statistics API</a></li>
</ul>
</body>
</html>
`)
})
}
// Wrap with logging middleware
handler := logger.Middleware(mux)
// Create HTTP server
a.server = &http.Server{
Addr: fmt.Sprintf("%s:%d", a.config.Server.Host, a.config.Server.Port),
Handler: handler,
ReadTimeout: a.config.Server.ReadTimeout,
WriteTimeout: a.config.Server.WriteTimeout,
}
log.Info().
Str("addr", a.server.Addr).
Msg("HTTP server configured")
return nil
}
// Run starts the application
func (a *App) Run() error {
ctx := context.Background()
// Start WebSocket server
a.wsServer.Start(ctx)
// Start pre-warming worker
a.prewarmWorker.Start(ctx)
// Start rescan worker if enabled
if a.rescanWorker != nil {
go a.rescanWorker.Start(ctx)
}
// Start HTTP server in goroutine
errChan := make(chan error, 1)
go func() {
log.Info().
Str("addr", a.server.Addr).
Msg("Starting HTTP server")
if err := a.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
errChan <- err
}
}()
// Wait for interrupt signal
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
select {
case err := <-errChan:
return fmt.Errorf("server error: %w", err)
case sig := <-sigChan:
log.Info().
Str("signal", sig.String()).
Msg("Shutdown signal received")
}
// Graceful shutdown
return a.Shutdown()
}
// Shutdown gracefully shuts down the application
func (a *App) Shutdown() error {
log.Info().Msg("Starting graceful shutdown")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Stop HTTP server
if err := a.server.Shutdown(ctx); err != nil {
log.Error().Err(err).Msg("Error shutting down HTTP server")
}
// Stop pre-warming worker
a.prewarmWorker.Stop()
// Stop rescan worker if running
if a.rescanWorker != nil {
a.rescanWorker.Stop()
}
// Close analytics engine
a.analyticsEngine.Close()
// Close storage
if err := a.storage.Close(); err != nil {
log.Error().Err(err).Msg("Error closing storage")
}
// Close metadata store
if err := a.metadata.Close(); err != nil {
log.Error().Err(err).Msg("Error closing metadata store")
}
// Close lock manager if initialized
if a.lockManager != nil {
if err := a.lockManager.Close(); err != nil {
log.Error().Err(err).Msg("Error closing lock manager")
}
}
log.Info().Msg("Shutdown complete")
return nil
}
+415
View File
@@ -0,0 +1,415 @@
package app
import (
"net/http"
"strings"
"time"
"github.com/lukaszraczylo/gohoarder/internal/version"
"github.com/lukaszraczylo/gohoarder/pkg/errors"
"github.com/lukaszraczylo/gohoarder/pkg/metadata"
"github.com/lukaszraczylo/gohoarder/pkg/websocket"
"github.com/rs/zerolog/log"
)
// handlePackages handles /api/packages endpoint
func (a *App) handlePackages(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
// Check if this is a vulnerability endpoint request
if strings.HasSuffix(r.URL.Path, "/vulnerabilities") {
a.handleVulnerabilities(w, r)
return
}
switch r.Method {
case "GET":
a.handleListPackages(w, r)
case "DELETE":
a.handleDeletePackage(w, r)
default:
errors.WriteErrorSimple(w, errors.BadRequest("method not allowed"))
}
}
// handleListPackages returns list of cached packages
func (a *App) handleListPackages(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Get packages from metadata store
allPackages, err := a.metadata.ListPackages(ctx, &metadata.ListOptions{
Limit: 1000, // Get more to account for duplicates
Offset: 0,
})
if err != nil {
log.Error().Err(err).Msg("Failed to list packages")
errors.WriteErrorSimple(w, errors.InternalServer("failed to list packages"))
return
}
// Filter, clean, and deduplicate packages
seen := make(map[string]*metadata.Package)
for _, pkg := range allPackages {
// Skip metadata entries
if pkg.Version == "list" || pkg.Version == "latest" {
continue
}
// Clean the package name (remove /@v/version.ext suffix)
cleanName := pkg.Name
if idx := strings.Index(cleanName, "/@v/"); idx != -1 {
cleanName = cleanName[:idx]
}
// Create deduplication key
key := cleanName + "@" + pkg.Version
// Keep the entry with the largest size (typically .zip files)
if existing, ok := seen[key]; !ok || pkg.Size > existing.Size {
// Create a copy with cleaned name
cleanPkg := *pkg
cleanPkg.Name = cleanName
seen[key] = &cleanPkg
}
}
// Convert map to slice
packages := make([]*metadata.Package, 0, len(seen))
for _, pkg := range seen {
packages = append(packages, pkg)
}
// Enhance packages with vulnerability information if security scanning is enabled
var response map[string]interface{}
if a.config.Security.Enabled {
enhancedPackages := make([]map[string]interface{}, 0, len(packages))
for _, pkg := range packages {
pkgMap := map[string]interface{}{
"id": pkg.ID,
"registry": pkg.Registry,
"name": pkg.Name,
"version": pkg.Version,
"size": pkg.Size,
"checksum_sha256": pkg.ChecksumSHA256,
"cached_at": pkg.CachedAt,
"last_accessed": pkg.LastAccessed,
"download_count": pkg.DownloadCount,
}
// Add vulnerability info if scanned
if pkg.SecurityScanned {
scanResult, err := a.metadata.GetScanResult(ctx, pkg.Registry, pkg.Name, pkg.Version)
if err == nil && scanResult != nil {
// Count vulnerabilities by severity
severityCounts := make(map[string]int)
for _, vuln := range scanResult.Vulnerabilities {
severityCounts[strings.ToUpper(vuln.Severity)]++
}
pkgMap["vulnerabilities"] = map[string]interface{}{
"scanned": true,
"status": scanResult.Status,
"scannedAt": scanResult.ScannedAt.Format(time.RFC3339),
"counts": map[string]int{
"critical": severityCounts["CRITICAL"],
"high": severityCounts["HIGH"],
"medium": severityCounts["MEDIUM"],
"low": severityCounts["LOW"],
},
"total": scanResult.VulnerabilityCount,
}
} else {
pkgMap["vulnerabilities"] = map[string]interface{}{
"scanned": false,
"status": "pending",
}
}
} else {
pkgMap["vulnerabilities"] = map[string]interface{}{
"scanned": false,
"status": "not_scanned",
}
}
enhancedPackages = append(enhancedPackages, pkgMap)
}
response = map[string]interface{}{
"packages": enhancedPackages,
"total": len(enhancedPackages),
}
} else {
response = map[string]interface{}{
"packages": packages,
"total": len(packages),
}
}
// Success response
errors.WriteJSONSimple(w, http.StatusOK, response)
}
// handleDeletePackage deletes a cached package
func (a *App) handleDeletePackage(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Parse path: /api/packages/{registry}/{name}/{version}
// For Go packages, name can contain slashes (e.g., github.com/user/repo)
// Version is always the last segment
path := strings.TrimPrefix(r.URL.Path, "/api/packages/")
parts := strings.Split(path, "/")
if len(parts) < 3 {
errors.WriteErrorSimple(w, errors.BadRequest("invalid path format, expected /api/packages/{registry}/{name}/{version}"))
return
}
registry := parts[0]
version := parts[len(parts)-1]
name := strings.Join(parts[1:len(parts)-1], "/")
// For Go packages, we need to find and delete all cache entries (.info, .mod, .zip)
// For other registries, we can delete directly
var deletedCount int
var lastErr error
if registry == "go" {
// List all packages matching the base name and version
allPackages, err := a.metadata.ListPackages(ctx, &metadata.ListOptions{
Limit: 1000,
})
if err != nil {
log.Error().Err(err).Msg("Failed to list packages for deletion")
errors.WriteErrorSimple(w, errors.InternalServer("failed to list packages"))
return
}
log.Debug().
Str("registry", registry).
Str("name", name).
Str("version", version).
Int("total_packages", len(allPackages)).
Msg("Searching for packages to delete")
// Find and delete all entries for this package
for _, pkg := range allPackages {
if pkg.Registry != registry || pkg.Version != version {
continue
}
// Check if this package name matches (either exact or with /@v/ suffix)
cleanName := pkg.Name
if idx := strings.Index(cleanName, "/@v/"); idx != -1 {
cleanName = cleanName[:idx]
}
log.Debug().
Str("db_name", pkg.Name).
Str("clean_name", cleanName).
Str("search_name", name).
Bool("matches", cleanName == name).
Msg("Checking package")
if cleanName == name {
if err := a.cache.Delete(ctx, pkg.Registry, pkg.Name, pkg.Version); err != nil {
log.Warn().
Err(err).
Str("registry", pkg.Registry).
Str("name", pkg.Name).
Str("version", pkg.Version).
Msg("Failed to delete package variant")
lastErr = err
} else {
deletedCount++
log.Info().
Str("registry", pkg.Registry).
Str("name", pkg.Name).
Str("version", pkg.Version).
Msg("Deleted package variant")
}
}
}
log.Debug().
Int("deleted_count", deletedCount).
Msg("Delete operation completed")
if deletedCount == 0 {
errors.WriteErrorSimple(w, errors.NotFound("package not found"))
return
}
if lastErr != nil && deletedCount == 0 {
errors.WriteErrorSimple(w, errors.InternalServer("failed to delete package"))
return
}
} else {
// For NPM and PyPI, delete directly
if err := a.cache.Delete(ctx, registry, name, version); err != nil {
log.Error().
Err(err).
Str("registry", registry).
Str("name", name).
Str("version", version).
Msg("Failed to delete package")
errors.WriteErrorSimple(w, errors.InternalServer("failed to delete package"))
return
}
deletedCount = 1
}
// Broadcast event via WebSocket
a.wsServer.Broadcast(websocket.EventPackageDeleted, map[string]interface{}{
"registry": registry,
"name": name,
"version": version,
})
// Success response
response := map[string]interface{}{
"deleted": true,
"package": map[string]string{
"registry": registry,
"name": name,
"version": version,
},
}
// For Go packages, include count of deleted variants
if registry == "go" {
response["deleted_count"] = deletedCount
}
errors.WriteJSONSimple(w, http.StatusOK, response)
}
// handleStats handles /api/stats endpoint
func (a *App) handleStats(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
if r.Method != "GET" {
errors.WriteErrorSimple(w, errors.BadRequest("method not allowed"))
return
}
ctx := r.Context()
// Get cache statistics for all registries
cacheStats, err := a.cache.GetStats(ctx, "")
if err != nil {
log.Error().Err(err).Msg("Failed to get cache stats")
cacheStats = &metadata.Stats{}
}
// Get all packages to calculate total size and downloads
packages, err := a.metadata.ListPackages(ctx, nil)
if err != nil {
log.Error().Err(err).Msg("Failed to list packages")
packages = []*metadata.Package{}
}
// Calculate totals and registry breakdown from actual packages (exclude metadata entries like "list", "latest")
var totalSize int64
var totalDownloads int64
var actualPackageCount int
registryStats := make(map[string]map[string]interface{})
for _, pkg := range packages {
// Skip metadata entries
if pkg.Version == "list" || pkg.Version == "latest" {
continue
}
totalSize += pkg.Size
totalDownloads += int64(pkg.DownloadCount)
actualPackageCount++
// Track per-registry stats
if _, ok := registryStats[pkg.Registry]; !ok {
registryStats[pkg.Registry] = map[string]interface{}{
"count": 0,
"size": int64(0),
"downloads": int64(0),
}
}
registryStats[pkg.Registry]["count"] = registryStats[pkg.Registry]["count"].(int) + 1
registryStats[pkg.Registry]["size"] = registryStats[pkg.Registry]["size"].(int64) + pkg.Size
registryStats[pkg.Registry]["downloads"] = registryStats[pkg.Registry]["downloads"].(int64) + int64(pkg.DownloadCount)
}
// Combine statistics
stats := map[string]interface{}{
"total_packages": actualPackageCount,
"total_downloads": totalDownloads,
"total_size": totalSize,
"cache_hits": cacheStats.TotalDownloads,
"cache_misses": 0, // TODO: Track cache misses
"cache_evictions": 0, // TODO: Track evictions
"cache_size": cacheStats.TotalSize,
"scanned_packages": cacheStats.ScannedPackages,
"vulnerable_packages": cacheStats.VulnerablePackages,
}
// Convert registry stats to interface map
registries := make(map[string]interface{})
for registry, regStats := range registryStats {
registries[registry] = regStats
}
errors.WriteJSONSimple(w, http.StatusOK, map[string]interface{}{
"stats": stats,
"registries": registries,
})
}
// handleInfo handles /api/info endpoint
func (a *App) handleInfo(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
if r.Method != "GET" {
errors.WriteErrorSimple(w, errors.BadRequest("method not allowed"))
return
}
info := map[string]interface{}{
"name": "GoHoarder",
"version": version.Version,
"config": map[string]interface{}{
"storage_backend": a.config.Storage.Backend,
"metadata_backend": a.config.Metadata.Backend,
"cache_ttl": a.config.Cache.DefaultTTL.String(),
"max_cache_size": a.config.Cache.MaxSizeBytes,
},
"features": map[string]bool{
"distributed_locking": a.lockManager != nil,
"security_scanning": a.config.Security.Enabled,
"pre_warming": a.prewarmWorker != nil,
"websockets": true,
"analytics": true,
},
}
errors.WriteJSONSimple(w, http.StatusOK, info)
}
+413
View File
@@ -0,0 +1,413 @@
package app
import (
"net/http"
"strings"
"github.com/lukaszraczylo/gohoarder/internal/version"
"github.com/lukaszraczylo/gohoarder/pkg/errors"
"github.com/lukaszraczylo/gohoarder/pkg/metadata"
"github.com/lukaszraczylo/gohoarder/pkg/websocket"
"github.com/rs/zerolog/log"
)
// handlePackages handles /api/packages endpoint
func (a *App) handlePackages(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
// Check if this is a vulnerability endpoint request
if strings.HasSuffix(r.URL.Path, "/vulnerabilities") {
a.handleVulnerabilities(w, r)
return
}
switch r.Method {
case "GET":
a.handleListPackages(w, r)
case "DELETE":
a.handleDeletePackage(w, r)
default:
errors.WriteErrorSimple(w, errors.BadRequest("method not allowed"))
}
}
// handleListPackages returns list of cached packages
func (a *App) handleListPackages(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Get packages from metadata store
allPackages, err := a.metadata.ListPackages(ctx, &metadata.ListOptions{
Limit: 1000, // Get more to account for duplicates
Offset: 0,
})
if err != nil {
log.Error().Err(err).Msg("Failed to list packages")
errors.WriteErrorSimple(w, errors.InternalServer("failed to list packages"))
return
}
// Filter, clean, and deduplicate packages
seen := make(map[string]*metadata.Package)
for _, pkg := range allPackages {
// Skip metadata entries
if pkg.Version == "list" || pkg.Version == "latest" {
continue
}
// Clean the package name (remove /@v/version.ext suffix)
cleanName := pkg.Name
if idx := strings.Index(cleanName, "/@v/"); idx != -1 {
cleanName = cleanName[:idx]
}
// Create deduplication key
key := cleanName + "@" + pkg.Version
// Keep the entry with the largest size (typically .zip files)
if existing, ok := seen[key]; !ok || pkg.Size > existing.Size {
// Create a copy with cleaned name
cleanPkg := *pkg
cleanPkg.Name = cleanName
seen[key] = &cleanPkg
}
}
// Convert map to slice
packages := make([]*metadata.Package, 0, len(seen))
for _, pkg := range seen {
packages = append(packages, pkg)
}
// Enhance packages with vulnerability information if security scanning is enabled
var response map[string]interface{}
if a.config.Security.Enabled {
enhancedPackages := make([]map[string]interface{}, 0, len(packages))
for _, pkg := range packages {
pkgMap := map[string]interface{}{
"id": pkg.ID,
"registry": pkg.Registry,
"name": pkg.Name,
"version": pkg.Version,
"size": pkg.Size,
"checksum_sha256": pkg.ChecksumSHA256,
"cached_at": pkg.CachedAt,
"last_accessed": pkg.LastAccessed,
"download_count": pkg.DownloadCount,
}
// Add vulnerability info if scanned
if pkg.SecurityScanned {
scanResult, err := a.metadata.GetScanResult(ctx, pkg.Registry, pkg.Name, pkg.Version)
if err == nil && scanResult != nil {
// Count vulnerabilities by severity
severityCounts := make(map[string]int)
for _, vuln := range scanResult.Vulnerabilities {
severityCounts[strings.ToUpper(vuln.Severity)]++
}
pkgMap["vulnerabilities"] = map[string]interface{}{
"scanned": true,
"status": scanResult.Status,
"counts": map[string]int{
"critical": severityCounts["CRITICAL"],
"high": severityCounts["HIGH"],
"medium": severityCounts["MEDIUM"],
"low": severityCounts["LOW"],
},
"total": scanResult.VulnerabilityCount,
}
} else {
pkgMap["vulnerabilities"] = map[string]interface{}{
"scanned": false,
"status": "pending",
}
}
} else {
pkgMap["vulnerabilities"] = map[string]interface{}{
"scanned": false,
"status": "not_scanned",
}
}
enhancedPackages = append(enhancedPackages, pkgMap)
}
response = map[string]interface{}{
"packages": enhancedPackages,
"total": len(enhancedPackages),
}
} else {
response = map[string]interface{}{
"packages": packages,
"total": len(packages),
}
}
// Success response
errors.WriteJSONSimple(w, http.StatusOK, response)
}
// handleDeletePackage deletes a cached package
func (a *App) handleDeletePackage(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Parse path: /api/packages/{registry}/{name}/{version}
// For Go packages, name can contain slashes (e.g., github.com/user/repo)
// Version is always the last segment
path := strings.TrimPrefix(r.URL.Path, "/api/packages/")
parts := strings.Split(path, "/")
if len(parts) < 3 {
errors.WriteErrorSimple(w, errors.BadRequest("invalid path format, expected /api/packages/{registry}/{name}/{version}"))
return
}
registry := parts[0]
version := parts[len(parts)-1]
name := strings.Join(parts[1:len(parts)-1], "/")
// For Go packages, we need to find and delete all cache entries (.info, .mod, .zip)
// For other registries, we can delete directly
var deletedCount int
var lastErr error
if registry == "go" {
// List all packages matching the base name and version
allPackages, err := a.metadata.ListPackages(ctx, &metadata.ListOptions{
Limit: 1000,
})
if err != nil {
log.Error().Err(err).Msg("Failed to list packages for deletion")
errors.WriteErrorSimple(w, errors.InternalServer("failed to list packages"))
return
}
log.Debug().
Str("registry", registry).
Str("name", name).
Str("version", version).
Int("total_packages", len(allPackages)).
Msg("Searching for packages to delete")
// Find and delete all entries for this package
for _, pkg := range allPackages {
if pkg.Registry != registry || pkg.Version != version {
continue
}
// Check if this package name matches (either exact or with /@v/ suffix)
cleanName := pkg.Name
if idx := strings.Index(cleanName, "/@v/"); idx != -1 {
cleanName = cleanName[:idx]
}
log.Debug().
Str("db_name", pkg.Name).
Str("clean_name", cleanName).
Str("search_name", name).
Bool("matches", cleanName == name).
Msg("Checking package")
if cleanName == name {
if err := a.cache.Delete(ctx, pkg.Registry, pkg.Name, pkg.Version); err != nil {
log.Warn().
Err(err).
Str("registry", pkg.Registry).
Str("name", pkg.Name).
Str("version", pkg.Version).
Msg("Failed to delete package variant")
lastErr = err
} else {
deletedCount++
log.Info().
Str("registry", pkg.Registry).
Str("name", pkg.Name).
Str("version", pkg.Version).
Msg("Deleted package variant")
}
}
}
log.Debug().
Int("deleted_count", deletedCount).
Msg("Delete operation completed")
if deletedCount == 0 {
errors.WriteErrorSimple(w, errors.NotFound("package not found"))
return
}
if lastErr != nil && deletedCount == 0 {
errors.WriteErrorSimple(w, errors.InternalServer("failed to delete package"))
return
}
} else {
// For NPM and PyPI, delete directly
if err := a.cache.Delete(ctx, registry, name, version); err != nil {
log.Error().
Err(err).
Str("registry", registry).
Str("name", name).
Str("version", version).
Msg("Failed to delete package")
errors.WriteErrorSimple(w, errors.InternalServer("failed to delete package"))
return
}
deletedCount = 1
}
// Broadcast event via WebSocket
a.wsServer.Broadcast(websocket.EventPackageDeleted, map[string]interface{}{
"registry": registry,
"name": name,
"version": version,
})
// Success response
response := map[string]interface{}{
"deleted": true,
"package": map[string]string{
"registry": registry,
"name": name,
"version": version,
},
}
// For Go packages, include count of deleted variants
if registry == "go" {
response["deleted_count"] = deletedCount
}
errors.WriteJSONSimple(w, http.StatusOK, response)
}
// handleStats handles /api/stats endpoint
func (a *App) handleStats(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
if r.Method != "GET" {
errors.WriteErrorSimple(w, errors.BadRequest("method not allowed"))
return
}
ctx := r.Context()
// Get cache statistics for all registries
cacheStats, err := a.cache.GetStats(ctx, "")
if err != nil {
log.Error().Err(err).Msg("Failed to get cache stats")
cacheStats = &metadata.Stats{}
}
// Get all packages to calculate total size and downloads
packages, err := a.metadata.ListPackages(ctx, nil)
if err != nil {
log.Error().Err(err).Msg("Failed to list packages")
packages = []*metadata.Package{}
}
// Calculate totals and registry breakdown from actual packages (exclude metadata entries like "list", "latest")
var totalSize int64
var totalDownloads int64
var actualPackageCount int
registryStats := make(map[string]map[string]interface{})
for _, pkg := range packages {
// Skip metadata entries
if pkg.Version == "list" || pkg.Version == "latest" {
continue
}
totalSize += pkg.Size
totalDownloads += int64(pkg.DownloadCount)
actualPackageCount++
// Track per-registry stats
if _, ok := registryStats[pkg.Registry]; !ok {
registryStats[pkg.Registry] = map[string]interface{}{
"count": 0,
"size": int64(0),
"downloads": int64(0),
}
}
registryStats[pkg.Registry]["count"] = registryStats[pkg.Registry]["count"].(int) + 1
registryStats[pkg.Registry]["size"] = registryStats[pkg.Registry]["size"].(int64) + pkg.Size
registryStats[pkg.Registry]["downloads"] = registryStats[pkg.Registry]["downloads"].(int64) + int64(pkg.DownloadCount)
}
// Combine statistics
stats := map[string]interface{}{
"total_packages": actualPackageCount,
"total_downloads": totalDownloads,
"total_size": totalSize,
"cache_hits": cacheStats.TotalDownloads,
"cache_misses": 0, // TODO: Track cache misses
"cache_evictions": 0, // TODO: Track evictions
"cache_size": cacheStats.TotalSize,
"scanned_packages": cacheStats.ScannedPackages,
"vulnerable_packages": cacheStats.VulnerablePackages,
}
// Convert registry stats to interface map
registries := make(map[string]interface{})
for registry, regStats := range registryStats {
registries[registry] = regStats
}
errors.WriteJSONSimple(w, http.StatusOK, map[string]interface{}{
"stats": stats,
"registries": registries,
})
}
// handleInfo handles /api/info endpoint
func (a *App) handleInfo(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
if r.Method != "GET" {
errors.WriteErrorSimple(w, errors.BadRequest("method not allowed"))
return
}
info := map[string]interface{}{
"name": "GoHoarder",
"version": version.Version,
"config": map[string]interface{}{
"storage_backend": a.config.Storage.Backend,
"metadata_backend": a.config.Metadata.Backend,
"cache_ttl": a.config.Cache.DefaultTTL.String(),
"max_cache_size": a.config.Cache.MaxSizeBytes,
},
"features": map[string]bool{
"distributed_locking": a.lockManager != nil,
"security_scanning": a.config.Security.Enabled,
"pre_warming": a.prewarmWorker != nil,
"websockets": true,
"analytics": true,
},
}
errors.WriteJSONSimple(w, http.StatusOK, info)
}
+415
View File
@@ -0,0 +1,415 @@
package app
import (
"net/http"
"strings"
"time"
"github.com/lukaszraczylo/gohoarder/internal/version"
"github.com/lukaszraczylo/gohoarder/pkg/errors"
"github.com/lukaszraczylo/gohoarder/pkg/metadata"
"github.com/lukaszraczylo/gohoarder/pkg/websocket"
"github.com/rs/zerolog/log"
)
// handlePackages handles /api/packages endpoint
func (a *App) handlePackages(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
// Check if this is a vulnerability endpoint request
if strings.HasSuffix(r.URL.Path, "/vulnerabilities") {
a.handleVulnerabilities(w, r)
return
}
switch r.Method {
case "GET":
a.handleListPackages(w, r)
case "DELETE":
a.handleDeletePackage(w, r)
default:
errors.WriteErrorSimple(w, errors.BadRequest("method not allowed"))
}
}
// handleListPackages returns list of cached packages
func (a *App) handleListPackages(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Get packages from metadata store
allPackages, err := a.metadata.ListPackages(ctx, &metadata.ListOptions{
Limit: 1000, // Get more to account for duplicates
Offset: 0,
})
if err != nil {
log.Error().Err(err).Msg("Failed to list packages")
errors.WriteErrorSimple(w, errors.InternalServer("failed to list packages"))
return
}
// Filter, clean, and deduplicate packages
seen := make(map[string]*metadata.Package)
for _, pkg := range allPackages {
// Skip metadata entries
if pkg.Version == "list" || pkg.Version == "latest" {
continue
}
// Clean the package name (remove /@v/version.ext suffix)
cleanName := pkg.Name
if idx := strings.Index(cleanName, "/@v/"); idx != -1 {
cleanName = cleanName[:idx]
}
// Create deduplication key
key := cleanName + "@" + pkg.Version
// Keep the entry with the largest size (typically .zip files)
if existing, ok := seen[key]; !ok || pkg.Size > existing.Size {
// Create a copy with cleaned name
cleanPkg := *pkg
cleanPkg.Name = cleanName
seen[key] = &cleanPkg
}
}
// Convert map to slice
packages := make([]*metadata.Package, 0, len(seen))
for _, pkg := range seen {
packages = append(packages, pkg)
}
// Enhance packages with vulnerability information if security scanning is enabled
var response map[string]interface{}
if a.config.Security.Enabled {
enhancedPackages := make([]map[string]interface{}, 0, len(packages))
for _, pkg := range packages {
pkgMap := map[string]interface{}{
"id": pkg.ID,
"registry": pkg.Registry,
"name": pkg.Name,
"version": pkg.Version,
"size": pkg.Size,
"checksum_sha256": pkg.ChecksumSHA256,
"cached_at": pkg.CachedAt,
"last_accessed": pkg.LastAccessed,
"download_count": pkg.DownloadCount,
}
// Add vulnerability info if scanned
if pkg.SecurityScanned {
scanResult, err := a.metadata.GetScanResult(ctx, pkg.Registry, pkg.Name, pkg.Version)
if err == nil && scanResult != nil {
// Count vulnerabilities by severity
severityCounts := make(map[string]int)
for _, vuln := range scanResult.Vulnerabilities {
severityCounts[strings.ToUpper(vuln.Severity)]++
}
pkgMap["vulnerabilities"] = map[string]interface{}{
"scanned": true,
"status": scanResult.Status,
"scannedAt": scanResult.ScannedAt.Format(time.RFC3339),
"counts": map[string]int{
"critical": severityCounts["CRITICAL"],
"high": severityCounts["HIGH"],
"medium": severityCounts["MEDIUM"],
"low": severityCounts["LOW"],
},
"total": scanResult.VulnerabilityCount,
}
} else {
pkgMap["vulnerabilities"] = map[string]interface{}{
"scanned": false,
"status": "pending",
}
}
} else {
pkgMap["vulnerabilities"] = map[string]interface{}{
"scanned": false,
"status": "not_scanned",
}
}
enhancedPackages = append(enhancedPackages, pkgMap)
}
response = map[string]interface{}{
"packages": enhancedPackages,
"total": len(enhancedPackages),
}
} else {
response = map[string]interface{}{
"packages": packages,
"total": len(packages),
}
}
// Success response
errors.WriteJSONSimple(w, http.StatusOK, response)
}
// handleDeletePackage deletes a cached package
func (a *App) handleDeletePackage(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Parse path: /api/packages/{registry}/{name}/{version}
// For Go packages, name can contain slashes (e.g., github.com/user/repo)
// Version is always the last segment
path := strings.TrimPrefix(r.URL.Path, "/api/packages/")
parts := strings.Split(path, "/")
if len(parts) < 3 {
errors.WriteErrorSimple(w, errors.BadRequest("invalid path format, expected /api/packages/{registry}/{name}/{version}"))
return
}
registry := parts[0]
version := parts[len(parts)-1]
name := strings.Join(parts[1:len(parts)-1], "/")
// For Go packages, we need to find and delete all cache entries (.info, .mod, .zip)
// For other registries, we can delete directly
var deletedCount int
var lastErr error
if registry == "go" {
// List all packages matching the base name and version
allPackages, err := a.metadata.ListPackages(ctx, &metadata.ListOptions{
Limit: 1000,
})
if err != nil {
log.Error().Err(err).Msg("Failed to list packages for deletion")
errors.WriteErrorSimple(w, errors.InternalServer("failed to list packages"))
return
}
log.Debug().
Str("registry", registry).
Str("name", name).
Str("version", version).
Int("total_packages", len(allPackages)).
Msg("Searching for packages to delete")
// Find and delete all entries for this package
for _, pkg := range allPackages {
if pkg.Registry != registry || pkg.Version != version {
continue
}
// Check if this package name matches (either exact or with /@v/ suffix)
cleanName := pkg.Name
if idx := strings.Index(cleanName, "/@v/"); idx != -1 {
cleanName = cleanName[:idx]
}
log.Debug().
Str("db_name", pkg.Name).
Str("clean_name", cleanName).
Str("search_name", name).
Bool("matches", cleanName == name).
Msg("Checking package")
if cleanName == name {
if err := a.cache.Delete(ctx, pkg.Registry, pkg.Name, pkg.Version); err != nil {
log.Warn().
Err(err).
Str("registry", pkg.Registry).
Str("name", pkg.Name).
Str("version", pkg.Version).
Msg("Failed to delete package variant")
lastErr = err
} else {
deletedCount++
log.Info().
Str("registry", pkg.Registry).
Str("name", pkg.Name).
Str("version", pkg.Version).
Msg("Deleted package variant")
}
}
}
log.Debug().
Int("deleted_count", deletedCount).
Msg("Delete operation completed")
if deletedCount == 0 {
errors.WriteErrorSimple(w, errors.NotFound("package not found"))
return
}
if lastErr != nil && deletedCount == 0 {
errors.WriteErrorSimple(w, errors.InternalServer("failed to delete package"))
return
}
} else {
// For NPM and PyPI, delete directly
if err := a.cache.Delete(ctx, registry, name, version); err != nil {
log.Error().
Err(err).
Str("registry", registry).
Str("name", name).
Str("version", version).
Msg("Failed to delete package")
errors.WriteErrorSimple(w, errors.InternalServer("failed to delete package"))
return
}
deletedCount = 1
}
// Broadcast event via WebSocket
a.wsServer.Broadcast(websocket.EventPackageDeleted, map[string]interface{}{
"registry": registry,
"name": name,
"version": version,
})
// Success response
response := map[string]interface{}{
"deleted": true,
"package": map[string]string{
"registry": registry,
"name": name,
"version": version,
},
}
// For Go packages, include count of deleted variants
if registry == "go" {
response["deleted_count"] = deletedCount
}
errors.WriteJSONSimple(w, http.StatusOK, response)
}
// handleStats handles /api/stats endpoint
func (a *App) handleStats(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
if r.Method != "GET" {
errors.WriteErrorSimple(w, errors.BadRequest("method not allowed"))
return
}
ctx := r.Context()
// Get cache statistics for all registries
cacheStats, err := a.cache.GetStats(ctx, "")
if err != nil {
log.Error().Err(err).Msg("Failed to get cache stats")
cacheStats = &metadata.Stats{}
}
// Get all packages to calculate total size and downloads
packages, err := a.metadata.ListPackages(ctx, nil)
if err != nil {
log.Error().Err(err).Msg("Failed to list packages")
packages = []*metadata.Package{}
}
// Calculate totals and registry breakdown from actual packages (exclude metadata entries like "list", "latest")
var totalSize int64
var totalDownloads int64
var actualPackageCount int
registryStats := make(map[string]map[string]interface{})
for _, pkg := range packages {
// Skip metadata entries
if pkg.Version == "list" || pkg.Version == "latest" {
continue
}
totalSize += pkg.Size
totalDownloads += int64(pkg.DownloadCount)
actualPackageCount++
// Track per-registry stats
if _, ok := registryStats[pkg.Registry]; !ok {
registryStats[pkg.Registry] = map[string]interface{}{
"count": 0,
"size": int64(0),
"downloads": int64(0),
}
}
registryStats[pkg.Registry]["count"] = registryStats[pkg.Registry]["count"].(int) + 1
registryStats[pkg.Registry]["size"] = registryStats[pkg.Registry]["size"].(int64) + pkg.Size
registryStats[pkg.Registry]["downloads"] = registryStats[pkg.Registry]["downloads"].(int64) + int64(pkg.DownloadCount)
}
// Combine statistics
stats := map[string]interface{}{
"total_packages": actualPackageCount,
"total_downloads": totalDownloads,
"total_size": totalSize,
"cache_hits": cacheStats.TotalDownloads,
"cache_misses": 0, // TODO: Track cache misses
"cache_evictions": 0, // TODO: Track evictions
"cache_size": cacheStats.TotalSize,
"scanned_packages": cacheStats.ScannedPackages,
"vulnerable_packages": cacheStats.VulnerablePackages,
}
// Convert registry stats to interface map
registries := make(map[string]interface{})
for registry, regStats := range registryStats {
registries[registry] = regStats
}
errors.WriteJSONSimple(w, http.StatusOK, map[string]interface{}{
"stats": stats,
"registries": registries,
})
}
// handleInfo handles /api/info endpoint
func (a *App) handleInfo(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
if r.Method != "GET" {
errors.WriteErrorSimple(w, errors.BadRequest("method not allowed"))
return
}
info := map[string]interface{}{
"name": "GoHoarder",
"version": version.Version,
"config": map[string]interface{}{
"storage_backend": a.config.Storage.Backend,
"metadata_backend": a.config.Metadata.Backend,
"cache_ttl": a.config.Cache.DefaultTTL.String(),
"max_cache_size": a.config.Cache.MaxSizeBytes,
},
"features": map[string]bool{
"distributed_locking": a.lockManager != nil,
"security_scanning": a.config.Security.Enabled,
"pre_warming": a.prewarmWorker != nil,
"websockets": true,
"analytics": true,
},
}
errors.WriteJSONSimple(w, http.StatusOK, info)
}
+378
View File
@@ -0,0 +1,378 @@
package app
import (
"encoding/json"
"io"
"net/http"
"strings"
"time"
"github.com/lukaszraczylo/gohoarder/pkg/auth"
"github.com/lukaszraczylo/gohoarder/pkg/errors"
"github.com/lukaszraczylo/gohoarder/pkg/metadata"
"github.com/lukaszraczylo/gohoarder/pkg/uuid"
"github.com/rs/zerolog/log"
)
// requireAdmin middleware checks for admin authentication
func (a *App) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Get API key from Authorization header
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
errors.WriteErrorSimple(w, errors.New(errors.ErrCodeUnauthorized, "missing authorization header"))
return
}
// Extract bearer token
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
errors.WriteErrorSimple(w, errors.New(errors.ErrCodeUnauthorized, "invalid authorization header format, expected: Bearer <token>"))
return
}
apiKey := parts[1]
// Validate API key
key, err := a.authManager.ValidateAPIKey(r.Context(), apiKey)
if err != nil {
errors.WriteErrorSimple(w, errors.New(errors.ErrCodeUnauthorized, "invalid or expired API key"))
return
}
// Check if user has admin role or bypass management permission
if key.Role != auth.RoleAdmin && !key.HasPermission(auth.PermissionManageBypasses) {
errors.WriteErrorSimple(w, errors.New(errors.ErrCodeForbidden, "insufficient permissions, admin role required"))
return
}
// Store user info in request context for handlers to use
// For now, we'll just proceed - could enhance with context.WithValue
next(w, r)
}
}
// handleAdminBypasses handles /api/admin/bypasses endpoint
func (a *App) handleAdminBypasses(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
switch r.Method {
case "GET":
a.requireAdmin(a.handleListBypasses)(w, r)
case "POST":
a.requireAdmin(a.handleCreateBypass)(w, r)
default:
errors.WriteErrorSimple(w, errors.BadRequest("method not allowed"))
}
}
// handleBypassByID handles /api/admin/bypasses/{id} endpoint
func (a *App) handleBypassByID(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, DELETE, PATCH, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
switch r.Method {
case "GET":
a.requireAdmin(a.handleGetBypass)(w, r)
case "DELETE":
a.requireAdmin(a.handleDeleteBypass)(w, r)
case "PATCH":
a.requireAdmin(a.handleUpdateBypass)(w, r)
default:
errors.WriteErrorSimple(w, errors.BadRequest("method not allowed"))
}
}
// handleListBypasses lists all CVE bypasses
func (a *App) handleListBypasses(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Parse query parameters
includeExpired := r.URL.Query().Get("include_expired") == "true"
activeOnly := r.URL.Query().Get("active_only") == "true"
bypassType := metadata.BypassType(r.URL.Query().Get("type"))
opts := &metadata.BypassListOptions{
IncludeExpired: includeExpired,
ActiveOnly: activeOnly,
Type: bypassType,
}
bypasses, err := a.metadata.ListCVEBypasses(ctx, opts)
if err != nil {
log.Error().Err(err).Msg("Failed to list CVE bypasses")
errors.WriteErrorSimple(w, errors.InternalServer("failed to list bypasses"))
return
}
errors.WriteJSONSimple(w, http.StatusOK, map[string]interface{}{
"bypasses": bypasses,
"total": len(bypasses),
})
}
// CreateBypassRequest represents the request body for creating a bypass
type CreateBypassRequest struct {
Type metadata.BypassType `json:"type"` // "cve" or "package"
Target string `json:"target"` // CVE ID or package name
Reason string `json:"reason"` // Why this bypass is needed
CreatedBy string `json:"created_by"` // Admin username
ExpiresInHours int `json:"expires_in_hours"` // How many hours until expiration
AppliesTo string `json:"applies_to,omitempty"` // Optional: limit CVE bypass to specific package
NotifyOnExpiry bool `json:"notify_on_expiry"` // Send notification when expired
}
// handleCreateBypass creates a new CVE bypass
func (a *App) handleCreateBypass(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Parse request body
body, err := io.ReadAll(r.Body)
if err != nil {
errors.WriteErrorSimple(w, errors.BadRequest("failed to read request body"))
return
}
defer r.Body.Close()
var req CreateBypassRequest
if err := json.Unmarshal(body, &req); err != nil {
errors.WriteErrorSimple(w, errors.BadRequest("invalid JSON in request body"))
return
}
// Validate request
if req.Type != metadata.BypassTypeCVE && req.Type != metadata.BypassTypePackage {
errors.WriteErrorSimple(w, errors.BadRequest("type must be 'cve' or 'package'"))
return
}
if req.Target == "" {
errors.WriteErrorSimple(w, errors.BadRequest("target is required"))
return
}
if req.Reason == "" {
errors.WriteErrorSimple(w, errors.BadRequest("reason is required"))
return
}
if req.CreatedBy == "" {
errors.WriteErrorSimple(w, errors.BadRequest("created_by is required"))
return
}
if req.ExpiresInHours <= 0 {
errors.WriteErrorSimple(w, errors.BadRequest("expires_in_hours must be greater than 0"))
return
}
// Create bypass
now := time.Now()
expiresAt := now.Add(time.Duration(req.ExpiresInHours) * time.Hour)
bypass := &metadata.CVEBypass{
ID: uuid.New().String(),
Type: req.Type,
Target: req.Target,
Reason: req.Reason,
CreatedBy: req.CreatedBy,
CreatedAt: now,
ExpiresAt: expiresAt,
AppliesTo: req.AppliesTo,
NotifyOnExpiry: req.NotifyOnExpiry,
Active: true,
}
// Save to database
if err := a.metadata.SaveCVEBypass(ctx, bypass); err != nil {
log.Error().Err(err).Msg("Failed to save CVE bypass")
errors.WriteErrorSimple(w, errors.InternalServer("failed to create bypass"))
return
}
log.Info().
Str("bypass_id", bypass.ID).
Str("type", string(bypass.Type)).
Str("target", bypass.Target).
Str("created_by", bypass.CreatedBy).
Time("expires_at", bypass.ExpiresAt).
Msg("CVE bypass created")
errors.WriteJSONSimple(w, http.StatusCreated, map[string]interface{}{
"bypass": bypass,
"message": "Bypass created successfully",
})
}
// handleGetBypass gets a specific bypass by ID
func (a *App) handleGetBypass(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Extract ID from path
path := strings.TrimPrefix(r.URL.Path, "/api/admin/bypasses/")
bypassID := path
if bypassID == "" {
errors.WriteErrorSimple(w, errors.BadRequest("bypass ID is required"))
return
}
// Get all bypasses and find the one with matching ID
bypasses, err := a.metadata.ListCVEBypasses(ctx, &metadata.BypassListOptions{
IncludeExpired: true,
})
if err != nil {
log.Error().Err(err).Msg("Failed to list bypasses")
errors.WriteErrorSimple(w, errors.InternalServer("failed to get bypass"))
return
}
for _, bypass := range bypasses {
if bypass.ID == bypassID {
errors.WriteJSONSimple(w, http.StatusOK, map[string]interface{}{
"bypass": bypass,
})
return
}
}
errors.WriteErrorSimple(w, errors.NotFound("bypass not found"))
}
// UpdateBypassRequest represents the request body for updating a bypass
type UpdateBypassRequest struct {
Active *bool `json:"active,omitempty"`
Reason string `json:"reason,omitempty"`
ExpiresInHours int `json:"expires_in_hours,omitempty"`
}
// handleUpdateBypass updates a bypass (activate/deactivate or extend expiration)
func (a *App) handleUpdateBypass(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Extract ID from path
path := strings.TrimPrefix(r.URL.Path, "/api/admin/bypasses/")
bypassID := path
if bypassID == "" {
errors.WriteErrorSimple(w, errors.BadRequest("bypass ID is required"))
return
}
// Parse request body
body, err := io.ReadAll(r.Body)
if err != nil {
errors.WriteErrorSimple(w, errors.BadRequest("failed to read request body"))
return
}
defer r.Body.Close()
var req UpdateBypassRequest
if err := json.Unmarshal(body, &req); err != nil {
errors.WriteErrorSimple(w, errors.BadRequest("invalid JSON in request body"))
return
}
// Get current bypass
bypasses, err := a.metadata.ListCVEBypasses(ctx, &metadata.BypassListOptions{
IncludeExpired: true,
})
if err != nil {
log.Error().Err(err).Msg("Failed to list bypasses")
errors.WriteErrorSimple(w, errors.InternalServer("failed to get bypass"))
return
}
var currentBypass *metadata.CVEBypass
for _, bypass := range bypasses {
if bypass.ID == bypassID {
currentBypass = bypass
break
}
}
if currentBypass == nil {
errors.WriteErrorSimple(w, errors.NotFound("bypass not found"))
return
}
// Update fields
if req.Active != nil {
currentBypass.Active = *req.Active
}
if req.Reason != "" {
currentBypass.Reason = req.Reason
}
if req.ExpiresInHours > 0 {
currentBypass.ExpiresAt = time.Now().Add(time.Duration(req.ExpiresInHours) * time.Hour)
}
// Save updated bypass
if err := a.metadata.SaveCVEBypass(ctx, currentBypass); err != nil {
log.Error().Err(err).Msg("Failed to update bypass")
errors.WriteErrorSimple(w, errors.InternalServer("failed to update bypass"))
return
}
log.Info().
Str("bypass_id", currentBypass.ID).
Bool("active", currentBypass.Active).
Msg("CVE bypass updated")
errors.WriteJSONSimple(w, http.StatusOK, map[string]interface{}{
"bypass": currentBypass,
"message": "Bypass updated successfully",
})
}
// handleDeleteBypass deletes a bypass
func (a *App) handleDeleteBypass(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Extract ID from path
path := strings.TrimPrefix(r.URL.Path, "/api/admin/bypasses/")
bypassID := path
if bypassID == "" {
errors.WriteErrorSimple(w, errors.BadRequest("bypass ID is required"))
return
}
// Delete bypass
if err := a.metadata.DeleteCVEBypass(ctx, bypassID); err != nil {
if strings.Contains(err.Error(), "not found") {
errors.WriteErrorSimple(w, errors.NotFound("bypass not found"))
} else {
log.Error().Err(err).Msg("Failed to delete bypass")
errors.WriteErrorSimple(w, errors.InternalServer("failed to delete bypass"))
}
return
}
log.Info().
Str("bypass_id", bypassID).
Msg("CVE bypass deleted")
errors.WriteJSONSimple(w, http.StatusOK, map[string]interface{}{
"deleted": true,
"bypass_id": bypassID,
"message": "Bypass deleted successfully",
})
}
+160
View File
@@ -0,0 +1,160 @@
package app
import (
"net/http"
"strings"
"github.com/lukaszraczylo/gohoarder/pkg/errors"
"github.com/lukaszraczylo/gohoarder/pkg/metadata"
"github.com/rs/zerolog/log"
)
// handleVulnerabilities handles /api/packages/{registry}/{name}/{version}/vulnerabilities endpoint
func (a *App) handleVulnerabilities(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
if r.Method != "GET" {
errors.WriteErrorSimple(w, errors.BadRequest("method not allowed"))
return
}
ctx := r.Context()
// Parse path: /api/packages/{registry}/{name}/{version}/vulnerabilities
path := strings.TrimPrefix(r.URL.Path, "/api/packages/")
path = strings.TrimSuffix(path, "/vulnerabilities")
parts := strings.Split(path, "/")
if len(parts) < 3 {
errors.WriteErrorSimple(w, errors.BadRequest("invalid path format, expected /api/packages/{registry}/{name}/{version}/vulnerabilities"))
return
}
registry := parts[0]
version := parts[len(parts)-1]
name := strings.Join(parts[1:len(parts)-1], "/")
log.Debug().
Str("registry", registry).
Str("name", name).
Str("version", version).
Msg("Getting vulnerabilities for package")
// Get scan result from metadata store
scanResult, err := a.metadata.GetScanResult(ctx, registry, name, version)
if err != nil {
// Check if package exists
pkg, pkgErr := a.metadata.GetPackage(ctx, registry, name, version)
if pkgErr != nil {
errors.WriteErrorSimple(w, errors.NotFound("package not found"))
return
}
// Package exists but not scanned yet
errors.WriteJSONSimple(w, http.StatusOK, map[string]interface{}{
"package": map[string]string{
"registry": registry,
"name": name,
"version": version,
},
"scanned": false,
"status": "pending",
"vulnerabilities": []interface{}{},
"vulnerability_count": 0,
"message": "Package not yet scanned for vulnerabilities",
"security_scanned": pkg.SecurityScanned,
})
return
}
// Get active bypasses to show which vulnerabilities are bypassed
bypasses, err := a.metadata.GetActiveCVEBypasses(ctx)
if err != nil {
log.Warn().Err(err).Msg("Failed to get CVE bypasses")
bypasses = []*metadata.CVEBypass{}
}
// Build bypass map for fast lookup
bypassedCVEs := make(map[string]*metadata.CVEBypass)
packageKey := registry + "/" + name + "@" + version
packageKeyNoVersion := registry + "/" + name
for _, bypass := range bypasses {
if bypass.Type == metadata.BypassTypeCVE && bypass.Active {
// Check if bypass applies to this package
if bypass.AppliesTo == "" || bypass.AppliesTo == packageKey || bypass.AppliesTo == packageKeyNoVersion {
bypassedCVEs[strings.ToUpper(bypass.Target)] = bypass
}
}
}
// Enrich vulnerabilities with bypass information
enrichedVulns := make([]map[string]interface{}, 0, len(scanResult.Vulnerabilities))
severityCounts := make(map[string]int)
for _, vuln := range scanResult.Vulnerabilities {
bypassed := false
var bypassInfo map[string]interface{}
// Check if this CVE is bypassed
if bypass, ok := bypassedCVEs[strings.ToUpper(vuln.ID)]; ok {
bypassed = true
bypassInfo = map[string]interface{}{
"id": bypass.ID,
"reason": bypass.Reason,
"created_by": bypass.CreatedBy,
"expires_at": bypass.ExpiresAt,
}
} else {
// Count non-bypassed vulnerabilities by severity
severityCounts[strings.ToUpper(vuln.Severity)]++
}
enrichedVuln := map[string]interface{}{
"id": vuln.ID,
"severity": vuln.Severity,
"title": vuln.Title,
"description": vuln.Description,
"references": vuln.References,
"fixed_in": vuln.FixedIn,
"bypassed": bypassed,
}
if bypassed {
enrichedVuln["bypass"] = bypassInfo
}
enrichedVulns = append(enrichedVulns, enrichedVuln)
}
// Build response
response := map[string]interface{}{
"package": map[string]string{
"registry": registry,
"name": name,
"version": version,
},
"scanned": true,
"scanner": scanResult.Scanner,
"scanned_at": scanResult.ScannedAt,
"status": scanResult.Status,
"vulnerabilities": enrichedVulns,
"vulnerability_count": scanResult.VulnerabilityCount,
"severity_counts": map[string]int{
"critical": severityCounts["CRITICAL"],
"high": severityCounts["HIGH"],
"medium": severityCounts["MEDIUM"],
"low": severityCounts["LOW"],
},
"bypassed_count": len(scanResult.Vulnerabilities) - (severityCounts["CRITICAL"] + severityCounts["HIGH"] + severityCounts["MEDIUM"] + severityCounts["LOW"]),
}
errors.WriteJSONSimple(w, http.StatusOK, response)
}