mirror of
https://github.com/brunopostle/ifcurl.git
synced 2026-07-12 02:08:13 +00:00
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
988 lines
34 KiB
Python
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},
|
|
)
|