VibecoderMcSwaggins's picture
fix(arch): comprehensive architecture audit fixes (#44)
fa1717e unverified
"""File serving routes for NIfTI result files.
BUG-004 FIX: This module replaces the StaticFiles mount approach.
Previously, files were served via:
app.mount("/files", StaticFiles(directory=RESULTS_DIR))
The problem: StaticFiles is a mounted sub-application, and FastAPI/Starlette
middleware (including CORSMiddleware) does NOT propagate to mounted apps.
This caused NiiVue's cross-origin fetch to fail with "Failed to fetch".
Solution: Use explicit route handlers that go through the main app's middleware.
Now CORS headers are correctly applied to file responses.
Reference: https://github.com/fastapi/fastapi/discussions/7319
"""
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse
from stroke_deepisles_demo.core.config import get_settings
from stroke_deepisles_demo.core.logging import get_logger
logger = get_logger(__name__)
# Allowed file extensions (defense-in-depth)
# Only serve NIfTI files to prevent accidental exposure of logs/metadata
_ALLOWED_EXTENSIONS = {".nii", ".nii.gz"}
files_router = APIRouter(prefix="/files", tags=["files"])
@files_router.get("/{job_id}/{case_id}/{filename}")
async def get_result_file(job_id: str, case_id: str, filename: str) -> FileResponse:
"""Serve NIfTI result files with proper CORS headers.
This route goes through the main FastAPI app's middleware stack,
ensuring CORS and CORP headers are applied to the response.
Args:
job_id: The job UUID from segmentation
case_id: The case identifier (e.g., sub-stroke0001)
filename: The NIfTI filename (e.g., dwi.nii.gz, prediction_fused.nii.gz)
Returns:
FileResponse with the NIfTI file
Raises:
404: File not found (job expired, invalid path, or doesn't exist)
"""
# Security: Validate file extension (defense-in-depth)
# Only serve NIfTI files to prevent accidental exposure of logs/metadata
if not any(filename.endswith(ext) for ext in _ALLOWED_EXTENSIONS):
logger.warning("Blocked request for non-NIfTI file: %s", filename)
raise HTTPException(
status_code=404,
detail="Only NIfTI files (.nii, .nii.gz) can be served.",
)
# Construct file path
results_dir = get_settings().results_dir
file_path = results_dir / job_id / case_id / filename
# Security: Ensure path doesn't escape RESULTS_DIR (path traversal protection)
# Using is_relative_to() instead of startswith() to prevent prefix-collision bypass
# e.g., /tmp/stroke-results-evil/file.txt would pass startswith but fail is_relative_to
try:
base_dir = results_dir.resolve()
resolved = file_path.resolve()
if not resolved.is_relative_to(base_dir):
logger.warning("Path traversal attempt blocked: %s", filename)
raise HTTPException(status_code=404, detail="File not found")
except (OSError, ValueError):
raise HTTPException(status_code=404, detail="Invalid file path") from None
# Check file exists
if not resolved.exists() or not resolved.is_file():
logger.debug("File not found: %s", resolved)
raise HTTPException(
status_code=404,
detail=f"File not found: {filename}. Job may have expired (1 hour TTL).",
)
# Determine media type based on extension
# NIfTI files are typically gzip-compressed
if filename.endswith(".nii.gz"):
media_type = "application/gzip"
elif filename.endswith(".nii"):
media_type = "application/octet-stream"
else:
media_type = "application/octet-stream"
logger.debug("Serving file: %s (type: %s)", resolved, media_type)
return FileResponse(
path=resolved,
media_type=media_type,
filename=filename,
)