Files
2026-07-13 12:10:23 +08:00

123 lines
4.5 KiB
Python

import os
from loguru import logger
from starlette.datastructures import Headers
from starlette.types import ASGIApp, Message, Receive, Scope, Send
# Matches the file-size guidance already documented in
# docs/3-USER-GUIDE/adding-sources.md ("Very large files (>100MB) - Timeout").
DEFAULT_MAX_UPLOAD_SIZE_MB = 100
def get_max_upload_size_bytes() -> int:
"""Read the configured max request body size, in bytes.
Configurable via OPEN_NOTEBOOK_MAX_UPLOAD_SIZE_MB for deployments that
need larger audio/video uploads; falls back to the default on unset,
malformed, or non-positive values (a zero/negative limit would reject
every request that has a body).
"""
raw = os.environ.get("OPEN_NOTEBOOK_MAX_UPLOAD_SIZE_MB", "").strip()
try:
mb = float(raw) if raw else DEFAULT_MAX_UPLOAD_SIZE_MB
except ValueError:
mb = DEFAULT_MAX_UPLOAD_SIZE_MB
if mb <= 0:
logger.warning(
f"OPEN_NOTEBOOK_MAX_UPLOAD_SIZE_MB={raw!r} is not a positive size; "
f"using the default of {DEFAULT_MAX_UPLOAD_SIZE_MB}MB"
)
mb = DEFAULT_MAX_UPLOAD_SIZE_MB
return int(mb * 1024 * 1024)
class _RequestBodyTooLarge(Exception):
pass
class MaxBodySizeMiddleware:
"""
Raw ASGI middleware rejecting requests whose body exceeds a configured
maximum, so a large upload can't exhaust memory/disk on a deployment
with no fronting proxy enforcing its own limit (e.g. the shipped
docker-compose.yml, which exposes the API directly).
Implemented at the raw ASGI level (not BaseHTTPMiddleware) so the check
can run ahead of FastAPI's own body/form parsing instead of after it.
Rejects on the `Content-Length` header up front when present (the common
case, and cheap), and also counts bytes as the body streams in - a
client can lie about Content-Length or omit it entirely with chunked
transfer-encoding.
"""
def __init__(self, app: ASGIApp, max_body_size: int) -> None:
self.app = app
self.max_body_size = max_body_size
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
headers = Headers(scope=scope)
content_length = headers.get("content-length")
if content_length is not None:
try:
if int(content_length) > self.max_body_size:
logger.warning(
f"Rejected {scope.get('method', '?')} {scope.get('path', '?')}: "
f"declared body of {content_length} bytes exceeds the "
f"{self.max_body_size}-byte limit"
)
await _send_413(send)
return
except ValueError:
pass # malformed header - fall through to streaming enforcement
total_size = 0
response_started = False
async def send_wrapper(message: Message) -> None:
nonlocal response_started
if message["type"] == "http.response.start":
response_started = True
await send(message)
async def receive_wrapper() -> Message:
nonlocal total_size
message = await receive()
if message["type"] == "http.request":
total_size += len(message.get("body") or b"")
if total_size > self.max_body_size:
raise _RequestBodyTooLarge()
return message
try:
await self.app(scope, receive_wrapper, send_wrapper)
except _RequestBodyTooLarge:
logger.warning(
f"Rejected {scope.get('method', '?')} {scope.get('path', '?')}: "
f"streamed body exceeded the {self.max_body_size}-byte limit"
)
if not response_started:
await _send_413(send)
# Else the app already started responding - nothing safe to send;
# let the connection drop rather than violate the ASGI protocol
# with a second response.start.
async def _send_413(send: Send) -> None:
await send(
{
"type": "http.response.start",
"status": 413,
"headers": [(b"content-type", b"application/json")],
}
)
await send(
{
"type": "http.response.body",
"body": b'{"detail":"Request body exceeds the maximum allowed upload size"}',
}
)