1
0
Fork 1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-07-07 14:53:20 -04:00
pkg-proxy/internal/handler/handler.go

301 lines
9.1 KiB
Go
Raw Permalink Normal View History

2026-01-20 21:52:44 +00:00
// Package handler provides HTTP protocol handlers for package manager proxying.
package handler
import (
"context"
"database/sql"
"fmt"
"io"
"log/slog"
"net/http"
"time"
"github.com/git-pkgs/proxy/internal/database"
2026-02-03 22:40:23 +00:00
"github.com/git-pkgs/proxy/internal/metrics"
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"
2026-01-20 21:52:44 +00:00
)
// Proxy provides shared functionality for protocol handlers.
type Proxy struct {
DB *database.DB
Storage storage.Storage
Fetcher fetch.FetcherInterface
Resolver *fetch.Resolver
2026-01-20 21:52:44 +00:00
Logger *slog.Logger
}
// NewProxy creates a new Proxy with the given dependencies.
func NewProxy(db *database.DB, store storage.Storage, fetcher fetch.FetcherInterface, resolver *fetch.Resolver, logger *slog.Logger) *Proxy {
2026-01-20 21:52:44 +00:00
if logger == nil {
logger = slog.Default()
}
return &Proxy{
DB: db,
Storage: store,
Fetcher: fetcher,
Resolver: resolver,
Logger: logger,
}
}
// CacheResult contains information about a cached or fetched artifact.
type CacheResult struct {
Reader io.ReadCloser
Size int64
ContentType string
Hash string
Cached bool
}
// GetOrFetchArtifact retrieves an artifact from cache or fetches from upstream.
func (p *Proxy) GetOrFetchArtifact(ctx context.Context, ecosystem, name, version, filename string) (*CacheResult, error) {
pkgPURL := purl.MakePURLString(ecosystem, name, "")
versionPURL := purl.MakePURLString(ecosystem, name, version)
2026-01-20 21:52:44 +00:00
if cached, err := p.checkCache(ctx, pkgPURL, versionPURL, filename); err != nil {
return nil, err
} else if cached != nil {
return cached, nil
}
return p.fetchAndCache(ctx, ecosystem, name, version, filename, pkgPURL, versionPURL)
}
// checkCache looks up an artifact in the cache. Returns nil if not cached.
func (p *Proxy) checkCache(ctx context.Context, pkgPURL, versionPURL, filename string) (*CacheResult, error) {
2026-01-20 21:52:44 +00:00
pkg, err := p.DB.GetPackageByPURL(pkgPURL)
if err != nil {
return nil, fmt.Errorf("checking package cache: %w", err)
}
if pkg == nil {
return nil, nil
}
2026-01-20 21:52:44 +00:00
ver, err := p.DB.GetVersionByPURL(versionPURL)
if err != nil {
return nil, fmt.Errorf("checking version cache: %w", err)
}
if ver == nil {
return nil, nil
2026-01-20 21:52:44 +00:00
}
artifact, err := p.DB.GetArtifact(versionPURL, filename)
if err != nil {
return nil, fmt.Errorf("checking artifact cache: %w", err)
}
if artifact == nil || !artifact.IsCached() {
return nil, nil
}
2026-02-03 22:40:23 +00:00
start := time.Now()
reader, err := p.Storage.Open(ctx, artifact.StoragePath.String)
2026-02-03 22:40:23 +00:00
metrics.RecordStorageOperation("read", time.Since(start))
if err != nil {
2026-02-03 22:40:23 +00:00
metrics.RecordStorageError("read")
p.Logger.Warn("cached artifact missing from storage, will refetch",
"path", artifact.StoragePath.String, "error", err)
return nil, nil
}
_ = p.DB.RecordArtifactHit(versionPURL, filename)
2026-02-03 22:40:23 +00:00
// Extract ecosystem from pkgPURL for metrics
if p, err := purl.Parse(pkgPURL); err == nil {
metrics.RecordCacheHit(purl.PURLTypeToEcosystem(p.Type))
}
2026-02-03 22:40:23 +00:00
return &CacheResult{
Reader: reader,
Size: artifact.Size.Int64,
ContentType: artifact.ContentType.String,
Hash: artifact.ContentHash.String,
Cached: true,
}, nil
2026-01-20 21:52:44 +00:00
}
func (p *Proxy) fetchAndCache(ctx context.Context, ecosystem, name, version, filename, pkgPURL, versionPURL string) (*CacheResult, error) {
2026-02-03 22:40:23 +00:00
// Record cache miss
metrics.RecordCacheMiss(ecosystem)
2026-01-20 21:52:44 +00:00
// Resolve download URL
info, err := p.Resolver.Resolve(ctx, ecosystem, name, version)
if err != nil {
return nil, fmt.Errorf("resolving download URL: %w", err)
}
// Use resolved filename if provided filename is empty
if filename == "" {
filename = info.Filename
}
p.Logger.Info("fetching from upstream",
"ecosystem", ecosystem, "name", name, "version", version, "url", info.URL)
2026-02-03 22:40:23 +00:00
// Fetch from upstream with timing
fetchStart := time.Now()
2026-01-20 21:52:44 +00:00
artifact, err := p.Fetcher.Fetch(ctx, info.URL)
2026-02-03 22:40:23 +00:00
fetchDuration := time.Since(fetchStart)
2026-01-20 21:52:44 +00:00
if err != nil {
2026-02-03 22:40:23 +00:00
metrics.RecordUpstreamFetch(ecosystem, fetchDuration)
metrics.RecordUpstreamError(ecosystem, "fetch_failed")
2026-01-20 21:52:44 +00:00
return nil, fmt.Errorf("fetching from upstream: %w", err)
}
2026-02-03 22:40:23 +00:00
metrics.RecordUpstreamFetch(ecosystem, fetchDuration)
2026-01-20 21:52:44 +00:00
// Store in cache
storagePath := storage.ArtifactPath(ecosystem, "", name, version, filename)
2026-02-03 22:40:23 +00:00
storeStart := time.Now()
2026-01-20 21:52:44 +00:00
size, hash, err := p.Storage.Store(ctx, storagePath, artifact.Body)
_ = artifact.Body.Close()
2026-02-03 22:40:23 +00:00
metrics.RecordStorageOperation("write", time.Since(storeStart))
2026-01-20 21:52:44 +00:00
if err != nil {
2026-02-03 22:40:23 +00:00
metrics.RecordStorageError("write")
2026-01-20 21:52:44 +00:00
return nil, fmt.Errorf("storing artifact: %w", err)
}
// Update database
if err := p.updateCacheDB(ctx, ecosystem, name, version, filename, pkgPURL, versionPURL, info.URL, storagePath, hash, size, artifact.ContentType); err != nil {
p.Logger.Warn("failed to update cache database", "error", err)
// Continue anyway - we have the file
}
// Open the stored file to return
2026-02-03 22:40:23 +00:00
readStart := time.Now()
2026-01-20 21:52:44 +00:00
reader, err := p.Storage.Open(ctx, storagePath)
2026-02-03 22:40:23 +00:00
metrics.RecordStorageOperation("read", time.Since(readStart))
2026-01-20 21:52:44 +00:00
if err != nil {
2026-02-03 22:40:23 +00:00
metrics.RecordStorageError("read")
2026-01-20 21:52:44 +00:00
return nil, fmt.Errorf("opening cached artifact: %w", err)
}
return &CacheResult{
Reader: reader,
Size: size,
ContentType: artifact.ContentType,
Hash: hash,
Cached: false,
}, nil
}
func (p *Proxy) updateCacheDB(ctx context.Context, ecosystem, name, version, filename, pkgPURL, versionPURL, upstreamURL, storagePath, hash string, size int64, contentType string) error {
now := time.Now()
// Upsert package
pkg := &database.Package{
PURL: pkgPURL,
Ecosystem: ecosystem,
Name: name,
RegistryURL: sql.NullString{String: upstreamURL, Valid: true},
EnrichedAt: sql.NullTime{Time: now, Valid: true},
2026-01-20 21:52:44 +00:00
}
if err := p.DB.UpsertPackage(pkg); err != nil {
2026-01-20 21:52:44 +00:00
return fmt.Errorf("upserting package: %w", err)
}
// Upsert version
ver := &database.Version{
PURL: versionPURL,
PackagePURL: pkgPURL,
EnrichedAt: sql.NullTime{Time: now, Valid: true},
2026-01-20 21:52:44 +00:00
}
if err := p.DB.UpsertVersion(ver); err != nil {
2026-01-20 21:52:44 +00:00
return fmt.Errorf("upserting version: %w", err)
}
// Upsert artifact
art := &database.Artifact{
VersionPURL: versionPURL,
2026-01-20 21:52:44 +00:00
Filename: filename,
UpstreamURL: upstreamURL,
StoragePath: sql.NullString{String: storagePath, Valid: true},
ContentHash: sql.NullString{String: hash, Valid: true},
Size: sql.NullInt64{Int64: size, Valid: true},
ContentType: sql.NullString{String: contentType, Valid: true},
FetchedAt: sql.NullTime{Time: now, Valid: true},
}
if err := p.DB.UpsertArtifact(art); err != nil {
2026-01-20 21:52:44 +00:00
return fmt.Errorf("upserting artifact: %w", err)
}
return nil
}
// ServeArtifact writes a CacheResult to an HTTP response.
func ServeArtifact(w http.ResponseWriter, result *CacheResult) {
defer func() { _ = result.Reader.Close() }()
if result.ContentType != "" {
w.Header().Set("Content-Type", result.ContentType)
}
if result.Size > 0 {
w.Header().Set("Content-Length", fmt.Sprintf("%d", result.Size))
}
if result.Hash != "" {
w.Header().Set("ETag", fmt.Sprintf(`"%s"`, result.Hash))
}
w.WriteHeader(http.StatusOK)
_, _ = io.Copy(w, result.Reader)
}
// JSONError writes a JSON error response.
func JSONError(w http.ResponseWriter, status int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = fmt.Fprintf(w, `{"error":%q}`, message)
}
// GetOrFetchArtifactFromURL retrieves an artifact from cache or fetches from a specific URL.
// This is useful for registries where download URLs are determined from metadata.
func (p *Proxy) GetOrFetchArtifactFromURL(ctx context.Context, ecosystem, name, version, filename, downloadURL string) (*CacheResult, error) {
pkgPURL := purl.MakePURLString(ecosystem, name, "")
versionPURL := purl.MakePURLString(ecosystem, name, version)
2026-01-20 21:52:44 +00:00
if cached, err := p.checkCache(ctx, pkgPURL, versionPURL, filename); err != nil {
return nil, err
} else if cached != nil {
return cached, nil
2026-01-20 21:52:44 +00:00
}
return p.fetchAndCacheFromURL(ctx, ecosystem, name, version, filename, pkgPURL, versionPURL, downloadURL)
}
func (p *Proxy) fetchAndCacheFromURL(ctx context.Context, ecosystem, name, version, filename, pkgPURL, versionPURL, downloadURL string) (*CacheResult, error) {
p.Logger.Info("fetching from upstream",
"ecosystem", ecosystem, "name", name, "version", version, "url", downloadURL)
artifact, err := p.Fetcher.Fetch(ctx, downloadURL)
if err != nil {
return nil, fmt.Errorf("fetching from upstream: %w", err)
}
storagePath := storage.ArtifactPath(ecosystem, "", name, version, filename)
size, hash, err := p.Storage.Store(ctx, storagePath, artifact.Body)
_ = artifact.Body.Close()
if err != nil {
return nil, fmt.Errorf("storing artifact: %w", err)
}
if err := p.updateCacheDB(ctx, ecosystem, name, version, filename, pkgPURL, versionPURL, downloadURL, storagePath, hash, size, artifact.ContentType); err != nil {
p.Logger.Warn("failed to update cache database", "error", err)
}
reader, err := p.Storage.Open(ctx, storagePath)
if err != nil {
return nil, fmt.Errorf("opening cached artifact: %w", err)
}
return &CacheResult{
Reader: reader,
Size: size,
ContentType: artifact.ContentType,
Hash: hash,
Cached: false,
}, nil
}
2026-02-03 22:40:23 +00:00