ifcurl/ifcurl/service.py
Claude b560551f3c Implement clash visibility mode and /query endpoint (ifcurl-19h, ifcurl-8rf)
render.py: add _CLASH_COLOR and clash branch in _add_shape — selected
elements rendered in red, non-selected in normal material colours.

render_service.py: add _sandboxed_query(ifc_bytes, selector, query_path)
and /query endpoint. Dot-notation query_path splits into pset/property
(Pset_WallCommon.FireRating) or falls back to direct IFC attribute (Name).
Returns {GlobalId: str(value)}, excluding None and entity references.

service.py: add QueryRequest model, _query_via_socket() for socket
delegation, and GET/POST /query endpoints reusing Tier 2 byte cache.

https://claude.ai/code/session_01NBsytCd6L7UQPiKbcLn4kn
2026-05-09 08:46:26 +00:00

988 lines
34 KiB
Python

# IFC URL — resolve and render ifc:// URLs
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
#
# This file is part of IFC URL.
#
# IFC URL is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IFC URL is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IFC URL. If not, see <http://www.gnu.org/licenses/>.
"""Preview service: POST /preview → image/png.
Caching tiers
-------------
Tier 2 (commit_hexsha, path) → IFC bytes
In-memory LRU. Avoids repeated git blob reads for the same commit.
Tier 3 (commit_hexsha, path, selector) → frozenset of GlobalIds
In-memory LRU. Avoids re-running ifcopenshell selector execution.
The GUID set is converted back to step IDs against the loaded model on
each render, so it remains valid across model reloads from the same blob.
Tier 4 sha256(url) → PNG bytes
Filesystem. Only written for immutable refs (commit hashes, tags).
Mutable refs (HEAD, branches) are never cached at this tier.
"""
from __future__ import annotations
import base64
import hashlib
import ipaddress
import json
import os
import threading
import time
from collections import OrderedDict
from pathlib import Path
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse, Response
from platformdirs import user_cache_dir
from pydantic import BaseModel
from ifcurl.auth import get_token_for_host
from ifcurl.bcf import build_bcf
from ifcurl.bcf_api import router as bcf_router
from ifcurl.documents_api import router as documents_router
from ifcurl.git import diff_text as git_diff_text
from ifcurl.git import fetch_ifc
from ifcurl.render_service import _sandboxed_diff, _sandboxed_pipeline, _sandboxed_query, _sandboxed_select
from ifcurl.sandbox import SandboxCrashError, SandboxTimeoutError, run_sandboxed
from ifcurl.url import IfcUrl
# ---------------------------------------------------------------------------
# Render service delegation (when IFCURL_RENDER_SOCKET is set)
# ---------------------------------------------------------------------------
_RENDER_SOCKET: str | None = os.environ.get("IFCURL_RENDER_SOCKET")
_RENDER_TIMEOUT: int = int(os.environ.get("IFCURL_SANDBOX_TIMEOUT", "120")) + 30
def _render_via_socket(
ifc_bytes: bytes,
selector: str | None,
element_guids: frozenset[str] | None,
camera: tuple | None,
fov: float | None,
scale: float | None,
clips: list,
visibility: str,
) -> tuple[frozenset[str] | None, bytes]:
"""Delegate render to the render service over the Unix socket."""
import httpx
params = {
"selector": selector,
"element_guids": list(element_guids) if element_guids is not None else None,
"camera": list(camera) if camera else None,
"fov": fov,
"scale": scale,
"clips": clips,
"visibility": visibility,
}
try:
with httpx.Client(transport=httpx.HTTPTransport(uds=_RENDER_SOCKET)) as client:
resp = client.post(
"http://render/render",
files={"ifc": ifc_bytes},
data={"params": json.dumps(params)},
timeout=_RENDER_TIMEOUT,
)
except httpx.TransportError as exc:
raise HTTPException(status_code=503, detail=f"Render service unavailable: {exc}") from exc
_check_render_response(resp)
result = resp.json()
png_bytes = base64.b64decode(result["png"])
raw_guids = result.get("guids")
new_guids = frozenset(raw_guids) if raw_guids is not None else None
return new_guids, png_bytes
def _select_via_socket(ifc_bytes: bytes, selector: str) -> list[str]:
"""Delegate selector execution to the render service over the Unix socket."""
import httpx
try:
with httpx.Client(transport=httpx.HTTPTransport(uds=_RENDER_SOCKET)) as client:
resp = client.post(
"http://render/select",
files={"ifc": ifc_bytes},
data={"selector": selector},
timeout=_RENDER_TIMEOUT,
)
except httpx.TransportError as exc:
raise HTTPException(status_code=503, detail=f"Render service unavailable: {exc}") from exc
_check_render_response(resp)
return resp.json()["guids"]
def _query_via_socket(ifc_bytes: bytes, selector: str, query_path: str) -> dict[str, str]:
"""Delegate query execution to the render service over the Unix socket."""
import httpx
try:
with httpx.Client(transport=httpx.HTTPTransport(uds=_RENDER_SOCKET)) as client:
resp = client.post(
"http://render/query",
files={"ifc": ifc_bytes},
data={"selector": selector, "query_path": query_path},
timeout=_RENDER_TIMEOUT,
)
except httpx.TransportError as exc:
raise HTTPException(status_code=503, detail=f"Render service unavailable: {exc}") from exc
_check_render_response(resp)
return resp.json()
def _diff_via_socket(
head_bytes: bytes,
base_bytes: bytes,
raw_diff: str,
camera: tuple | None,
fov: float | None,
scale: float | None,
clips: list,
) -> bytes:
"""Delegate diff rendering to the render service over the Unix socket."""
import httpx
params = {
"raw_diff": raw_diff,
"camera": list(camera) if camera else None,
"fov": fov,
"scale": scale,
"clips": clips,
}
try:
with httpx.Client(transport=httpx.HTTPTransport(uds=_RENDER_SOCKET)) as client:
resp = client.post(
"http://render/render_diff",
files={"head_ifc": head_bytes, "base_ifc": base_bytes},
data={"params": json.dumps(params)},
timeout=_RENDER_TIMEOUT,
)
except httpx.TransportError as exc:
raise HTTPException(status_code=503, detail=f"Render service unavailable: {exc}") from exc
_check_render_response(resp)
return resp.content
def _check_render_response(resp) -> None:
"""Raise SandboxCrashError / SandboxTimeoutError / HTTPException from render service response."""
if resp.status_code == 422:
raise SandboxCrashError(resp.json().get("detail", "render error"))
if resp.status_code == 503:
raise SandboxTimeoutError(resp.json().get("detail", "render timeout"))
if resp.status_code != 200:
raise HTTPException(status_code=resp.status_code, detail=resp.text)
app = FastAPI(
title="ifcurl preview service",
description="Renders ifc:// URLs to PNG images for embedding in Gitea and other consumers.",
version="0.0.0",
)
app.include_router(bcf_router)
app.include_router(documents_router)
# ---------------------------------------------------------------------------
# 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
# ---------------------------------------------------------------------------
_allowed_hosts: set[str] | None = None
def configure_allowed_hosts(hosts: set[str] | None) -> None:
"""Set the allowlist of git hosts the service will fetch from.
Pass a set of hostname strings (optionally with :port) to restrict which
hosts are contacted. Pass None to allow all non-private remote hosts.
"""
global _allowed_hosts
_allowed_hosts = hosts
def _is_private_ip(host: str) -> bool:
"""Return True if *host* is a literal private/loopback/link-local IP."""
bare = host.split(":")[0].strip("[]") # strip port and IPv6 brackets
try:
addr = ipaddress.ip_address(bare)
return (
addr.is_loopback
or addr.is_link_local
or addr.is_private
or addr.is_reserved
)
except ValueError:
return False
def _ssrf_check(ifc_url: IfcUrl) -> None:
"""Raise HTTPException if the URL fails SSRF protection checks."""
if ifc_url.transport == "local":
raise HTTPException(
status_code=403,
detail="Local file transport is not permitted in service mode",
)
if _allowed_hosts is not None:
if ifc_url.host not in _allowed_hosts:
raise HTTPException(
status_code=403,
detail=f"Host {ifc_url.host!r} is not in the allowed-hosts list",
)
elif _is_private_ip(ifc_url.host):
raise HTTPException(
status_code=403,
detail="Requests to private/loopback addresses are not permitted",
)
# ---------------------------------------------------------------------------
# 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
# ---------------------------------------------------------------------------
_T2_MAX: int = int(os.environ.get("IFCURL_T2_MAX", "8"))
_t2_cache: OrderedDict[tuple[str, str], bytes] = OrderedDict()
_t2_lock = threading.Lock()
def _t2_get(hexsha: str, path: str) -> bytes | None:
key = (hexsha, path)
with _t2_lock:
if key not in _t2_cache:
return None
_t2_cache.move_to_end(key)
return _t2_cache[key]
def _t2_put(hexsha: str, path: str, data: bytes) -> None:
key = (hexsha, path)
with _t2_lock:
_t2_cache[key] = data
_t2_cache.move_to_end(key)
while len(_t2_cache) > _T2_MAX:
_t2_cache.popitem(last=False)
# ---------------------------------------------------------------------------
# Tier 3: (commit_hexsha, path, selector) → frozenset[GlobalId]
# ---------------------------------------------------------------------------
_T3_MAX: int = int(os.environ.get("IFCURL_T3_MAX", "64"))
_t3_cache: OrderedDict[tuple[str, str, str], frozenset[str]] = OrderedDict()
_t3_lock = threading.Lock()
def _t3_get(hexsha: str, path: str, selector: str) -> frozenset[str] | None:
key = (hexsha, path, selector)
with _t3_lock:
if key not in _t3_cache:
return None
_t3_cache.move_to_end(key)
return _t3_cache[key]
def _t3_put(hexsha: str, path: str, selector: str, guids: frozenset[str]) -> None:
key = (hexsha, path, selector)
with _t3_lock:
_t3_cache[key] = guids
_t3_cache.move_to_end(key)
while len(_t3_cache) > _T3_MAX:
_t3_cache.popitem(last=False)
# ---------------------------------------------------------------------------
# Tier 4: sha256(url) → PNG (filesystem, immutable refs only, no expiry)
# ---------------------------------------------------------------------------
def _t4_path(url: str) -> Path:
cache_dir = Path(user_cache_dir("ifcurl")) / "renders" / "immutable"
cache_dir.mkdir(parents=True, exist_ok=True)
url_hash = hashlib.sha256(url.encode()).hexdigest()
return cache_dir / f"{url_hash}.png"
def _t4_get(url: str) -> bytes | None:
try:
return _t4_path(url).read_bytes()
except FileNotFoundError:
return None
def _t4_put(url: str, png: bytes) -> None:
_t4_path(url).write_bytes(png)
# ---------------------------------------------------------------------------
# Tier 4m: sha256(url) → PNG (filesystem, mutable refs, 5-minute TTL)
# ---------------------------------------------------------------------------
_T4M_TTL = 300 # seconds
def _t4m_path(url: str) -> Path:
cache_dir = Path(user_cache_dir("ifcurl")) / "renders" / "mutable"
cache_dir.mkdir(parents=True, exist_ok=True)
url_hash = hashlib.sha256(url.encode()).hexdigest()
return cache_dir / f"{url_hash}.png"
def _t4m_get(url: str) -> tuple[bytes, int] | None:
"""Return (png_bytes, remaining_seconds) or None if missing/expired."""
path = _t4m_path(url)
try:
remaining = int(os.path.getmtime(path) + _T4M_TTL - time.time())
if remaining <= 0:
return None
return path.read_bytes(), remaining
except (FileNotFoundError, OSError):
return None
def _t4m_put(url: str, png: bytes) -> None:
_t4m_path(url).write_bytes(png)
# ---------------------------------------------------------------------------
# Request / response models
# ---------------------------------------------------------------------------
class PreviewRequest(BaseModel):
url: str
token: str | None = None
"""Optional bearer token for git authentication.
When provided, takes precedence over any token configured in
``~/.config/ifcurl/tokens.json``. Intended for co-located Gitea
deployments that pass the requesting user's session token.
"""
class SelectRequest(BaseModel):
url: str
token: str | None = None
class QueryRequest(BaseModel):
url: str
token: str | None = None
class BcfRequest(BaseModel):
url: str
title: str = "IFC View"
comment: str = ""
token: str | None = None
class DiffRequest(BaseModel):
base: str
"""ifc:// URL for the base (older) commit."""
head: str
"""ifc:// URL for the head (newer) commit."""
token: str | None = None
"""Optional bearer token for git authentication."""
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@app.get("/preview")
def preview_get(url: str, token: str | None = None) -> Response:
"""GET variant of POST /preview for use in HTML ``<img src>`` tags.
The ``url`` query parameter is the ifc:// URL to render. The optional
``token`` parameter accepts a bearer token for private-repository access,
mirroring the JSON body ``token`` field of POST /preview. All caching
and authentication behaviour is otherwise identical to the POST endpoint.
"""
return preview(PreviewRequest(url=url, token=token))
@app.post("/preview")
def preview(request: PreviewRequest) -> Response:
"""Render an ifc:// URL to a PNG image.
Returns ``image/png``. For immutable refs the result is cached on disk
and served directly on subsequent requests without re-rendering.
"""
# --- Parse ---
try:
ifc_url = IfcUrl.parse(request.url)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if ifc_url.path is None:
raise HTTPException(status_code=400, detail="URL has no 'path' parameter")
# --- SSRF protection ---
_ssrf_check(ifc_url)
# --- Tier 4 / 4m: cached PNG ---
if ifc_url.is_mutable_ref():
t4m_hit = _t4m_get(request.url)
if t4m_hit is not None:
cached_png, remaining = t4m_hit
return Response(
content=cached_png,
media_type="image/png",
headers={"Cache-Control": f"public, max-age={remaining}"},
)
else:
cached_png = _t4_get(request.url)
if cached_png is not None:
return Response(
content=cached_png,
media_type="image/png",
headers={"Cache-Control": "public, max-age=31536000, immutable"},
)
# --- Resolve authentication token ---
token = request.token
if token is None and ifc_url.host:
token = get_token_for_host(ifc_url.host)
# --- Fetch IFC bytes + commit hexsha ---
try:
hexsha, ifc_bytes, is_stale = fetch_ifc(ifc_url, token=token)
except ImportError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
_check_ifc_size(ifc_bytes)
# --- Tier 2: populate / use byte cache ---
cached_bytes = _t2_get(hexsha, ifc_url.path)
if cached_bytes is not None:
ifc_bytes = cached_bytes
else:
_t2_put(hexsha, ifc_url.path, ifc_bytes)
# --- Tier 3: check GUID cache ---
# T3 hit → pass element_guids (child converts to step IDs), skip selector
# T3 miss → pass selector string (child runs filter_elements and returns GUIDs)
cached_guids: frozenset[str] | None = None
selector_for_sandbox: str | None = ifc_url.selector
element_guids: frozenset[str] | None = None
if ifc_url.selector:
cached_guids = _t3_get(hexsha, ifc_url.path, ifc_url.selector)
if cached_guids is not None:
element_guids = cached_guids
selector_for_sandbox = None
# --- Sandboxed parse + select + render ---
try:
if _RENDER_SOCKET:
new_guids, png_bytes = _render_via_socket(
ifc_bytes,
selector_for_sandbox,
element_guids,
ifc_url.camera,
ifc_url.fov,
ifc_url.scale,
ifc_url.clips or [],
ifc_url.visibility,
)
else:
new_guids, png_bytes = run_sandboxed(
_sandboxed_pipeline,
ifc_bytes,
selector_for_sandbox,
element_guids,
ifc_url.camera,
ifc_url.fov,
ifc_url.scale,
ifc_url.clips or [],
ifc_url.visibility,
)
except SandboxCrashError as exc:
raise HTTPException(
status_code=422, detail=f"IFC parse/render crashed: {exc}"
) from exc
except SandboxTimeoutError as exc:
raise HTTPException(status_code=503, detail=f"Render timed out: {exc}") from exc
except ImportError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
# --- Tier 3: populate GUID cache from selector run ---
if new_guids is not None and ifc_url.selector:
_t3_put(hexsha, ifc_url.path, ifc_url.selector, new_guids)
# --- Tier 4 / 4m: store PNG and return with appropriate cache headers ---
extra_headers = {"X-Ifcurl-Cache": "stale"} if is_stale else {}
if ifc_url.is_mutable_ref():
_t4m_put(request.url, png_bytes)
return Response(
content=png_bytes,
media_type="image/png",
headers={"Cache-Control": f"public, max-age={_T4M_TTL}", **extra_headers},
)
else:
_t4_put(request.url, png_bytes)
return Response(
content=png_bytes,
media_type="image/png",
headers={
"Cache-Control": "public, max-age=31536000, immutable",
**extra_headers,
},
)
@app.post("/bcf")
def bcf_export(request: BcfRequest) -> Response:
"""Generate a BCF 2.1 zip from an ifc:// URL viewpoint.
Returns ``application/octet-stream`` with a ``.bcf`` zip file containing
the camera, clipping planes, and (when a selector is present) the resolved
component GUID selection.
"""
try:
ifc_url = IfcUrl.parse(request.url)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if ifc_url.path is None:
raise HTTPException(status_code=400, detail="URL has no 'path' parameter")
_ssrf_check(ifc_url)
# Resolve selector → component GUIDs when present.
guids: list[str] | None = None
if ifc_url.selector:
token = request.token
if token is None and ifc_url.host:
token = get_token_for_host(ifc_url.host)
try:
hexsha, ifc_bytes, _ = fetch_ifc(ifc_url, token=token)
except (ImportError, ValueError) as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
_check_ifc_size(ifc_bytes)
cached = _t2_get(hexsha, ifc_url.path)
ifc_bytes = cached if cached is not None else ifc_bytes
if cached is None:
_t2_put(hexsha, ifc_url.path, ifc_bytes)
try:
if _RENDER_SOCKET:
guids = _select_via_socket(ifc_bytes, ifc_url.selector)
else:
guids = run_sandboxed(_sandboxed_select, ifc_bytes, ifc_url.selector)
except SandboxCrashError as exc:
raise HTTPException(
status_code=422, detail=f"IFC parse/select crashed: {exc}"
) from exc
except SandboxTimeoutError as exc:
raise HTTPException(
status_code=503, detail=f"Select timed out: {exc}"
) from exc
except Exception as exc:
raise HTTPException(
status_code=422, detail=f"Invalid selector: {exc}"
) from exc
bcf_bytes = build_bcf(
camera=ifc_url.camera,
fov=ifc_url.fov,
scale=ifc_url.scale,
clips=ifc_url.clips or None,
guids=guids,
visibility=ifc_url.visibility,
title=request.title,
comment=request.comment,
description=request.url,
)
return Response(
content=bcf_bytes,
media_type="application/octet-stream",
headers={"Content-Disposition": 'attachment; filename="view.bcf"'},
)
@app.get("/select")
def select_get(url: str, token: str | None = None) -> JSONResponse:
"""GET variant of POST /select."""
return select(SelectRequest(url=url, token=token))
@app.post("/select")
def select(request: SelectRequest) -> JSONResponse:
"""Resolve an ifc:// URL selector to a list of element GlobalIds.
Returns ``{"guids": [...]}`` JSON. The selector must be present in the
URL's ``selector=`` parameter. Results are cached at Tier 3.
"""
try:
ifc_url = IfcUrl.parse(request.url)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if ifc_url.path is None:
raise HTTPException(status_code=400, detail="URL has no 'path' parameter")
if not ifc_url.selector:
raise HTTPException(status_code=400, detail="URL has no 'selector' parameter")
_ssrf_check(ifc_url)
token = request.token
if token is None and ifc_url.host:
token = get_token_for_host(ifc_url.host)
try:
hexsha, ifc_bytes, _ = fetch_ifc(ifc_url, token=token)
except ImportError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
_check_ifc_size(ifc_bytes)
cached_bytes = _t2_get(hexsha, ifc_url.path)
ifc_bytes = cached_bytes if cached_bytes is not None else ifc_bytes
if cached_bytes is None:
_t2_put(hexsha, ifc_url.path, ifc_bytes)
cached_guids = _t3_get(hexsha, ifc_url.path, ifc_url.selector)
if cached_guids is not None:
return JSONResponse({"guids": list(cached_guids)})
try:
if _RENDER_SOCKET:
guids = _select_via_socket(ifc_bytes, ifc_url.selector)
else:
guids = run_sandboxed(_sandboxed_select, ifc_bytes, ifc_url.selector)
except SandboxCrashError as exc:
raise HTTPException(
status_code=422, detail=f"IFC parse/select crashed: {exc}"
) from exc
except SandboxTimeoutError as exc:
raise HTTPException(status_code=503, detail=f"Select timed out: {exc}") from exc
except Exception as exc:
raise HTTPException(status_code=422, detail=f"Invalid selector: {exc}") from exc
_t3_put(hexsha, ifc_url.path, ifc_url.selector, frozenset(guids))
return JSONResponse({"guids": guids})
@app.get("/query")
def query_get(url: str, token: str | None = None) -> JSONResponse:
"""GET variant of POST /query."""
return query(QueryRequest(url=url, token=token))
@app.post("/query")
def query(request: QueryRequest) -> JSONResponse:
"""Retrieve a property or attribute value for each element matched by the selector.
The URL must contain both ``selector=`` and ``query=`` parameters. The
``query`` value uses dot notation: a bare name is an IFC attribute
(e.g. ``Name``); a dotted name addresses a property set
(e.g. ``Pset_WallCommon.FireRating``).
Returns ``{"<GlobalId>": "<value>", …}`` JSON. Elements with no GlobalId
or whose query path resolves to an entity reference are excluded.
Results are not cached at Tier 4 — the response depends on the query path
as well as the URL, making a shared cache key impractical.
"""
try:
ifc_url = IfcUrl.parse(request.url)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if ifc_url.path is None:
raise HTTPException(status_code=400, detail="URL has no 'path' parameter")
if not ifc_url.selector:
raise HTTPException(status_code=400, detail="URL has no 'selector' parameter")
if not ifc_url.query:
raise HTTPException(status_code=400, detail="URL has no 'query' parameter")
_ssrf_check(ifc_url)
token = request.token
if token is None and ifc_url.host:
token = get_token_for_host(ifc_url.host)
try:
hexsha, ifc_bytes, _ = fetch_ifc(ifc_url, token=token)
except ImportError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
_check_ifc_size(ifc_bytes)
cached_bytes = _t2_get(hexsha, ifc_url.path)
ifc_bytes = cached_bytes if cached_bytes is not None else ifc_bytes
if cached_bytes is None:
_t2_put(hexsha, ifc_url.path, ifc_bytes)
try:
if _RENDER_SOCKET:
result = _query_via_socket(ifc_bytes, ifc_url.selector, ifc_url.query)
else:
result = run_sandboxed(_sandboxed_query, ifc_bytes, ifc_url.selector, ifc_url.query)
except SandboxCrashError as exc:
raise HTTPException(
status_code=422, detail=f"IFC parse/query crashed: {exc}"
) from exc
except SandboxTimeoutError as exc:
raise HTTPException(status_code=503, detail=f"Query timed out: {exc}") from exc
except Exception as exc:
raise HTTPException(status_code=422, detail=f"Query failed: {exc}") from exc
return JSONResponse(result)
@app.get("/foundation/versions")
def foundation_versions(request: Request) -> JSONResponse:
"""OpenCDE Foundation API discovery endpoint.
Public, no auth required. Returns the base URLs for all OpenCDE APIs
supported by this server. Proxied at /foundation/versions on the
Forgejo hostname via nginx/Caddy.
The public base URL is derived from X-Forwarded-Host and X-Forwarded-Proto
headers set by the reverse proxy.
"""
host = request.headers.get("x-forwarded-host") or request.headers.get("host", "localhost")
proto = request.headers.get("x-forwarded-proto", "https")
base = f"{proto}://{host}"
return JSONResponse({
"versions": [
{
"api_id": "foundation",
"version_id": "1.1",
"detailed_version": "https://github.com/buildingSMART/foundation-API/tree/release_1_1",
"api_base_url": f"{base}/foundation/1.1",
},
{
"api_id": "bcf",
"version_id": "3.0",
"detailed_version": "https://github.com/buildingSMART/BCF-API/tree/release_3_0",
"api_base_url": f"{base}/bcf/3.0",
},
{
"api_id": "documents",
"version_id": "1.0",
"detailed_version": "https://github.com/buildingSMART/documents-API/tree/release_1_0",
"api_base_url": f"{base}/documents/1.0",
},
]
})
@app.get("/render_diff")
def render_diff_get(base: str, head: str, token: str | None = None) -> Response:
"""GET variant of POST /render_diff for use in HTML ``<img src>`` tags.
*base* and *head* are ifc:// URLs (URL-encoded) for the base and head
commits of the same IFC file. The optional *token* is a bearer token
for private-repository access.
"""
return render_diff(DiffRequest(base=base, head=head, token=token))
@app.post("/render_diff")
def render_diff(request: DiffRequest) -> Response:
"""Render a two-pass IFC diff image (green=added, blue=modified, red=removed).
Both URLs must refer to the same IFC file path in the same repository.
The result is cached on disk when both refs are immutable (commit hashes).
Returns ``image/png``.
"""
# --- Parse ---
try:
base_url = IfcUrl.parse(request.base)
head_url = IfcUrl.parse(request.head)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
for label, ifc_url in (("base", base_url), ("head", head_url)):
if ifc_url.path is None:
raise HTTPException(
status_code=400, detail=f"{label} URL has no 'path' parameter"
)
if base_url.path != head_url.path:
raise HTTPException(
status_code=400,
detail=f"base and head must refer to the same IFC path ({base_url.path!r} vs {head_url.path!r})",
)
# --- SSRF protection ---
_ssrf_check(base_url)
_ssrf_check(head_url)
# --- Tier 4: cached diff PNG (immutable refs only) ---
both_immutable = not base_url.is_mutable_ref() and not head_url.is_mutable_ref()
cache_key = f"diff:{request.base}:{request.head}"
if both_immutable:
cached_png = _t4_get(cache_key)
if cached_png is not None:
return Response(
content=cached_png,
media_type="image/png",
headers={"Cache-Control": "public, max-age=31536000, immutable"},
)
# --- Resolve authentication token ---
token = request.token
if token is None and base_url.host:
token = get_token_for_host(base_url.host)
# --- Fetch IFC bytes for both commits ---
try:
base_hexsha, base_bytes, base_stale = fetch_ifc(base_url, token=token)
head_hexsha, head_bytes, head_stale = fetch_ifc(head_url, token=token)
except ImportError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
except ValueError as 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 ---
for hexsha, path, ifc_bytes in (
(base_hexsha, base_url.path, base_bytes),
(head_hexsha, head_url.path, head_bytes),
):
cached = _t2_get(hexsha, path)
if cached is not None:
if hexsha == base_hexsha:
base_bytes = cached
else:
head_bytes = cached
else:
_t2_put(hexsha, path, ifc_bytes)
# --- Get git diff text ---
try:
raw_diff = git_diff_text(base_url, head_url, token=token)
except (ImportError, ValueError) as exc:
raise HTTPException(status_code=422, detail=f"Could not diff: {exc}") from exc
# --- Sandboxed parse + expand + render ---
try:
if _RENDER_SOCKET:
png_bytes = _diff_via_socket(
head_bytes,
base_bytes,
raw_diff,
head_url.camera,
head_url.fov,
head_url.scale,
head_url.clips or [],
)
else:
png_bytes = run_sandboxed(
_sandboxed_diff,
head_bytes,
base_bytes,
raw_diff,
head_url.camera,
head_url.fov,
head_url.scale,
head_url.clips or [],
)
except SandboxCrashError as exc:
raise HTTPException(
status_code=422, detail=f"IFC diff render crashed: {exc}"
) from exc
except SandboxTimeoutError as exc:
raise HTTPException(
status_code=503, detail=f"Diff render timed out: {exc}"
) from exc
except ImportError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
# --- Tier 4: cache and return ---
diff_stale_headers = (
{"X-Ifcurl-Cache": "stale"} if (base_stale or head_stale) else {}
)
if both_immutable:
_t4_put(cache_key, png_bytes)
return Response(
content=png_bytes,
media_type="image/png",
headers={
"Cache-Control": "public, max-age=31536000, immutable",
**diff_stale_headers,
},
)
return Response(
content=png_bytes,
media_type="image/png",
headers={"Cache-Control": "no-store", **diff_stale_headers},
)