File size: 3,785 Bytes
c3e7865
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ba32591
c3e7865
 
 
 
fa1717e
 
 
 
c3e7865
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fa1717e
 
 
 
 
 
 
 
 
c3e7865
ba32591
 
c3e7865
 
cfeab47
 
c3e7865
ba32591
c3e7865
cfeab47
c3e7865
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
"""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,
    )