2026-01-20 21:52:44 +00:00
|
|
|
package handler
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"net/http"
|
|
|
|
|
"strings"
|
2026-10-11 01:50:16 +02:00
|
|
|
|
|
|
|
|
"github.com/git-pkgs/proxy/internal/retention"
|
2026-01-20 21:52:44 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
cranUpstream = "https://cloud.r-project.org"
|
|
|
|
|
)
|
|
|
|
|
|
2026-10-11 01:50:16 +02:00
|
|
|
// Source and binary packages are stored under pkg:cran/{name}, the name from
|
|
|
|
|
// the filename with its case kept, whether they came from src/contrib, its
|
|
|
|
|
// Archive or a binary tree. purl keeps cran names as they are, so package
|
|
|
|
|
// rules use the default key mapping.
|
|
|
|
|
func init() {
|
|
|
|
|
retention.Default.Register(retention.Spec{Key: "cran", CanonicalPackage: retention.DefaultCanonical("cran")})
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
// CRANHandler handles CRAN (R) registry protocol requests.
|
|
|
|
|
type CRANHandler struct {
|
|
|
|
|
proxy *Proxy
|
|
|
|
|
upstreamURL string
|
|
|
|
|
proxyURL string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// NewCRANHandler creates a new CRAN protocol handler.
|
|
|
|
|
func NewCRANHandler(proxy *Proxy, proxyURL string) *CRANHandler {
|
|
|
|
|
return &CRANHandler{
|
|
|
|
|
proxy: proxy,
|
|
|
|
|
upstreamURL: cranUpstream,
|
|
|
|
|
proxyURL: strings.TrimSuffix(proxyURL, "/"),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-29 11:19:54 +01:00
|
|
|
// NewCRANHandlerWithUpstream creates a CRAN handler with a custom upstream.
|
|
|
|
|
func NewCRANHandlerWithUpstream(proxy *Proxy, proxyURL, upstreamURL string) *CRANHandler {
|
|
|
|
|
h := NewCRANHandler(proxy, proxyURL)
|
|
|
|
|
h.upstreamURL = configuredUpstreamURL(upstreamURL, cranUpstream)
|
|
|
|
|
return h
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
// Routes returns the HTTP handler for CRAN requests.
|
|
|
|
|
func (h *CRANHandler) Routes() http.Handler {
|
|
|
|
|
mux := http.NewServeMux()
|
|
|
|
|
|
|
|
|
|
// Package indexes
|
2026-03-19 21:06:02 +00:00
|
|
|
mux.HandleFunc("GET /src/contrib/PACKAGES", h.proxyCached)
|
|
|
|
|
mux.HandleFunc("GET /src/contrib/PACKAGES.gz", h.proxyCached)
|
|
|
|
|
mux.HandleFunc("GET /src/contrib/PACKAGES.rds", h.proxyCached)
|
2026-01-20 21:52:44 +00:00
|
|
|
|
|
|
|
|
// Binary package indexes
|
2026-03-19 21:06:02 +00:00
|
|
|
mux.HandleFunc("GET /bin/{platform}/contrib/{rversion}/PACKAGES", h.proxyCached)
|
|
|
|
|
mux.HandleFunc("GET /bin/{platform}/contrib/{rversion}/PACKAGES.gz", h.proxyCached)
|
|
|
|
|
mux.HandleFunc("GET /bin/{platform}/contrib/{rversion}/PACKAGES.rds", h.proxyCached)
|
2026-01-20 21:52:44 +00:00
|
|
|
|
|
|
|
|
// Source package downloads
|
|
|
|
|
mux.HandleFunc("GET /src/contrib/{filename}", h.handleSourceDownload)
|
|
|
|
|
mux.HandleFunc("GET /src/contrib/Archive/{name}/{filename}", h.handleSourceDownload)
|
|
|
|
|
|
|
|
|
|
// Binary package downloads
|
|
|
|
|
mux.HandleFunc("GET /bin/{platform}/contrib/{rversion}/{filename}", h.handleBinaryDownload)
|
|
|
|
|
|
|
|
|
|
return mux
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// handleSourceDownload serves a source package, fetching and caching from upstream.
|
|
|
|
|
func (h *CRANHandler) handleSourceDownload(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
filename := r.PathValue("filename")
|
|
|
|
|
archiveName := r.PathValue("name") // empty for current packages
|
|
|
|
|
|
|
|
|
|
if !strings.HasSuffix(filename, ".tar.gz") {
|
|
|
|
|
h.proxyUpstream(w, r)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
name, version := h.parseSourceFilename(filename)
|
|
|
|
|
if name == "" {
|
|
|
|
|
h.proxyUpstream(w, r)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
h.proxy.Logger.Info("cran source download",
|
|
|
|
|
"name", name, "version", version, "archive", archiveName)
|
|
|
|
|
|
|
|
|
|
upstreamURL := h.upstreamURL + r.URL.Path
|
|
|
|
|
|
|
|
|
|
result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "cran", name, version, filename, upstreamURL)
|
|
|
|
|
if err != nil {
|
2026-07-26 20:07:46 +02:00
|
|
|
h.proxy.serveArtifactError(w, err, "failed to fetch package")
|
2026-01-20 21:52:44 +00:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
Support HTTP byte-range requests for local file cached artifact downloads (#404)
* feat(storage): expose efficient seek for local files
Open file-backed artifacts with os.Open so local readers support
efficient seeking. Leave cloud storage readers unchanged.
Keep the legacy sidecar mapping test focused on fileblob's reader:
Blob.Open now reads local files directly and no longer consults the
sidecar. Continue checking sidecar cleanup and post-store reads.
* feat(handler): preserve efficient seek through integrity checks
Keep the cache result's single Reader field and preserve io.Seeker
when the underlying storage reader supports efficient seeking.
Sequential reads continue through whole-object integrity verification.
A successful seek switches subsequent reads to the source for range
responses, where full-object verification is not possible.
Test both modes and verify the source is closed once.
* feat(handler): add request-aware byte range responses
Add a request-aware artifact serving helper for bounded, open-ended,
and suffix byte ranges, including If-Range handling, 206 responses,
and 416 responses for valid unsatisfiable ranges.
Ignore malformed and multi-range requests by serving the full artifact.
Advertise byte-range support only for seekable readers, preserving HEAD,
redirect, and non-seekable behavior. Clarify that net/http recovers
ErrAbortHandler per request and keeps the server running.
* feat(handler): enable byte ranges across artifact downloads
Forward each artifact request to the shared range-aware response helper,
including cached generic release assets and OCI blobs. This lets the
handler honor ranges only when its reader supports efficient seeking,
without changing direct-storage redirect behavior.
Add warm-cache endpoint tests that assert partial response headers and
bytes without an upstream refetch. Document supported range behavior,
unsupported reader fallback, and the integrity limitation of partial reads.
2026-10-04 09:48:41 -07:00
|
|
|
ServeArtifactRequest(w, r, result)
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// handleBinaryDownload serves a binary package, fetching and caching from upstream.
|
|
|
|
|
func (h *CRANHandler) handleBinaryDownload(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
platform := r.PathValue("platform")
|
|
|
|
|
rversion := r.PathValue("rversion")
|
|
|
|
|
filename := r.PathValue("filename")
|
|
|
|
|
|
|
|
|
|
if !h.isBinaryPackage(filename) {
|
|
|
|
|
h.proxyUpstream(w, r)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
name, version := h.parseBinaryFilename(filename)
|
|
|
|
|
if name == "" {
|
|
|
|
|
h.proxyUpstream(w, r)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Include platform and R version in stored version
|
|
|
|
|
storageVersion := version + "_" + platform + "_" + rversion
|
|
|
|
|
|
|
|
|
|
h.proxy.Logger.Info("cran binary download",
|
|
|
|
|
"name", name, "version", version, "platform", platform, "rversion", rversion)
|
|
|
|
|
|
|
|
|
|
upstreamURL := h.upstreamURL + r.URL.Path
|
|
|
|
|
|
|
|
|
|
result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "cran", name, storageVersion, filename, upstreamURL)
|
|
|
|
|
if err != nil {
|
2026-07-26 20:07:46 +02:00
|
|
|
h.proxy.serveArtifactError(w, err, "failed to fetch package")
|
2026-01-20 21:52:44 +00:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
Support HTTP byte-range requests for local file cached artifact downloads (#404)
* feat(storage): expose efficient seek for local files
Open file-backed artifacts with os.Open so local readers support
efficient seeking. Leave cloud storage readers unchanged.
Keep the legacy sidecar mapping test focused on fileblob's reader:
Blob.Open now reads local files directly and no longer consults the
sidecar. Continue checking sidecar cleanup and post-store reads.
* feat(handler): preserve efficient seek through integrity checks
Keep the cache result's single Reader field and preserve io.Seeker
when the underlying storage reader supports efficient seeking.
Sequential reads continue through whole-object integrity verification.
A successful seek switches subsequent reads to the source for range
responses, where full-object verification is not possible.
Test both modes and verify the source is closed once.
* feat(handler): add request-aware byte range responses
Add a request-aware artifact serving helper for bounded, open-ended,
and suffix byte ranges, including If-Range handling, 206 responses,
and 416 responses for valid unsatisfiable ranges.
Ignore malformed and multi-range requests by serving the full artifact.
Advertise byte-range support only for seekable readers, preserving HEAD,
redirect, and non-seekable behavior. Clarify that net/http recovers
ErrAbortHandler per request and keeps the server running.
* feat(handler): enable byte ranges across artifact downloads
Forward each artifact request to the shared range-aware response helper,
including cached generic release assets and OCI blobs. This lets the
handler honor ranges only when its reader supports efficient seeking,
without changing direct-storage redirect behavior.
Add warm-cache endpoint tests that assert partial response headers and
bytes without an upstream refetch. Document supported range behavior,
unsupported reader fallback, and the integrity limitation of partial reads.
2026-10-04 09:48:41 -07:00
|
|
|
ServeArtifactRequest(w, r, result)
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// parseSourceFilename extracts name and version from a CRAN source filename.
|
|
|
|
|
// Format: {name}_{version}.tar.gz
|
|
|
|
|
func (h *CRANHandler) parseSourceFilename(filename string) (name, version string) {
|
|
|
|
|
base := strings.TrimSuffix(filename, ".tar.gz")
|
|
|
|
|
idx := strings.LastIndex(base, "_")
|
|
|
|
|
if idx < 0 {
|
|
|
|
|
return "", ""
|
|
|
|
|
}
|
|
|
|
|
return base[:idx], base[idx+1:]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// parseBinaryFilename extracts name and version from a CRAN binary filename.
|
|
|
|
|
// Windows: {name}_{version}.zip
|
|
|
|
|
// macOS: {name}_{version}.tgz
|
|
|
|
|
func (h *CRANHandler) parseBinaryFilename(filename string) (name, version string) {
|
|
|
|
|
base := filename
|
|
|
|
|
for _, ext := range []string{".zip", ".tgz"} {
|
|
|
|
|
if strings.HasSuffix(base, ext) {
|
|
|
|
|
base = strings.TrimSuffix(base, ext)
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
idx := strings.LastIndex(base, "_")
|
|
|
|
|
if idx < 0 {
|
|
|
|
|
return "", ""
|
|
|
|
|
}
|
|
|
|
|
return base[:idx], base[idx+1:]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// isBinaryPackage returns true if the filename is a CRAN binary package.
|
|
|
|
|
func (h *CRANHandler) isBinaryPackage(filename string) bool {
|
|
|
|
|
return strings.HasSuffix(filename, ".zip") || strings.HasSuffix(filename, ".tgz")
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 21:06:02 +00:00
|
|
|
// proxyCached forwards a metadata request with caching.
|
|
|
|
|
func (h *CRANHandler) proxyCached(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
cacheKey := strings.TrimPrefix(r.URL.Path, "/")
|
|
|
|
|
cacheKey = strings.ReplaceAll(cacheKey, "/", "_")
|
|
|
|
|
h.proxy.ProxyCached(w, r, h.upstreamURL+r.URL.Path, "cran", cacheKey, "*/*")
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
// proxyUpstream forwards a request to CRAN without caching.
|
|
|
|
|
func (h *CRANHandler) proxyUpstream(w http.ResponseWriter, r *http.Request) {
|
2026-05-05 10:25:17 +01:00
|
|
|
h.proxy.ProxyUpstream(w, r, h.upstreamURL+r.URL.Path, []string{headerAcceptEncoding})
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|