This commit is contained in:
@@ -97,6 +97,11 @@ from .schemas import (
|
||||
)
|
||||
from .metadata import extract_metadata
|
||||
from .config import get_settings
|
||||
from .checksum import (
|
||||
ChecksumMismatchError,
|
||||
VerifyingStreamWrapper,
|
||||
sha256_to_base64,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -1777,7 +1782,7 @@ def _resolve_artifact_ref(
|
||||
return artifact
|
||||
|
||||
|
||||
# Download artifact with range request support and download modes
|
||||
# Download artifact with range request support, download modes, and verification
|
||||
@router.get("/api/v1/project/{project_name}/{package_name}/+/{ref}")
|
||||
def download_artifact(
|
||||
project_name: str,
|
||||
@@ -1791,7 +1796,34 @@ def download_artifact(
|
||||
default=None,
|
||||
description="Download mode: proxy (stream through backend), redirect (302 to presigned URL), presigned (return JSON with URL)",
|
||||
),
|
||||
verify: bool = Query(
|
||||
default=False,
|
||||
description="Enable checksum verification during download",
|
||||
),
|
||||
verify_mode: Optional[Literal["stream", "pre"]] = Query(
|
||||
default="stream",
|
||||
description="Verification mode: 'stream' (verify after streaming, logs error if mismatch), 'pre' (verify before streaming, returns 500 if mismatch)",
|
||||
),
|
||||
):
|
||||
"""
|
||||
Download an artifact by reference (tag name, artifact:hash, tag:name).
|
||||
|
||||
Verification modes:
|
||||
- verify=false (default): No verification, maximum performance
|
||||
- verify=true&verify_mode=stream: Compute hash while streaming, verify after completion.
|
||||
If mismatch, logs error but content already sent.
|
||||
- verify=true&verify_mode=pre: Download and verify BEFORE streaming to client.
|
||||
Higher latency but guarantees no corrupt data sent.
|
||||
|
||||
Response headers always include:
|
||||
- X-Checksum-SHA256: The expected SHA256 hash
|
||||
- X-Content-Length: File size in bytes
|
||||
- ETag: Artifact ID (SHA256)
|
||||
- Digest: RFC 3230 format sha-256 hash
|
||||
|
||||
When verify=true:
|
||||
- X-Verified: 'true' if verified, 'false' if verification failed
|
||||
"""
|
||||
settings = get_settings()
|
||||
|
||||
# Get project and package
|
||||
@@ -1831,6 +1863,23 @@ def download_artifact(
|
||||
)
|
||||
db.commit()
|
||||
|
||||
# Build common checksum headers (always included)
|
||||
checksum_headers = {
|
||||
"X-Checksum-SHA256": artifact.id,
|
||||
"X-Content-Length": str(artifact.size),
|
||||
"ETag": f'"{artifact.id}"',
|
||||
}
|
||||
# Add RFC 3230 Digest header
|
||||
try:
|
||||
digest_base64 = sha256_to_base64(artifact.id)
|
||||
checksum_headers["Digest"] = f"sha-256={digest_base64}"
|
||||
except Exception:
|
||||
pass # Skip if conversion fails
|
||||
|
||||
# Add MD5 checksum if available
|
||||
if artifact.checksum_md5:
|
||||
checksum_headers["X-Checksum-MD5"] = artifact.checksum_md5
|
||||
|
||||
# Determine download mode (query param overrides server default)
|
||||
download_mode = mode or settings.download_mode
|
||||
|
||||
@@ -1867,7 +1916,7 @@ def download_artifact(
|
||||
return RedirectResponse(url=presigned_url, status_code=302)
|
||||
|
||||
# Proxy mode (default fallback) - stream through backend
|
||||
# Handle range requests
|
||||
# Handle range requests (verification not supported for partial downloads)
|
||||
if range:
|
||||
stream, content_length, content_range = storage.get_stream(
|
||||
artifact.s3_key, range
|
||||
@@ -1877,9 +1926,11 @@ def download_artifact(
|
||||
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Length": str(content_length),
|
||||
**checksum_headers,
|
||||
}
|
||||
if content_range:
|
||||
headers["Content-Range"] = content_range
|
||||
# Note: X-Verified not set for range requests (cannot verify partial content)
|
||||
|
||||
return StreamingResponse(
|
||||
stream,
|
||||
@@ -1888,16 +1939,88 @@ def download_artifact(
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
# Full download
|
||||
# Full download with optional verification
|
||||
base_headers = {
|
||||
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||
"Accept-Ranges": "bytes",
|
||||
**checksum_headers,
|
||||
}
|
||||
|
||||
# Pre-verification mode: verify before streaming
|
||||
if verify and verify_mode == "pre":
|
||||
try:
|
||||
content = storage.get_verified(artifact.s3_key, artifact.id)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type=artifact.content_type or "application/octet-stream",
|
||||
headers={
|
||||
**base_headers,
|
||||
"Content-Length": str(len(content)),
|
||||
"X-Verified": "true",
|
||||
},
|
||||
)
|
||||
except ChecksumMismatchError as e:
|
||||
logger.error(
|
||||
f"Pre-verification failed for artifact {artifact.id[:16]}...: {e.to_dict()}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail={
|
||||
"error": "checksum_verification_failed",
|
||||
"message": "Downloaded content does not match expected checksum",
|
||||
"expected": e.expected,
|
||||
"actual": e.actual,
|
||||
"artifact_id": artifact.id,
|
||||
},
|
||||
)
|
||||
|
||||
# Streaming verification mode: verify while/after streaming
|
||||
if verify and verify_mode == "stream":
|
||||
verifying_wrapper, content_length, _ = storage.get_stream_verified(
|
||||
artifact.s3_key, artifact.id
|
||||
)
|
||||
|
||||
def verified_stream():
|
||||
"""Generator that yields chunks and verifies after completion."""
|
||||
try:
|
||||
for chunk in verifying_wrapper:
|
||||
yield chunk
|
||||
# After all chunks yielded, verify
|
||||
try:
|
||||
verifying_wrapper.verify()
|
||||
logger.info(
|
||||
f"Streaming verification passed for artifact {artifact.id[:16]}..."
|
||||
)
|
||||
except ChecksumMismatchError as e:
|
||||
# Content already sent - log error but cannot reject
|
||||
logger.error(
|
||||
f"Streaming verification FAILED for artifact {artifact.id[:16]}...: "
|
||||
f"expected {e.expected[:16]}..., got {e.actual[:16]}..."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error during streaming download: {e}")
|
||||
raise
|
||||
|
||||
return StreamingResponse(
|
||||
verified_stream(),
|
||||
media_type=artifact.content_type or "application/octet-stream",
|
||||
headers={
|
||||
**base_headers,
|
||||
"Content-Length": str(content_length),
|
||||
"X-Verified": "pending", # Verification happens after streaming
|
||||
},
|
||||
)
|
||||
|
||||
# No verification - direct streaming
|
||||
stream, content_length, _ = storage.get_stream(artifact.s3_key)
|
||||
|
||||
return StreamingResponse(
|
||||
stream,
|
||||
media_type=artifact.content_type or "application/octet-stream",
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||
"Accept-Ranges": "bytes",
|
||||
**base_headers,
|
||||
"Content-Length": str(content_length),
|
||||
"X-Verified": "false",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1975,6 +2098,11 @@ def head_artifact(
|
||||
db: Session = Depends(get_db),
|
||||
storage: S3Storage = Depends(get_storage),
|
||||
):
|
||||
"""
|
||||
Get artifact metadata without downloading content.
|
||||
|
||||
Returns headers with checksum information for client-side verification.
|
||||
"""
|
||||
# Get project and package
|
||||
project = db.query(Project).filter(Project.name == project_name).first()
|
||||
if not project:
|
||||
@@ -1995,15 +2123,32 @@ def head_artifact(
|
||||
|
||||
filename = sanitize_filename(artifact.original_name or f"{artifact.id}")
|
||||
|
||||
# Build headers with checksum information
|
||||
headers = {
|
||||
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Length": str(artifact.size),
|
||||
"X-Artifact-Id": artifact.id,
|
||||
"X-Checksum-SHA256": artifact.id,
|
||||
"X-Content-Length": str(artifact.size),
|
||||
"ETag": f'"{artifact.id}"',
|
||||
}
|
||||
|
||||
# Add RFC 3230 Digest header
|
||||
try:
|
||||
digest_base64 = sha256_to_base64(artifact.id)
|
||||
headers["Digest"] = f"sha-256={digest_base64}"
|
||||
except Exception:
|
||||
pass # Skip if conversion fails
|
||||
|
||||
# Add MD5 checksum if available
|
||||
if artifact.checksum_md5:
|
||||
headers["X-Checksum-MD5"] = artifact.checksum_md5
|
||||
|
||||
return Response(
|
||||
content=b"",
|
||||
media_type=artifact.content_type or "application/octet-stream",
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Length": str(artifact.size),
|
||||
"X-Artifact-Id": artifact.id,
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
|
||||
@@ -2017,9 +2162,19 @@ def download_artifact_compat(
|
||||
db: Session = Depends(get_db),
|
||||
storage: S3Storage = Depends(get_storage),
|
||||
range: Optional[str] = Header(None),
|
||||
verify: bool = Query(default=False),
|
||||
verify_mode: Optional[Literal["stream", "pre"]] = Query(default="stream"),
|
||||
):
|
||||
return download_artifact(
|
||||
project_name, package_name, ref, request, db, storage, range
|
||||
project_name,
|
||||
package_name,
|
||||
ref,
|
||||
request,
|
||||
db,
|
||||
storage,
|
||||
range,
|
||||
verify=verify,
|
||||
verify_mode=verify_mode,
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user