Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions openrag/api/chainlit_assets.py
Original file line number Diff line number Diff line change
@@ -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")
5 changes: 5 additions & 0 deletions openrag/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
10 changes: 9 additions & 1 deletion openrag/api/middleware/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
33 changes: 32 additions & 1 deletion openrag/api/routers/user/download.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Comment thread
Ahmath-Gadji marked this conversation as resolved.
file_path,
filename=file_path.name,
content_disposition_type="inline" if inline_ok else "attachment",
headers={"X-Content-Type-Options": "nosniff"},
)
16 changes: 16 additions & 0 deletions openrag/chainlit_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand All @@ -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)
Loading