service: add per-IP rate limiting and IFC blob size cap

IFCURL_RATE_LIMIT (default 60 req/min, 0=off) — sliding-window middleware
returns 429 before any expensive work is done.  IFCURL_MAX_IFC_MB (default
256, 0=off) — rejects oversized fetches with 413 in preview, bcf, and
render_diff endpoints.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Bruno Postle 2026-04-25 13:01:56 +01:00
parent c6ffc4009c
commit fe793d2194
2 changed files with 58 additions and 3 deletions

View file

@ -51,12 +51,12 @@
{"id":"ifcurl-np8","title":"Viewer depends on CDN (unpkg, esm.sh) — broken in air-gapped or restricted networks","description":"viewer.html loads all JavaScript from public CDNs: three.js from esm.sh, @thatopen/components and fragments from esm.sh/unpkg, web-ifc WASM from unpkg, the fragment worker from unpkg, JSZip from esm.sh. In corporate BIM environments with restricted internet (common in construction/engineering), the viewer will fail to load entirely. There is no documented self-hosted path. Consider: document how to serve the dependencies from the Forgejo instance's own /assets/ directory, or provide a bundled viewer.html as a build artifact. The viewer worker fetch ('https://unpkg.com/@thatopen/fragments@3.3.1/dist/Worker/worker.mjs') is already fetched at runtime with a special workaround for Firefox — this is the most fragile CDN dependency.","status":"open","priority":3,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-04-24T11:04:04Z","created_by":"Bruno Postle","updated_at":"2026-04-24T11:04:04Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-np8","title":"Viewer depends on CDN (unpkg, esm.sh) — broken in air-gapped or restricted networks","description":"viewer.html loads all JavaScript from public CDNs: three.js from esm.sh, @thatopen/components and fragments from esm.sh/unpkg, web-ifc WASM from unpkg, the fragment worker from unpkg, JSZip from esm.sh. In corporate BIM environments with restricted internet (common in construction/engineering), the viewer will fail to load entirely. There is no documented self-hosted path. Consider: document how to serve the dependencies from the Forgejo instance's own /assets/ directory, or provide a bundled viewer.html as a build artifact. The viewer worker fetch ('https://unpkg.com/@thatopen/fragments@3.3.1/dist/Worker/worker.mjs') is already fetched at runtime with a special workaround for Firefox — this is the most fragile CDN dependency.","status":"open","priority":3,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-04-24T11:04:04Z","created_by":"Bruno Postle","updated_at":"2026-04-24T11:04:04Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-sa0","title":"Viewer: no load progress — large IFC files appear to hang","description":"The viewer status shows 'Fetching IFC file…' and 'Parsing IFC…' as plain text but gives no indication of how far along the download or parse is. IFC files in real projects are often 20200MB. The fetch() call and the web-ifc WASM parser both block for seconds with no visual feedback. A progress bar or percentage indicator during fetch (using response.body ReadableStream + Content-Length header) and a spinner during parse would prevent users abandoning the page thinking it crashed. The status element exists and is already used — it just needs richer updates.","status":"closed","priority":3,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T11:03:58Z","created_by":"Bruno Postle","updated_at":"2026-04-24T11:47:23Z","started_at":"2026-04-24T11:14:56Z","closed_at":"2026-04-24T11:47:23Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-sa0","title":"Viewer: no load progress — large IFC files appear to hang","description":"The viewer status shows 'Fetching IFC file…' and 'Parsing IFC…' as plain text but gives no indication of how far along the download or parse is. IFC files in real projects are often 20200MB. The fetch() call and the web-ifc WASM parser both block for seconds with no visual feedback. A progress bar or percentage indicator during fetch (using response.body ReadableStream + Content-Length header) and a spinner during parse would prevent users abandoning the page thinking it crashed. The status element exists and is already used — it just needs richer updates.","status":"closed","priority":3,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T11:03:58Z","created_by":"Bruno Postle","updated_at":"2026-04-24T11:47:23Z","started_at":"2026-04-24T11:14:56Z","closed_at":"2026-04-24T11:47:23Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-4xl","title":"viewer.html: selector support is a subset of IfcOpenShell syntax — not documented","description":"applySelector() in the viewer only handles type-name union syntax ('IfcWall+IfcSlab'). The comment says 'Attribute/property filters not yet supported', but this limitation is not visible to users and not documented in README.md or SPECIFICATION.md. A user pasting a URL with 'selector=IfcWall,+Pset_WallCommon.FireRating=2HR' will see all elements rather than the filtered set, with no error. Should add a user-visible note or at minimum document the viewer's selector limitations.","status":"closed","priority":3,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T10:41:49Z","created_by":"Bruno Postle","updated_at":"2026-04-24T14:04:57Z","started_at":"2026-04-24T13:22:10Z","closed_at":"2026-04-24T14:04:57Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-4xl","title":"viewer.html: selector support is a subset of IfcOpenShell syntax — not documented","description":"applySelector() in the viewer only handles type-name union syntax ('IfcWall+IfcSlab'). The comment says 'Attribute/property filters not yet supported', but this limitation is not visible to users and not documented in README.md or SPECIFICATION.md. A user pasting a URL with 'selector=IfcWall,+Pset_WallCommon.FireRating=2HR' will see all elements rather than the filtered set, with no error. Should add a user-visible note or at minimum document the viewer's selector limitations.","status":"closed","priority":3,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T10:41:49Z","created_by":"Bruno Postle","updated_at":"2026-04-24T14:04:57Z","started_at":"2026-04-24T13:22:10Z","closed_at":"2026-04-24T14:04:57Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-fu5","title":"service.py: no rate limiting or request size cap on /preview and /bcf","description":"The preview and BCF endpoints have no rate limiting, no cap on IFC file size fetched, and no timeout on the render operation. A single large IFC file or a flood of requests can saturate memory and CPU. Consider: max IFC blob size (reject fetch if commit object too large), render timeout via threading.Timer, and a simple in-process rate limiter or documentation that a reverse proxy (nginx) should front the service with rate limits.","status":"open","priority":3,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-04-24T10:41:45Z","created_by":"Bruno Postle","updated_at":"2026-04-24T10:41:45Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-fu5","title":"service.py: no rate limiting or request size cap on /preview and /bcf","description":"The preview and BCF endpoints have no rate limiting, no cap on IFC file size fetched, and no timeout on the render operation. A single large IFC file or a flood of requests can saturate memory and CPU. Consider: max IFC blob size (reject fetch if commit object too large), render timeout via threading.Timer, and a simple in-process rate limiter or documentation that a reverse proxy (nginx) should front the service with rate limits.","status":"in_progress","priority":3,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T10:41:45Z","created_by":"Bruno Postle","updated_at":"2026-04-25T11:56:48Z","started_at":"2026-04-25T11:56:48Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-d58","title":"ifc_url_test.go: no test for bare URL with path containing @ character","description":"The Go transformer reassembles multi-sibling text nodes for bare ifc:// URLs. There is a test for underscores in the path (which goldmark splits on), but no test for paths or query values containing '@', which is a valid character in query strings and could appear in selector expressions. Also missing: test for a bare URL followed immediately by punctuation like a period or closing parenthesis to verify the terminator logic.","status":"closed","priority":3,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T10:41:32Z","created_by":"Bruno Postle","updated_at":"2026-04-24T14:04:56Z","started_at":"2026-04-24T13:22:09Z","closed_at":"2026-04-24T14:04:56Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-d58","title":"ifc_url_test.go: no test for bare URL with path containing @ character","description":"The Go transformer reassembles multi-sibling text nodes for bare ifc:// URLs. There is a test for underscores in the path (which goldmark splits on), but no test for paths or query values containing '@', which is a valid character in query strings and could appear in selector expressions. Also missing: test for a bare URL followed immediately by punctuation like a period or closing parenthesis to verify the terminator logic.","status":"closed","priority":3,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T10:41:32Z","created_by":"Bruno Postle","updated_at":"2026-04-24T14:04:56Z","started_at":"2026-04-24T13:22:09Z","closed_at":"2026-04-24T14:04:56Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-7mg","title":"auth.py: token appears in git process arguments, visible in process list","description":"inject_token() embeds the authentication token into the HTTPS remote URL as 'https://\u003ctoken\u003e@host/path'. This URL is passed directly to git clone/fetch as a command-line argument, which means the token is visible in the process list (ps aux) while the git command runs. On a shared server this is a security leak. The standard mitigation is to use GIT_ASKPASS or a credential helper that supplies the token via stdin/environment rather than via the URL. Should be documented as a known limitation at minimum.","status":"closed","priority":3,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T10:41:29Z","created_by":"Bruno Postle","updated_at":"2026-04-24T15:52:13Z","started_at":"2026-04-24T14:47:34Z","closed_at":"2026-04-24T15:52:13Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-7mg","title":"auth.py: token appears in git process arguments, visible in process list","description":"inject_token() embeds the authentication token into the HTTPS remote URL as 'https://\u003ctoken\u003e@host/path'. This URL is passed directly to git clone/fetch as a command-line argument, which means the token is visible in the process list (ps aux) while the git command runs. On a shared server this is a security leak. The standard mitigation is to use GIT_ASKPASS or a credential helper that supplies the token via stdin/environment rather than via the URL. Should be documented as a known limitation at minimum.","status":"closed","priority":3,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T10:41:29Z","created_by":"Bruno Postle","updated_at":"2026-04-24T15:52:13Z","started_at":"2026-04-24T14:47:34Z","closed_at":"2026-04-24T15:52:13Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-z4f","title":"render.py: concurrent requests each spawn full CPU-count process pools","description":"render() calls ifcopenshell.geom.iterator(settings, model, multiprocessing.cpu_count(), ...). In a multi-request service scenario, N concurrent renders each spawn cpu_count worker processes, so a 16-core machine handling 4 simultaneous requests spawns 64 processes. This can saturate the machine and is effectively a DoS amplifier. Consider capping the worker count (e.g. max 2 or 4 per request), or using a shared process pool at the service level.","status":"closed","priority":3,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T10:41:16Z","created_by":"Bruno Postle","updated_at":"2026-04-24T15:52:14Z","started_at":"2026-04-24T14:47:54Z","closed_at":"2026-04-24T15:52:14Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-z4f","title":"render.py: concurrent requests each spawn full CPU-count process pools","description":"render() calls ifcopenshell.geom.iterator(settings, model, multiprocessing.cpu_count(), ...). In a multi-request service scenario, N concurrent renders each spawn cpu_count worker processes, so a 16-core machine handling 4 simultaneous requests spawns 64 processes. This can saturate the machine and is effectively a DoS amplifier. Consider capping the worker count (e.g. max 2 or 4 per request), or using a shared process pool at the service level.","status":"closed","priority":3,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T10:41:16Z","created_by":"Bruno Postle","updated_at":"2026-04-24T15:52:14Z","started_at":"2026-04-24T14:47:54Z","closed_at":"2026-04-24T15:52:14Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-5or","title":"README.md: library usage example doesn't show temp file pattern for ifcopenshell.open()","description":"The 'Use as a library' section shows 'model = ifcopenshell.open(...) # write ifc_bytes to tmp file first' with a comment but no actual code. ifcopenshell.open() requires a file path, not bytes. A new user following the README will be stuck. The example should show the tempfile.mkstemp pattern used in _load_model() in service.py, or at least reference that function.","status":"closed","priority":3,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T10:41:04Z","created_by":"Bruno Postle","updated_at":"2026-04-24T15:52:14Z","started_at":"2026-04-24T14:47:55Z","closed_at":"2026-04-24T15:52:14Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-5or","title":"README.md: library usage example doesn't show temp file pattern for ifcopenshell.open()","description":"The 'Use as a library' section shows 'model = ifcopenshell.open(...) # write ifc_bytes to tmp file first' with a comment but no actual code. ifcopenshell.open() requires a file path, not bytes. A new user following the README will be stuck. The example should show the tempfile.mkstemp pattern used in _load_model() in service.py, or at least reference that function.","status":"closed","priority":3,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T10:41:04Z","created_by":"Bruno Postle","updated_at":"2026-04-24T15:52:14Z","started_at":"2026-04-24T14:47:55Z","closed_at":"2026-04-24T15:52:14Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-dyy","title":"viewer.html: BCF export from viewer omits component GUIDs — undocumented","description":"The viewer's client-side BCF export cannot include component GUIDs because it has no access to the IFC model's resolved GlobalIds. The service POST /bcf endpoint resolves the selector server-side and produces proper BCF with \u003cSelection\u003e or \u003cVisibility/Exceptions\u003e elements. This discrepancy is not documented anywhere. The viewer's BCF button should either show a note ('GUIDs not resolved — use /bcf API for full component selection') or redirect to the service endpoint when available.","status":"in_progress","priority":3,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T10:40:49Z","created_by":"Bruno Postle","updated_at":"2026-04-25T11:32:57Z","started_at":"2026-04-25T11:32:57Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-dyy","title":"viewer.html: BCF export from viewer omits component GUIDs — undocumented","description":"The viewer's client-side BCF export cannot include component GUIDs because it has no access to the IFC model's resolved GlobalIds. The service POST /bcf endpoint resolves the selector server-side and produces proper BCF with \u003cSelection\u003e or \u003cVisibility/Exceptions\u003e elements. This discrepancy is not documented anywhere. The viewer's BCF button should either show a note ('GUIDs not resolved — use /bcf API for full component selection') or redirect to the service endpoint when available.","status":"closed","priority":3,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T10:40:49Z","created_by":"Bruno Postle","updated_at":"2026-04-25T11:54:29Z","started_at":"2026-04-25T11:32:57Z","closed_at":"2026-04-25T11:54:29Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-tq8","title":"No tests for ifcurl cache CLI subcommands","description":"The 'ifcurl cache list', 'ifcurl cache prune', and 'ifcurl cache clear' subcommands in __main__.py have no automated test coverage. _repo_cache_entries(), _evict_if_needed(), and _get_max_cache_bytes() have unit tests in test_git.py, but the CLI layer (_cmd_cache) is untested. Should add tests that exercise the argparse wiring, the output formatting, and the prune logic via subprocess or by calling _cmd_cache() directly with a fake args namespace.","status":"open","priority":3,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-04-24T10:40:45Z","created_by":"Bruno Postle","updated_at":"2026-04-24T10:40:45Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-tq8","title":"No tests for ifcurl cache CLI subcommands","description":"The 'ifcurl cache list', 'ifcurl cache prune', and 'ifcurl cache clear' subcommands in __main__.py have no automated test coverage. _repo_cache_entries(), _evict_if_needed(), and _get_max_cache_bytes() have unit tests in test_git.py, but the CLI layer (_cmd_cache) is untested. Should add tests that exercise the argparse wiring, the output formatting, and the prune logic via subprocess or by calling _cmd_cache() directly with a fake args namespace.","status":"open","priority":3,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-04-24T10:40:45Z","created_by":"Bruno Postle","updated_at":"2026-04-24T10:40:45Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-ovi","title":"dev/compose.yml uses Forgejo v10, but go.patch targets v13","description":"dev/compose.yml pulls codeberg.org/forgejo/forgejo:10 for local development. The go.patch and ifc_url.go were written and tested against the v13 branch. Running the dev environment with v10 won't have the patched binary or the correct template slots. The compose file should either use the patched v13 build, or document that the stock container is for testing the service/viewer in isolation only, not for testing the Go patch.","status":"closed","priority":3,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T10:40:35Z","created_by":"Bruno Postle","updated_at":"2026-04-25T09:05:41Z","started_at":"2026-04-25T09:05:21Z","closed_at":"2026-04-25T09:05:41Z","close_reason":"Updated image to v13 and added comment clarifying stock container doesn't include the IFC patch","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-ovi","title":"dev/compose.yml uses Forgejo v10, but go.patch targets v13","description":"dev/compose.yml pulls codeberg.org/forgejo/forgejo:10 for local development. The go.patch and ifc_url.go were written and tested against the v13 branch. Running the dev environment with v10 won't have the patched binary or the correct template slots. The compose file should either use the patched v13 build, or document that the stock container is for testing the service/viewer in isolation only, not for testing the Go patch.","status":"closed","priority":3,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T10:40:35Z","created_by":"Bruno Postle","updated_at":"2026-04-25T09:05:41Z","started_at":"2026-04-25T09:05:21Z","closed_at":"2026-04-25T09:05:41Z","close_reason":"Updated image to v13 and added comment clarifying stock container doesn't include the IFC patch","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-j35","title":"git.py: silent failure on mutable ref fetch serves stale data without warning","description":"In _open_remote(), when fetching for a mutable ref (branch/HEAD) fails — network error, auth failure, server down — the exception is caught and ignored ('pass # offline — use cached data'). The caller receives stale data with no indication anything went wrong. The render then proceeds, potentially showing outdated geometry. At minimum a log.warning should be emitted. Ideally the service response should include a header (e.g. X-Ifcurl-Cache: stale) so Forgejo or the browser viewer can surface a warning to the user.","status":"closed","priority":3,"issue_type":"bug","owner":"bruno@postle.net","created_at":"2026-04-24T10:40:31Z","created_by":"Bruno Postle","updated_at":"2026-04-25T08:05:18Z","closed_at":"2026-04-25T08:05:18Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-j35","title":"git.py: silent failure on mutable ref fetch serves stale data without warning","description":"In _open_remote(), when fetching for a mutable ref (branch/HEAD) fails — network error, auth failure, server down — the exception is caught and ignored ('pass # offline — use cached data'). The caller receives stale data with no indication anything went wrong. The render then proceeds, potentially showing outdated geometry. At minimum a log.warning should be emitted. Ideally the service response should include a header (e.g. X-Ifcurl-Cache: stale) so Forgejo or the browser viewer can surface a warning to the user.","status":"closed","priority":3,"issue_type":"bug","owner":"bruno@postle.net","created_at":"2026-04-24T10:40:31Z","created_by":"Bruno Postle","updated_at":"2026-04-25T08:05:18Z","closed_at":"2026-04-25T08:05:18Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}

View file

@ -46,7 +46,7 @@ from collections import OrderedDict
from pathlib import Path from pathlib import Path
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException
from fastapi.responses import Response from fastapi.responses import JSONResponse, Response
from platformdirs import user_cache_dir from platformdirs import user_cache_dir
from pydantic import BaseModel from pydantic import BaseModel
@ -173,6 +173,36 @@ app = FastAPI(
version="0.0.0", version="0.0.0",
) )
# ---------------------------------------------------------------------------
# Rate limiting (in-process, per client IP, sliding window)
# ---------------------------------------------------------------------------
_RATE_LIMIT: int = int(os.environ.get("IFCURL_RATE_LIMIT", "60")) # requests/minute, 0=disabled
_RATE_WINDOW: float = 60.0 # seconds
_rate_lock = threading.Lock()
_rate_hits: dict[str, list[float]] = {}
@app.middleware("http")
async def _rate_limit_middleware(request, call_next):
if _RATE_LIMIT > 0:
ip = request.client.host if request.client else "unknown"
now = time.time()
cutoff = now - _RATE_WINDOW
with _rate_lock:
hits = _rate_hits.setdefault(ip, [])
while hits and hits[0] < cutoff:
hits.pop(0)
if len(hits) >= _RATE_LIMIT:
return JSONResponse(
{"detail": f"Rate limit exceeded — max {_RATE_LIMIT} requests/minute"},
status_code=429,
headers={"Retry-After": "60"},
)
hits.append(now)
return await call_next(request)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# SSRF protection # SSRF protection
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -225,6 +255,24 @@ def _ssrf_check(ifc_url: IfcUrl) -> None:
) )
# ---------------------------------------------------------------------------
# IFC blob size cap
# ---------------------------------------------------------------------------
_MAX_IFC_BYTES: int = int(os.environ.get("IFCURL_MAX_IFC_MB", "256")) * 1024 * 1024
def _check_ifc_size(ifc_bytes: bytes) -> None:
"""Raise 413 if the fetched IFC file exceeds IFCURL_MAX_IFC_MB."""
if _MAX_IFC_BYTES > 0 and len(ifc_bytes) > _MAX_IFC_BYTES:
mb = len(ifc_bytes) / 1024 / 1024
limit_mb = _MAX_IFC_BYTES // (1024 * 1024)
raise HTTPException(
status_code=413,
detail=f"IFC file too large: {mb:.1f} MB (limit {limit_mb} MB)",
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Tier 2: (commit_hexsha, path) → IFC bytes # Tier 2: (commit_hexsha, path) → IFC bytes
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -432,6 +480,8 @@ def preview(request: PreviewRequest) -> Response:
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc raise HTTPException(status_code=404, detail=str(exc)) from exc
_check_ifc_size(ifc_bytes)
# --- Tier 2: populate / use byte cache --- # --- Tier 2: populate / use byte cache ---
cached_bytes = _t2_get(hexsha, ifc_url.path) cached_bytes = _t2_get(hexsha, ifc_url.path)
if cached_bytes is not None: if cached_bytes is not None:
@ -542,6 +592,8 @@ def bcf_export(request: BcfRequest) -> Response:
except (ImportError, ValueError) as exc: except (ImportError, ValueError) as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc raise HTTPException(status_code=404, detail=str(exc)) from exc
_check_ifc_size(ifc_bytes)
cached = _t2_get(hexsha, ifc_url.path) cached = _t2_get(hexsha, ifc_url.path)
ifc_bytes = cached if cached is not None else ifc_bytes ifc_bytes = cached if cached is not None else ifc_bytes
if cached is None: if cached is None:
@ -651,6 +703,9 @@ def render_diff(request: DiffRequest) -> Response:
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc raise HTTPException(status_code=404, detail=str(exc)) from exc
_check_ifc_size(base_bytes)
_check_ifc_size(head_bytes)
# --- Tier 2: populate byte cache --- # --- Tier 2: populate byte cache ---
for hexsha, path, ifc_bytes in ( for hexsha, path, ifc_bytes in (
(base_hexsha, base_url.path, base_bytes), (base_hexsha, base_url.path, base_bytes),