Skip to content

files

tit.server.routes.files

/api/files/* — jailed file reads and bounded custom-mask imports (v1).

Every route resolves its path against the project directory (all catalog routes hand back absolute container paths already) and against the bundled resources/ tree (atlases, electrode caps -- read-only reference data a viewer may legitimately want, e.g. the MNI152 template), and refuses to serve anything that resolves outside both. No path rule beyond that jail check lives here; content comes from tit.catalog (reports) or is read directly for generic artifacts, matching the other v1 route modules.

raw

raw(request: Request, path: str, range_header: str | None = Header(None, alias='Range'), if_none_match: str | None = Header(None, alias='If-None-Match'), if_range: str | None = Header(None, alias='If-Range')) -> Response

Stream one project file to the in-app viewer as opaque bytes.

Unlike /api/files/artifact this is not restricted to the document extension allow-list -- the viewer needs .nii.gz, .msh, .msh.opt, .gii, *_LUT.txt, .lut and friends, none of which that route will serve. The trade is the opposite response policy: every response is application/octet-stream with nosniff and attachment, and the handful of extensions a browser could execute as a document in this origin are refused outright, so nothing served here can ever become a page.

The URL is the file's absolute path minus its leading slash (/api/files/raw/mnt/000/.../T1.nii.gz) rather than a ?path= query, because the engine's loader takes the file name, the gzip decision and its volume-vs-mesh routing from the URL's last segment.

Range/206, If-Range and ETag/304 are supported so a large mesh can be resumed. No response ever carries a Content-Encoding: a .nii.gz must reach the viewer still deflated (it inflates the stream itself), and an encoded body would break both Content-Length and ranges.

This route is the whole reason viewing needs no X11 at all (docs/dev/DECISIONS.md § 2026-09-03 (One Docker image and a real development loop)): bytes are served to the app's own WebGL2 panes, or named by an exported scene the native TetraVox app opens on the host -- never to an external Freeview/Gmsh process here.

Source code in tit/server/routes/files.py
@router.get(
    "/api/files/raw/{path:path}",
    summary="Raw bytes of one volume/mesh/LUT file for the in-app viewer, jailed to the project",
    response_class=Response,
    responses=_RAW_RESPONSES,
)
@router.head("/api/files/raw/{path:path}", include_in_schema=False)
def raw(
    request: Request,
    path: str,
    range_header: str | None = Header(None, alias="Range"),
    if_none_match: str | None = Header(None, alias="If-None-Match"),
    if_range: str | None = Header(None, alias="If-Range"),
) -> Response:
    """Stream one project file to the in-app viewer as opaque bytes.

    Unlike ``/api/files/artifact`` this is not restricted to the document
    extension allow-list -- the viewer needs ``.nii.gz``, ``.msh``,
    ``.msh.opt``, ``.gii``, ``*_LUT.txt``, ``.lut`` and friends, none of
    which that route will serve. The trade is the opposite response policy:
    every response is ``application/octet-stream`` with ``nosniff`` and
    ``attachment``, and the handful of extensions a browser could execute as
    a document in this origin are refused outright, so nothing served here
    can ever become a page.

    The URL *is* the file's absolute path minus its leading slash
    (``/api/files/raw/mnt/000/.../T1.nii.gz``) rather than a ``?path=``
    query, because the engine's loader takes the file name, the gzip
    decision and its volume-vs-mesh routing from the URL's last segment.

    Range/206, ``If-Range`` and ``ETag``/304 are supported so a large mesh
    can be resumed. No response ever carries a ``Content-Encoding``: a
    ``.nii.gz`` must reach the viewer still deflated (it inflates the stream
    itself), and an encoded body would break both ``Content-Length`` and
    ranges.

    This route is the whole reason viewing needs no X11 at all
    (``docs/dev/DECISIONS.md § 2026-09-03 (One Docker image and a real
    development loop)``): bytes are served to the app's own WebGL2 panes, or
    named by an exported scene the native TetraVox app opens on the host --
    never to an external Freeview/Gmsh process here.
    """
    resolved = _resolve_jailed("/" + path.lstrip("/"), roots=raw_jail_roots())
    lowered = resolved.name.lower()
    if any(lowered.endswith(ext) for ext in _RAW_DENY_EXTS):
        raise HTTPException(
            status_code=403,
            detail="File type not servable as raw data; use /api/files/artifact",
        )

    # Open once, with O_NOFOLLOW on the final component, and serve the bytes
    # of *that* file descriptor: the jail check and the read then cannot see
    # two different files, so a symlink swapped in after the check (the TOCTOU
    # window /api/files/artifact still has) cannot redirect the response.
    try:
        fd = os.open(resolved, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
    except OSError as exc:
        raise HTTPException(status_code=404, detail="Not found") from exc
    try:
        st = os.fstat(fd)
        if not stat.S_ISREG(st.st_mode):
            raise HTTPException(status_code=404, detail="Not found")
    except BaseException:
        os.close(fd)
        raise

    size = st.st_size
    etag = _etag(st)
    headers = {
        "content-type": "application/octet-stream",
        "x-content-type-options": "nosniff",
        "content-disposition": f'attachment; filename="{resolved.name}"',
        "cache-control": "private, max-age=0, must-revalidate",
        "accept-ranges": "bytes",
        "etag": etag,
        "last-modified": formatdate(st.st_mtime, usegmt=True),
    }

    if _if_none_match(if_none_match, etag):
        os.close(fd)
        return Response(status_code=304, headers=headers)

    requested = range_header
    if requested and if_range and if_range.strip() != etag:
        requested = None  # the file changed under the client: send it whole
    start, end = 0, size - 1
    status_code = 200
    if requested:
        parsed = _parse_range(requested, size)
        if parsed is False:
            os.close(fd)
            return Response(
                status_code=416,
                headers={**headers, "content-range": f"bytes */{size}"},
            )
        if parsed is not None:
            start, end = parsed
            status_code = 206
            headers["content-range"] = f"bytes {start}-{end}/{size}"

    length = 0 if size == 0 else end - start + 1
    headers["content-length"] = str(length)
    if request.method == "HEAD":
        os.close(fd)
        return Response(status_code=status_code, headers=headers)
    return StreamingResponse(
        _raw_stream(fd, start, length), status_code=status_code, headers=headers
    )

upload_mask async

upload_mask(request: Request, name: Annotated[str, Query()], subject: Annotated[SubjectId, Query()]) -> dict

Store a validated mask under this subject; coordinate space is chosen per job.

Source code in tit/server/routes/files.py
@router.post(
    "/api/files/mask",
    status_code=201,
    summary="Upload a custom NIfTI mask",
    responses={413: {"description": "Mask exceeds the size limit"}},
    openapi_extra={
        "requestBody": {
            "required": True,
            "content": {
                "application/octet-stream": {
                    "schema": {"type": "string", "format": "binary"}
                }
            },
        }
    },
)
async def upload_mask(
    request: Request,
    name: Annotated[str, Query()],
    subject: Annotated[SubjectId, Query()],
) -> dict:
    """Store a validated mask under this subject; coordinate space is chosen per job."""
    import tempfile

    from starlette.concurrency import run_in_threadpool

    if not _is_simple_mask_filename(name):
        raise HTTPException(422, "Choose a .nii or .nii.gz file with a simple filename")
    pm = get_path_manager()
    if subject not in catalog.subject_ids(pm):
        raise HTTPException(404, "Unknown subject")
    try:
        directory = Path(
            resolve_within(pm.project_dir, resolve_under(pm.masks(subject), "imported"))
        )
    except ValueError as exc:
        raise HTTPException(403, "Mask directory escapes the project") from exc
    directory.mkdir(parents=True, exist_ok=True)
    # Imports stay outside atlas autodiscovery: their coordinate space is explicit in the job.
    suffix = ".nii.gz" if name.endswith(".nii.gz") else ".nii"
    try:
        with tempfile.TemporaryDirectory(dir=directory) as scratch:
            uploaded = Path(scratch) / ("upload" + suffix)
            total = 0
            with uploaded.open("wb") as stream:
                async for chunk in request.stream():
                    total += len(chunk)
                    if total > _MASK_UPLOAD_LIMIT:
                        raise HTTPException(413, "Mask upload exceeds 64 MiB")
                    stream.write(chunk)
            destination = await run_in_threadpool(
                _finish_mask_upload, uploaded, Path(scratch), directory, name, suffix
            )
    except HTTPException:
        raise
    except (OSError, ValueError, EOFError) as exc:
        raise HTTPException(422, f"Invalid NIfTI mask: {exc}") from exc
    return {"path": str(destination)}