diff --git a/openrag/api/chainlit_assets.py b/openrag/api/chainlit_assets.py new file mode 100644 index 000000000..09b7a3165 --- /dev/null +++ b/openrag/api/chainlit_assets.py @@ -0,0 +1,41 @@ +"""Serve Chainlit's bundled root-absolute static assets (the pdf.js worker). + +Chainlit is submounted at ``/chainlit``, so its HTML asset references are +rewritten to ``/chainlit/assets/*``. Its bundled pdf.js worker URL, however, is +computed at runtime in JS as the root-absolute ``/assets/pdf.worker*.mjs`` (no +mount prefix), so react-pdf fetches the worker from the origin root. That path +is not under the ``/chainlit`` auth bypass, so it returns a 403 JSON body and +the browser blocks the module worker on a bad MIME type — source PDF previews +then fail with "Failed to load PDF file". Serving the same asset files at +``/assets`` too (``AuthMiddleware`` bypasses ``/assets/*``) lets the worker load +with a JavaScript MIME type. + +Both browser origins that load Chainlit must expose this route: the mounted +deployment (``api.main``) and the standalone Ray Serve Chainlit process +(``chainlit_api``, served on its own port). This mirrors how ``chainlit_api`` +already replicates the ``/static`` download route for the same +separate-origin reason. +""" + +from __future__ import annotations + +from pathlib import Path + +from fastapi import FastAPI + + +def mount_chainlit_root_assets(app: FastAPI) -> None: + """Mount Chainlit's ``frontend/dist/assets`` at ``/assets`` (no-op if absent).""" + import chainlit + from starlette.staticfiles import StaticFiles + + # A regularly installed ``chainlit`` always has ``__file__``; a namespace + # package or a test double (bare ``ModuleType``) may not — treat that as + # "bundled assets unavailable" and no-op rather than raising AttributeError. + chainlit_file = getattr(chainlit, "__file__", None) + if not chainlit_file: + return + + assets_dir = Path(chainlit_file).parent / "frontend" / "dist" / "assets" + if assets_dir.is_dir(): + app.mount("/assets", StaticFiles(directory=str(assets_dir)), name="chainlit_root_assets") diff --git a/openrag/api/main.py b/openrag/api/main.py index 3468f8922..cec7a8395 100644 --- a/openrag/api/main.py +++ b/openrag/api/main.py @@ -362,10 +362,15 @@ def get_config(): app.include_router(openai_router, prefix="/v1", tags=[Tags.OPENAI]) if WITH_CHAINLIT_UI: + from api.chainlit_assets import mount_chainlit_root_assets from chainlit.utils import mount_chainlit mount_chainlit(app, "./app_front.py", path="/chainlit") + # Also serve Chainlit's bundled pdf.js worker at the origin root so source + # PDF previews load (see mount_chainlit_root_assets for the full rationale). + mount_chainlit_root_assets(app) + if __name__ == "__main__": if settings.ray.serve.enable: diff --git a/openrag/api/middleware/auth.py b/openrag/api/middleware/auth.py index a5cbef119..dab7900f8 100644 --- a/openrag/api/middleware/auth.py +++ b/openrag/api/middleware/auth.py @@ -82,9 +82,17 @@ def is_bypass_path(path: str, *, bypass_config: AuthBypassConfig | None = None) Matches a literal bypass list (``/docs``, health probes, OIDC callback endpoints) plus the entire ``/chainlit`` subtree — Chainlit handles its own header-auth callback for those routes. + + ``/assets/`` is also bypassed: Chainlit is submounted at ``/chainlit`` but + its bundled pdf.js worker requests itself from the origin-root + ``/assets/pdf.worker*.mjs`` (a runtime-computed URL the HTML root-path + rewrite never sees). Those files are public frontend bundles — the same + ones already served (and bypassed) under ``/chainlit/assets`` — so serving + them at ``/assets`` lets the module worker load with a JS MIME type instead + of a 403 JSON body that breaks source PDF previews. """ cfg = bypass_config or _DEFAULT_BYPASS_CONFIG - return path in cfg.bypass_paths or path == "/chainlit" or path.startswith("/chainlit/") + return path in cfg.bypass_paths or path == "/chainlit" or path.startswith(("/chainlit/", "/assets/")) def _allow_no_auth() -> bool: diff --git a/openrag/api/routers/user/download.py b/openrag/api/routers/user/download.py index e092e1296..6157121f5 100644 --- a/openrag/api/routers/user/download.py +++ b/openrag/api/routers/user/download.py @@ -8,6 +8,7 @@ ``/static`` so the middleware's browser ``?token=`` access works the same way. """ +import mimetypes from pathlib import Path from api.dependencies.auth import current_user_or_admin_partitions_list @@ -62,4 +63,34 @@ async def download_source( log.warning("Resolved source path is outside DATA_DIR or missing.") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found.") - return FileResponse(file_path, filename=file_path.name) + # Serve inline (not as an attachment) so browser viewers can render the + # source in place — the Chainlit source preview embeds this URL in cl.Pdf / + # cl.Image / cl.Video / cl.Audio elements, which display the resource rather + # than download it. FileResponse defaults to ``Content-Disposition: + # attachment`` when ``filename`` is set, which makes the browser download the + # file and leaves the PDF viewer with nothing to render ("Failed to load PDF + # file"). ``filename`` is kept so a manual "Save as" still gets a sane name. + # + # FileResponse already infers the Content-Type from ``filename`` (guess_type), + # so we don't set media_type here — we only need the guessed type to choose the + # disposition. Restrict inline rendering to media types that cannot execute + # script in the app's origin: HTML / SVG / etc. stay ``attachment`` so a crafted + # indexed file can't turn this same-origin route into a stored-XSS vector. + media_type, _ = mimetypes.guess_type(file_path.name) + inline_ok = bool(media_type) and ( + media_type == "application/pdf" + or media_type.startswith(("audio/", "video/")) + or (media_type.startswith("image/") and media_type != "image/svg+xml") + ) + # ``X-Content-Type-Options: nosniff`` stops the browser from MIME-sniffing + # the body into a type other than the one we declare. Since this route serves + # some files inline and the Content-Type is only inferred from the filename, + # nosniff ensures a mislabeled file can't be reinterpreted as an executable + # type (e.g. HTML/JS) in the app's origin — defense-in-depth alongside the + # attachment fallback for HTML/SVG above. + return FileResponse( + file_path, + filename=file_path.name, + content_disposition_type="inline" if inline_ok else "attachment", + headers={"X-Content-Type-Options": "nosniff"}, + ) diff --git a/openrag/chainlit_api.py b/openrag/chainlit_api.py index 69b7673eb..739b3f616 100644 --- a/openrag/chainlit_api.py +++ b/openrag/chainlit_api.py @@ -17,7 +17,9 @@ from contextlib import asynccontextmanager +from api.chainlit_assets import mount_chainlit_root_assets from api.middleware.auth import AuthMiddleware +from api.middleware.request_id import RequestIdMiddleware from api.routers.user.download import router as download_router from chainlit.utils import mount_chainlit from core.config import load_config @@ -78,6 +80,15 @@ def _get_auth_service(request): AuthMiddleware, get_auth_service=_get_auth_service, ) +# AuthMiddleware's ``/static`` branch authenticates via the ``?token=`` query +# param, but it reads it from ``request.state.original_token`` — which only +# RequestIdMiddleware populates (it stashes the raw token there before redacting +# the query string for logs). The mounted deployment (``api.main``) registers +# this; without it here, source-file downloads on this standalone origin see no +# token and 403 with "Missing token" (the Ray Serve source-preview failure). +# Registered after AuthMiddleware so it wraps it — i.e. runs first and sets +# ``original_token`` before auth reads it (add_middleware adds outermost-last). +app.add_middleware(RequestIdMiddleware) # Ray Serve mode runs the API and Chainlit on separate ports. Source previews # rewrite their file download links to the browser origin (the Chainlit host), @@ -88,3 +99,8 @@ def _get_auth_service(request): app.include_router(download_router) mount_chainlit(app=app, target="./app_front.py", path="/chainlit") + +# Ray Serve mode serves this standalone Chainlit app on its own origin, so it +# must also expose Chainlit's root-absolute pdf.js worker at /assets — the same +# separate-origin reason the /static download route is replicated above. +mount_chainlit_root_assets(app)