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"
|
2026-03-12 12:05:52 +00:00
|
|
|
"strings"
|
2026-01-20 21:52:44 +00:00
|
|
|
"time"
|
|
|
|
|
|
2026-03-04 19:00:31 +00:00
|
|
|
"github.com/git-pkgs/proxy/internal/cooldown"
|
2026-01-20 21:52:44 +00:00
|
|
|
"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"
|
2026-03-04 09:20:16 +00:00
|
|
|
"github.com/git-pkgs/purl"
|
2026-02-20 08:47:14 +00:00
|
|
|
"github.com/git-pkgs/registries/fetch"
|
2026-01-20 21:52:44 +00:00
|
|
|
)
|
|
|
|
|
|
2026-03-12 12:05:52 +00:00
|
|
|
// containsPathTraversal returns true if the path contains ".." segments
|
|
|
|
|
// that could be used to escape the intended directory.
|
|
|
|
|
func containsPathTraversal(path string) bool {
|
|
|
|
|
for _, segment := range strings.Split(path, "/") {
|
|
|
|
|
if segment == ".." {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 10:59:29 +00:00
|
|
|
const defaultHTTPTimeout = 30 * time.Second
|
|
|
|
|
|
2026-03-12 12:04:12 +00:00
|
|
|
// maxMetadataSize is the maximum size of upstream metadata responses (50 MB).
|
|
|
|
|
// Package metadata (e.g. npm with many versions) can be large, but unbounded
|
|
|
|
|
// reads risk OOM if an upstream misbehaves.
|
|
|
|
|
const maxMetadataSize = 50 << 20
|
|
|
|
|
|
|
|
|
|
// ReadMetadata reads an upstream response body with a size limit to prevent OOM
|
|
|
|
|
// from unexpectedly large responses.
|
|
|
|
|
func ReadMetadata(r io.Reader) ([]byte, error) {
|
|
|
|
|
return io.ReadAll(io.LimitReader(r, maxMetadataSize))
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
// Proxy provides shared functionality for protocol handlers.
|
|
|
|
|
type Proxy struct {
|
2026-03-13 07:44:11 +00:00
|
|
|
DB *database.DB
|
|
|
|
|
Storage storage.Storage
|
|
|
|
|
Fetcher fetch.FetcherInterface
|
|
|
|
|
Resolver *fetch.Resolver
|
|
|
|
|
Logger *slog.Logger
|
|
|
|
|
Cooldown *cooldown.Config
|
|
|
|
|
HTTPClient *http.Client
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// NewProxy creates a new Proxy with the given dependencies.
|
2026-02-20 08:47:14 +00:00
|
|
|
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,
|
2026-03-13 07:44:11 +00:00
|
|
|
HTTPClient: &http.Client{
|
2026-03-18 10:59:29 +00:00
|
|
|
Timeout: defaultHTTPTimeout,
|
2026-03-13 07:44:11 +00:00
|
|
|
},
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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) {
|
2026-03-04 09:20:16 +00:00
|
|
|
pkgPURL := purl.MakePURLString(ecosystem, name, "")
|
|
|
|
|
versionPURL := purl.MakePURLString(ecosystem, name, version)
|
2026-01-20 21:52:44 +00:00
|
|
|
|
2026-01-21 22:47:23 +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)
|
|
|
|
|
}
|
2026-01-21 22:47:23 +00:00
|
|
|
if pkg == nil {
|
|
|
|
|
return nil, nil
|
|
|
|
|
}
|
2026-01-20 21:52:44 +00:00
|
|
|
|
2026-01-21 22:47:23 +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
|
|
|
}
|
|
|
|
|
|
2026-01-29 16:44:01 +00:00
|
|
|
artifact, err := p.DB.GetArtifact(versionPURL, filename)
|
2026-01-21 22:47:23 +00:00
|
|
|
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()
|
2026-01-21 22:47:23 +00:00
|
|
|
reader, err := p.Storage.Open(ctx, artifact.StoragePath.String)
|
2026-02-03 22:40:23 +00:00
|
|
|
metrics.RecordStorageOperation("read", time.Since(start))
|
2026-01-21 22:47:23 +00:00
|
|
|
if err != nil {
|
2026-02-03 22:40:23 +00:00
|
|
|
metrics.RecordStorageError("read")
|
2026-01-21 22:47:23 +00:00
|
|
|
p.Logger.Warn("cached artifact missing from storage, will refetch",
|
|
|
|
|
"path", artifact.StoragePath.String, "error", err)
|
|
|
|
|
return nil, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-29 16:44:01 +00:00
|
|
|
_ = p.DB.RecordArtifactHit(versionPURL, filename)
|
2026-02-03 22:40:23 +00:00
|
|
|
|
|
|
|
|
// Extract ecosystem from pkgPURL for metrics
|
2026-03-04 09:20:16 +00:00
|
|
|
if p, err := purl.Parse(pkgPURL); err == nil {
|
|
|
|
|
metrics.RecordCacheHit(purl.PURLTypeToEcosystem(p.Type))
|
|
|
|
|
}
|
2026-02-03 22:40:23 +00:00
|
|
|
|
2026-01-21 22:47: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
|
2026-03-18 10:59:29 +00:00
|
|
|
if err := p.updateCacheDB(ecosystem, name, filename, pkgPURL, versionPURL, info.URL, storagePath, hash, size, artifact.ContentType); err != nil {
|
2026-01-20 21:52:44 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 10:59:29 +00:00
|
|
|
func (p *Proxy) updateCacheDB(ecosystem, name, filename, pkgPURL, versionPURL, upstreamURL, storagePath, hash string, size int64, contentType string) error {
|
2026-01-20 21:52:44 +00:00
|
|
|
now := time.Now()
|
|
|
|
|
|
|
|
|
|
// Upsert package
|
|
|
|
|
pkg := &database.Package{
|
2026-01-29 16:44:01 +00:00
|
|
|
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
|
|
|
}
|
2026-01-29 16:44:01 +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{
|
2026-01-29 16:44:01 +00:00
|
|
|
PURL: versionPURL,
|
|
|
|
|
PackagePURL: pkgPURL,
|
|
|
|
|
EnrichedAt: sql.NullTime{Time: now, Valid: true},
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
2026-01-29 16:44:01 +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{
|
2026-01-29 16:44:01 +00:00
|
|
|
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},
|
|
|
|
|
}
|
2026-01-29 16:44:01 +00:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 10:59:29 +00:00
|
|
|
// ProxyUpstream forwards a request to an upstream URL without caching.
|
|
|
|
|
// It copies the request, forwards specified headers, and streams the response back.
|
|
|
|
|
// If forwardHeaders is nil, all response headers are copied.
|
|
|
|
|
func (p *Proxy) ProxyUpstream(w http.ResponseWriter, r *http.Request, upstreamURL string, forwardHeaders []string) {
|
|
|
|
|
p.Logger.Debug("proxying to upstream", "url", upstreamURL)
|
|
|
|
|
|
|
|
|
|
req, err := http.NewRequestWithContext(r.Context(), r.Method, upstreamURL, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
http.Error(w, "failed to create request", http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Copy request headers that affect content negotiation / caching
|
|
|
|
|
for _, header := range forwardHeaders {
|
|
|
|
|
if v := r.Header.Get(header); v != "" {
|
|
|
|
|
req.Header.Set(header, v)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resp, err := p.HTTPClient.Do(req)
|
|
|
|
|
if err != nil {
|
|
|
|
|
p.Logger.Error("upstream request failed", "error", err)
|
|
|
|
|
http.Error(w, "upstream request failed", http.StatusBadGateway)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
|
|
|
|
|
|
for k, vv := range resp.Header {
|
|
|
|
|
for _, v := range vv {
|
|
|
|
|
w.Header().Add(k, v)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
w.WriteHeader(resp.StatusCode)
|
|
|
|
|
_, _ = io.Copy(w, resp.Body)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ProxyMetadata forwards a metadata request to upstream, copying only specific response headers.
|
|
|
|
|
func (p *Proxy) ProxyMetadata(w http.ResponseWriter, r *http.Request, upstreamURL string, logLabel string) {
|
|
|
|
|
p.Logger.Debug(logLabel+" metadata request", "url", upstreamURL)
|
|
|
|
|
|
|
|
|
|
req, err := http.NewRequestWithContext(r.Context(), r.Method, upstreamURL, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
http.Error(w, "failed to create request", http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, header := range []string{"Accept", "Accept-Encoding", "If-Modified-Since", "If-None-Match"} {
|
|
|
|
|
if v := r.Header.Get(header); v != "" {
|
|
|
|
|
req.Header.Set(header, v)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resp, err := p.HTTPClient.Do(req)
|
|
|
|
|
if err != nil {
|
|
|
|
|
p.Logger.Error("failed to fetch upstream metadata", "error", err)
|
|
|
|
|
http.Error(w, "failed to fetch from upstream", http.StatusBadGateway)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
|
|
|
|
|
|
for _, header := range []string{"Content-Type", "Content-Length", "Last-Modified", "ETag"} {
|
|
|
|
|
if v := resp.Header.Get(header); v != "" {
|
|
|
|
|
w.Header().Set(header, v)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
w.WriteHeader(resp.StatusCode)
|
|
|
|
|
_, _ = io.Copy(w, resp.Body)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ProxyFile forwards a file request to upstream, copying all response headers.
|
|
|
|
|
func (p *Proxy) ProxyFile(w http.ResponseWriter, r *http.Request, upstreamURL string) {
|
|
|
|
|
req, err := http.NewRequestWithContext(r.Context(), r.Method, upstreamURL, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
http.Error(w, "failed to create request", http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resp, err := p.HTTPClient.Do(req)
|
|
|
|
|
if err != nil {
|
|
|
|
|
http.Error(w, "failed to fetch from upstream", http.StatusBadGateway)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
|
|
|
|
|
|
for key, values := range resp.Header {
|
|
|
|
|
for _, v := range values {
|
|
|
|
|
w.Header().Add(key, v)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
w.WriteHeader(resp.StatusCode)
|
|
|
|
|
_, _ = io.Copy(w, resp.Body)
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
// 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) {
|
2026-03-04 09:20:16 +00:00
|
|
|
pkgPURL := purl.MakePURLString(ecosystem, name, "")
|
|
|
|
|
versionPURL := purl.MakePURLString(ecosystem, name, version)
|
2026-01-20 21:52:44 +00:00
|
|
|
|
2026-01-21 22:47:23 +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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 10:59:29 +00:00
|
|
|
if err := p.updateCacheDB(ecosystem, name, filename, pkgPURL, versionPURL, downloadURL, storagePath, hash, size, artifact.ContentType); err != nil {
|
2026-01-20 21:52:44 +00:00
|
|
|
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
|
|
|
|