mirror of
https://github.com/brunopostle/ifcurl.git
synced 2026-07-12 10:18:14 +00:00
Split service into ifcurl-api + ifcurl-render for RCE isolation
render_service.py: new FastAPI app with /render, /select, /render_diff endpoints that wrap run_sandboxed — listens on a Unix domain socket. service.py: imports the three pipeline functions from render_service.py; delegates to render service via httpx when IFCURL_RENDER_SOCKET is set, falls back to direct run_sandboxed when unset (single-service mode). __main__.py: add 'render-service' subcommand (--socket PATH). pyproject.toml: add httpx to service optional deps. ifcurl-api.service: renamed from ifcurl-preview.service; sets IFCURL_RENDER_SOCKET, adds AF_UNIX to RestrictAddressFamilies. ifcurl-render.service: new hardened unit — AF_UNIX only, ~execve ~execveat blocked, no credentials, separate ifcurl-render user. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6e62f9e51c
commit
6b6e47ef54
8 changed files with 598 additions and 105 deletions
|
|
@ -2,6 +2,7 @@
|
||||||
{"id":"ifcurl-p60","title":"Viewer: no element click-to-identify — can't get GlobalId or properties","description":"There is no way to click on a mesh in the viewer and see which IFC element it represents. Reviewers can see geometry but cannot reference specific elements — they can't discover a GlobalId to put in a selector URL, can't see the element name/type/Psets, and can't say 'this specific wall' in a BCF or Forgejo comment. This breaks the collaboration workflow at its foundation: every useful feedback comment needs to identify specific elements. Need: click or hover on a surface to highlight it and show a properties panel (name, type, GlobalId, key Psets). The GlobalId should be copyable into the selector field or URL. The ThatOpen components library provides the tools for this (OBC.IfcRelationsIndexer, element property queries).","status":"closed","priority":1,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T11:02:33Z","created_by":"Bruno Postle","updated_at":"2026-04-24T11:47:23Z","started_at":"2026-04-24T11:14:57Z","closed_at":"2026-04-24T11:47:23Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"id":"ifcurl-p60","title":"Viewer: no element click-to-identify — can't get GlobalId or properties","description":"There is no way to click on a mesh in the viewer and see which IFC element it represents. Reviewers can see geometry but cannot reference specific elements — they can't discover a GlobalId to put in a selector URL, can't see the element name/type/Psets, and can't say 'this specific wall' in a BCF or Forgejo comment. This breaks the collaboration workflow at its foundation: every useful feedback comment needs to identify specific elements. Need: click or hover on a surface to highlight it and show a properties panel (name, type, GlobalId, key Psets). The GlobalId should be copyable into the selector field or URL. The ThatOpen components library provides the tools for this (OBC.IfcRelationsIndexer, element property queries).","status":"closed","priority":1,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T11:02:33Z","created_by":"Bruno Postle","updated_at":"2026-04-24T11:47:23Z","started_at":"2026-04-24T11:14:57Z","closed_at":"2026-04-24T11:47:23Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
{"id":"ifcurl-4l7","title":"viewer.html: scale= URLs don't load in orthographic mode","description":"applyCameraParam() reads the camera= and fov= parameters from the URL and applies them, but never reads scale= or switches the camera to orthographic (parallel) projection. URLs like ?camera=...\u0026scale=50 will load in perspective mode instead of orthographic. The OBC OrthoPerspectiveCamera can switch modes, but the code path to set parallel projection and apply the scale value is missing from applyCameraParam.","status":"closed","priority":1,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T09:04:51Z","created_by":"Bruno Postle","updated_at":"2026-04-24T10:56:29Z","started_at":"2026-04-24T10:50:19Z","closed_at":"2026-04-24T10:56:29Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"id":"ifcurl-4l7","title":"viewer.html: scale= URLs don't load in orthographic mode","description":"applyCameraParam() reads the camera= and fov= parameters from the URL and applies them, but never reads scale= or switches the camera to orthographic (parallel) projection. URLs like ?camera=...\u0026scale=50 will load in perspective mode instead of orthographic. The OBC OrthoPerspectiveCamera can switch modes, but the code path to set parallel projection and apply the scale value is missing from applyCameraParam.","status":"closed","priority":1,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T09:04:51Z","created_by":"Bruno Postle","updated_at":"2026-04-24T10:56:29Z","started_at":"2026-04-24T10:50:19Z","closed_at":"2026-04-24T10:56:29Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
{"id":"ifcurl-362","title":"Add missing server-config files: gitconfig-ifcmerge and gitattributes","description":"forgejo/README.md documents deploying two files that don't exist in the repo: forgejo/server-config/gitconfig-ifcmerge and forgejo/server-config/gitattributes. Both are required for the ifcmerge git merge driver to work in bare Forgejo repos. The README even includes 'sudo cp forgejo/server-config/gitattributes /etc/gitattributes' and 'sudo git config --system include.path .../gitconfig-ifcmerge', but the files themselves are absent. Anyone following the deployment instructions will hit a 'file not found' error. Need to create both files with the content described in the README.","status":"closed","priority":1,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T09:04:31Z","created_by":"Bruno Postle","updated_at":"2026-04-24T10:51:47Z","started_at":"2026-04-24T10:50:15Z","closed_at":"2026-04-24T10:51:47Z","close_reason":"Files already exist: forgejo/server-config/gitconfig-ifcmerge and gitattributes are both present and correct. Missed in initial review.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"id":"ifcurl-362","title":"Add missing server-config files: gitconfig-ifcmerge and gitattributes","description":"forgejo/README.md documents deploying two files that don't exist in the repo: forgejo/server-config/gitconfig-ifcmerge and forgejo/server-config/gitattributes. Both are required for the ifcmerge git merge driver to work in bare Forgejo repos. The README even includes 'sudo cp forgejo/server-config/gitattributes /etc/gitattributes' and 'sudo git config --system include.path .../gitconfig-ifcmerge', but the files themselves are absent. Anyone following the deployment instructions will hit a 'file not found' error. Need to create both files with the content described in the README.","status":"closed","priority":1,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T09:04:31Z","created_by":"Bruno Postle","updated_at":"2026-04-24T10:51:47Z","started_at":"2026-04-24T10:50:15Z","closed_at":"2026-04-24T10:51:47Z","close_reason":"Files already exist: forgejo/server-config/gitconfig-ifcmerge and gitattributes are both present and correct. Missed in initial review.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"id":"ifcurl-bue","title":"Split service into ifcurl-api and ifcurl-render for RCE isolation","description":"The ifcopenshell C++ bindings are the main RCE attack surface (memory corruption on malformed IFC). The git/token code is not at risk but currently shares the same process and systemd security profile.\n\nSplit the service into two systemd units with different security profiles:\n\nifcurl-api (renamed from ifcurl-preview): handles HTTP, git fetch, caching, auth token lookup. Needs execve (git CLI via GitPython) and AF_INET/AF_INET6. No ifcopenshell.\n\nifcurl-render: handles IFC parsing and PNG rendering only. Listens on Unix socket /run/ifcurl-render/render.sock. Has SystemCallFilter ~execve ~execveat (confirmed safe: strace shows pyvista and ifcopenshell geom iterator use fork/clone only, never exec). Has RestrictAddressFamilies=AF_UNIX only. No access to /etc/ifcurl tokens. Runs as separate user ifcurl-render.\n\nCommunication: API service calls render service via httpx over the Unix socket. Both service users belong to a shared ifcurl group for socket access.\n\nInterface (three endpoints on render service):\n POST /render multipart(ifc, params JSON) → JSON {png: base64, guids: list|null}\n POST /select multipart(ifc, params JSON) → JSON {guids: list}\n POST /render_diff multipart(head_ifc, base_ifc, params JSON) → image/png binary\n\nsandbox.py crash isolation is kept inside the render service for per-request segfault containment.\n\nBackward compat: if IFCURL_RENDER_SOCKET is unset, API service falls back to direct in-process rendering (current sandbox.py path).\n\nImplementation steps:\n1. ifcurl/render_service.py — FastAPI app with the three endpoints wrapping sandboxed functions\n2. ifcurl/service.py — delegate to render service when IFCURL_RENDER_SOCKET is set\n3. ifcurl/__main__.py — add 'render-service' CLI subcommand\n4. pyproject.toml — add httpx to service optional deps\n5. forgejo/server-config/ifcurl-api.service (rename + update)\n6. forgejo/server-config/ifcurl-render.service (new, hardened unit)\n7. forgejo/README.md — update deployment docs for two-service setup\n8. tests/test_render_service.py — integration tests for the render service endpoints","acceptance_criteria":"render service cannot exec /bin/sh even with RCE (verified by strace showing no execve during normal render); normal renders still produce correct PNG; API service correctly delegates to render service when socket is configured; fallback to in-process rendering works when socket is unset","status":"closed","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-25T08:32:27Z","created_by":"Bruno Postle","updated_at":"2026-04-25T08:57:22Z","started_at":"2026-04-25T08:32:57Z","closed_at":"2026-04-25T08:57:22Z","close_reason":"Two-service split implemented: render_service.py with /render /select /render_diff endpoints, service.py delegates via httpx when IFCURL_RENDER_SOCKET is set, render-service CLI subcommand added, two systemd units created","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
{"id":"ifcurl-ndf","title":"gitconfig-ifcmerge: update to use ifcmerge --prioritise-local instead of swapping LOCAL/REMOTE","description":"The current gitconfig-ifcmerge uses the old technique of swapping the order of %A and %B arguments to preserve the base branch's STEP ID space:\n\n driver = ifcmerge %O %A %B %L (standard)\n driver = ifcmerge %O %B %A %L (ours variant)\n\nBonsai's current ifcgit code (tool/ifcgit.py, config_ifcmerge) uses the newer --prioritise-local flag instead:\n\n ifcmerge --prioritise-local $BASE $LOCAL $REMOTE $MERGED\n\nThe --prioritise-local flag tells ifcmerge to preserve LOCAL's STEP ID space without needing to swap argument order. This is cleaner, more readable, and follows the canonical ifcmerge interface.\n\nThe gitconfig-ifcmerge needs updating to:\n driver = ifcmerge --prioritise-local %O %A %B %A\n\n(where %A is both LOCAL and the output file — git merge drivers write their result to %A)\n\nThe ifcmerge_ours variant (for rebase direction where base branch arrives as %B) would become:\n driver = ifcmerge --prioritise-local %O %B %A %B\n\nVerify that the --prioritise-local flag exists in the version of ifcmerge being used before merging.","status":"closed","priority":2,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-04-25T06:06:59Z","created_by":"Bruno Postle","updated_at":"2026-04-25T06:41:59Z","closed_at":"2026-04-25T06:41:59Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"id":"ifcurl-ndf","title":"gitconfig-ifcmerge: update to use ifcmerge --prioritise-local instead of swapping LOCAL/REMOTE","description":"The current gitconfig-ifcmerge uses the old technique of swapping the order of %A and %B arguments to preserve the base branch's STEP ID space:\n\n driver = ifcmerge %O %A %B %L (standard)\n driver = ifcmerge %O %B %A %L (ours variant)\n\nBonsai's current ifcgit code (tool/ifcgit.py, config_ifcmerge) uses the newer --prioritise-local flag instead:\n\n ifcmerge --prioritise-local $BASE $LOCAL $REMOTE $MERGED\n\nThe --prioritise-local flag tells ifcmerge to preserve LOCAL's STEP ID space without needing to swap argument order. This is cleaner, more readable, and follows the canonical ifcmerge interface.\n\nThe gitconfig-ifcmerge needs updating to:\n driver = ifcmerge --prioritise-local %O %A %B %A\n\n(where %A is both LOCAL and the output file — git merge drivers write their result to %A)\n\nThe ifcmerge_ours variant (for rebase direction where base branch arrives as %B) would become:\n driver = ifcmerge --prioritise-local %O %B %A %B\n\nVerify that the --prioritise-local flag exists in the version of ifcmerge being used before merging.","status":"closed","priority":2,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-04-25T06:06:59Z","created_by":"Bruno Postle","updated_at":"2026-04-25T06:41:59Z","closed_at":"2026-04-25T06:41:59Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
{"id":"ifcurl-qjd","title":"render_diff: auto-fit camera to changed elements, not whole model","description":"When rendering a diff of a large building model, the auto-fit camera fits to the entire head model. A wall added to one corner of a multi-storey building would appear as a tiny green speck. The camera should instead zoom to the bounding box of just the added + modified + removed elements so the changed area fills the viewport.\n\nImplementation sketch:\n- In render_diff(), after adding all shapes to plotter1, call plotter1.reset_camera() as now, but also compute the bounds of only the diff-coloured meshes (added + modified) and re-fit to those bounds.\n- For pass 2 (removed elements), the elements are already isolated in the iterator, so reset_camera() naturally fits to them. The pass 1 camera capture then needs to consider that pass 2 might be out of that frustum.\n- A combined approach: compute the bounding box union of added + modified + removed element meshes, then set the camera to fit that box with a small margin. This is the camera used for both passes.\n- pyvista Plotter has plotter.reset_camera(bounds=(xmin,xmax,ymin,ymax,zmin,zmax)) which can fit to an explicit box.\n- The bounds can be accumulated during the _add_shape loop for diff-coloured elements.\n\nAcceptance: a single modified wall in a large building renders with the wall clearly visible and filling most of the image.","status":"closed","priority":2,"issue_type":"bug","owner":"bruno@postle.net","created_at":"2026-04-25T06:06:39Z","created_by":"Bruno Postle","updated_at":"2026-04-25T06:57:06Z","closed_at":"2026-04-25T06:57:06Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"id":"ifcurl-qjd","title":"render_diff: auto-fit camera to changed elements, not whole model","description":"When rendering a diff of a large building model, the auto-fit camera fits to the entire head model. A wall added to one corner of a multi-storey building would appear as a tiny green speck. The camera should instead zoom to the bounding box of just the added + modified + removed elements so the changed area fills the viewport.\n\nImplementation sketch:\n- In render_diff(), after adding all shapes to plotter1, call plotter1.reset_camera() as now, but also compute the bounds of only the diff-coloured meshes (added + modified) and re-fit to those bounds.\n- For pass 2 (removed elements), the elements are already isolated in the iterator, so reset_camera() naturally fits to them. The pass 1 camera capture then needs to consider that pass 2 might be out of that frustum.\n- A combined approach: compute the bounding box union of added + modified + removed element meshes, then set the camera to fit that box with a small margin. This is the camera used for both passes.\n- pyvista Plotter has plotter.reset_camera(bounds=(xmin,xmax,ymin,ymax,zmin,zmax)) which can fit to an explicit box.\n- The bounds can be accumulated during the _add_shape loop for diff-coloured elements.\n\nAcceptance: a single modified wall in a large building renders with the wall clearly visible and filling most of the image.","status":"closed","priority":2,"issue_type":"bug","owner":"bruno@postle.net","created_at":"2026-04-25T06:06:39Z","created_by":"Bruno Postle","updated_at":"2026-04-25T06:57:06Z","closed_at":"2026-04-25T06:57:06Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
{"id":"ifcurl-zxs","title":"sandbox.py: add seccomp filter to block execve and socket in render subprocess","description":"The subprocess isolation in sandbox.py (ifcurl-vm5) prevents DoS but not RCE: if a crafted IFC file achieves code execution inside the render child, that child still has full access to execve and the network. A seccomp BPF filter applied at the start of _worker() would block the two most useful post-exploitation steps even under active code execution.\n\nThe filter should:\n- Block execve/execveat (cannot exec a shell or any binary)\n- Block socket() for AF_INET/AF_INET6/AF_UNIX (cannot open a reverse shell or connect to the Forgejo Unix socket)\n- Allow clone/fork (ifcopenshell's geom.iterator spawns its own worker processes)\n- Allow everything else needed by Python + ifcopenshell + pyvista\n\nThe python-seccomp library (pip install seccomp, wraps libseccomp) is the cleanest way to do this. The filter would be installed via resource.setrlimit companion code already present in sandbox._worker().\n\nThe main risk is over-filtering: if the seccomp policy blocks a syscall that ifcopenshell or pyvista needs, renders fail with EPERM. The policy needs to be validated against the full render path (including the geometry iterator subprocesses, which inherit the filter across fork).\n\nAcceptance criteria:\n- A render subprocess cannot exec /bin/sh even with arbitrary code execution (verified by replacing fn with a ctypes execve call — should get EPERM/killed, not a shell)\n- Normal renders continue to produce correct PNG output\n- The seccomp library is optional (graceful fallback if libseccomp is absent, e.g. on macOS or minimal containers)","status":"closed","priority":2,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-04-24T20:47:00Z","created_by":"Bruno Postle","updated_at":"2026-04-25T07:30:44Z","closed_at":"2026-04-25T07:30:44Z","close_reason":"systemd hardening accepted as sufficient; execve cannot be blocked per-subprocess without non-standard seccomp library; residual risk acceptable for threat model","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"id":"ifcurl-zxs","title":"sandbox.py: add seccomp filter to block execve and socket in render subprocess","description":"The subprocess isolation in sandbox.py (ifcurl-vm5) prevents DoS but not RCE: if a crafted IFC file achieves code execution inside the render child, that child still has full access to execve and the network. A seccomp BPF filter applied at the start of _worker() would block the two most useful post-exploitation steps even under active code execution.\n\nThe filter should:\n- Block execve/execveat (cannot exec a shell or any binary)\n- Block socket() for AF_INET/AF_INET6/AF_UNIX (cannot open a reverse shell or connect to the Forgejo Unix socket)\n- Allow clone/fork (ifcopenshell's geom.iterator spawns its own worker processes)\n- Allow everything else needed by Python + ifcopenshell + pyvista\n\nThe python-seccomp library (pip install seccomp, wraps libseccomp) is the cleanest way to do this. The filter would be installed via resource.setrlimit companion code already present in sandbox._worker().\n\nThe main risk is over-filtering: if the seccomp policy blocks a syscall that ifcopenshell or pyvista needs, renders fail with EPERM. The policy needs to be validated against the full render path (including the geometry iterator subprocesses, which inherit the filter across fork).\n\nAcceptance criteria:\n- A render subprocess cannot exec /bin/sh even with arbitrary code execution (verified by replacing fn with a ctypes execve call — should get EPERM/killed, not a shell)\n- Normal renders continue to produce correct PNG output\n- The seccomp library is optional (graceful fallback if libseccomp is absent, e.g. on macOS or minimal containers)","status":"closed","priority":2,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-04-24T20:47:00Z","created_by":"Bruno Postle","updated_at":"2026-04-25T07:30:44Z","closed_at":"2026-04-25T07:30:44Z","close_reason":"systemd hardening accepted as sufficient; execve cannot be blocked per-subprocess without non-standard seccomp library; residual risk acceptable for threat model","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
|
@ -57,7 +58,7 @@
|
||||||
{"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":"open","priority":3,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-04-24T10:40:49Z","created_by":"Bruno Postle","updated_at":"2026-04-24T10:40:49Z","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":"open","priority":3,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-04-24T10:40:49Z","created_by":"Bruno Postle","updated_at":"2026-04-24T10:40:49Z","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":"open","priority":3,"issue_type":"bug","owner":"bruno@postle.net","created_at":"2026-04-24T10:40:35Z","created_by":"Bruno Postle","updated_at":"2026-04-24T10:40:35Z","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":"open","priority":3,"issue_type":"bug","owner":"bruno@postle.net","created_at":"2026-04-24T10:40:35Z","created_by":"Bruno Postle","updated_at":"2026-04-24T10:40:35Z","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":"open","priority":3,"issue_type":"bug","owner":"bruno@postle.net","created_at":"2026-04-24T10:40:31Z","created_by":"Bruno Postle","updated_at":"2026-04-24T10:40:31Z","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-69l","title":"License headers for Forgejo integration files","description":"The Go patch files (ifc_url.go, ifc_url_test.go) already carry MIT headers matching Forgejo's per-file convention. The JS/HTML assets have no license header: viewer.html, viewer-url.js, footer.tmpl. Review: (1) confirm MIT is correct for files intended for Forgejo contribution (Forgejo project is GPL-3.0 but individual contributed files use MIT); (2) check compatibility with ThatOpen components licence (viewer.html loads @thatopen/components from CDN); (3) add SPDX licence headers to the three unlicensed asset files. The Python/CLI core remains LGPL-3.0-or-later as it targets IfcOpenShell contribution.","status":"closed","priority":3,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-04-24T07:09:46Z","created_by":"Bruno Postle","updated_at":"2026-04-24T07:29:22Z","closed_at":"2026-04-24T07:29:22Z","close_reason":"Added MIT SPDX headers to viewer.html, viewer-url.js, footer.tmpl — ThatOpen components also MIT-licensed so no compatibility issue","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"id":"ifcurl-69l","title":"License headers for Forgejo integration files","description":"The Go patch files (ifc_url.go, ifc_url_test.go) already carry MIT headers matching Forgejo's per-file convention. The JS/HTML assets have no license header: viewer.html, viewer-url.js, footer.tmpl. Review: (1) confirm MIT is correct for files intended for Forgejo contribution (Forgejo project is GPL-3.0 but individual contributed files use MIT); (2) check compatibility with ThatOpen components licence (viewer.html loads @thatopen/components from CDN); (3) add SPDX licence headers to the three unlicensed asset files. The Python/CLI core remains LGPL-3.0-or-later as it targets IfcOpenShell contribution.","status":"closed","priority":3,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-04-24T07:09:46Z","created_by":"Bruno Postle","updated_at":"2026-04-24T07:29:22Z","closed_at":"2026-04-24T07:29:22Z","close_reason":"Added MIT SPDX headers to viewer.html, viewer-url.js, footer.tmpl — ThatOpen components also MIT-licensed so no compatibility issue","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
{"id":"ifcurl-8s8","title":"viewer.html: interactive tool to set clipping planes","description":"Currently clip= planes can only be set by hand-editing the URL. Add an interactive clipping tool to the viewer: a UI control (e.g. toolbar button) that lets the user drag/place a clipping plane against the loaded model. The plane position and normal should be written back into the ifc:// URL as clip= parameters so the view is shareable. Consider using ThatOpen's clipper component (OBC.Clipper) if available, or a Three.js-based approach.","status":"closed","priority":3,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T05:49:22Z","created_by":"Bruno Postle","updated_at":"2026-04-24T06:23:14Z","started_at":"2026-04-24T06:11:49Z","closed_at":"2026-04-24T06:23:14Z","close_reason":"setupClipper() uses OBC.Clipper: seeds URL planes as draggable SimplePlanes, toolbar '✂ clip' toggle for double-click placement, '⊗ clips' clear button. syncCameraUrl extended to serialise current planes back to URL.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"id":"ifcurl-8s8","title":"viewer.html: interactive tool to set clipping planes","description":"Currently clip= planes can only be set by hand-editing the URL. Add an interactive clipping tool to the viewer: a UI control (e.g. toolbar button) that lets the user drag/place a clipping plane against the loaded model. The plane position and normal should be written back into the ifc:// URL as clip= parameters so the view is shareable. Consider using ThatOpen's clipper component (OBC.Clipper) if available, or a Three.js-based approach.","status":"closed","priority":3,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T05:49:22Z","created_by":"Bruno Postle","updated_at":"2026-04-24T06:23:14Z","started_at":"2026-04-24T06:11:49Z","closed_at":"2026-04-24T06:23:14Z","close_reason":"setupClipper() uses OBC.Clipper: seeds URL planes as draggable SimplePlanes, toolbar '✂ clip' toggle for double-click placement, '⊗ clips' clear button. syncCameraUrl extended to serialise current planes back to URL.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
{"id":"ifcurl-07a","title":"Cache size control for bare repo clones","description":"git.py clones remote repos as bare repos under ~/.cache/ifcurl/\u003chash\u003e/repo.git with no size limit or eviction policy. Large repos accumulate indefinitely. Add a configurable max cache size (e.g. via env var IFCURL_CACHE_MAX_GB or a config file) with an LRU eviction policy based on last-access time of each repo directory. Should also expose a CLI command (ifcurl cache --list, --prune, --clear) to inspect and manage the cache manually.","status":"closed","priority":3,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T05:45:59Z","created_by":"Bruno Postle","updated_at":"2026-04-24T08:06:23Z","started_at":"2026-04-24T07:48:28Z","closed_at":"2026-04-24T08:06:23Z","close_reason":"IFCURL_CACHE_MAX_GB env var for LRU disk eviction; IFCURL_T2_MAX/T3_MAX for in-memory limits; ifcurl cache list/prune/clear CLI; remote_url stored per clone","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"id":"ifcurl-07a","title":"Cache size control for bare repo clones","description":"git.py clones remote repos as bare repos under ~/.cache/ifcurl/\u003chash\u003e/repo.git with no size limit or eviction policy. Large repos accumulate indefinitely. Add a configurable max cache size (e.g. via env var IFCURL_CACHE_MAX_GB or a config file) with an LRU eviction policy based on last-access time of each repo directory. Should also expose a CLI command (ifcurl cache --list, --prune, --clear) to inspect and manage the cache manually.","status":"closed","priority":3,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T05:45:59Z","created_by":"Bruno Postle","updated_at":"2026-04-24T08:06:23Z","started_at":"2026-04-24T07:48:28Z","closed_at":"2026-04-24T08:06:23Z","close_reason":"IFCURL_CACHE_MAX_GB env var for LRU disk eviction; IFCURL_T2_MAX/T3_MAX for in-memory limits; ifcurl cache list/prune/clear CLI; remote_url stored per clone","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,8 @@ forgejo/
|
||||||
templates/custom/
|
templates/custom/
|
||||||
footer.tmpl ← "View in 3D" + PR diff injection (no rebuild needed)
|
footer.tmpl ← "View in 3D" + PR diff injection (no rebuild needed)
|
||||||
server-config/
|
server-config/
|
||||||
ifcurl-preview.service ← systemd unit for the preview service
|
ifcurl-api.service ← systemd unit for the API service (git + caching)
|
||||||
|
ifcurl-render.service ← systemd unit for the render isolation service
|
||||||
gitconfig-ifcmerge ← git merge driver registration for /etc/gitconfig
|
gitconfig-ifcmerge ← git merge driver registration for /etc/gitconfig
|
||||||
gitattributes ← system-wide gitattributes for bare repos
|
gitattributes ← system-wide gitattributes for bare repos
|
||||||
```
|
```
|
||||||
|
|
@ -53,7 +54,7 @@ forgejo/
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- ifcurl preview service running and reachable from the Forgejo server
|
- ifcurl API service + render service running (see [Running the preview service](#running-the-preview-service))
|
||||||
- Nginx (or equivalent) in front of Forgejo if PR diff images are wanted
|
- Nginx (or equivalent) in front of Forgejo if PR diff images are wanted
|
||||||
- Forgejo source tree cloned and building cleanly (`go` 1.21+) — **only if**
|
- Forgejo source tree cloned and building cleanly (`go` 1.21+) — **only if**
|
||||||
markdown inline preview is wanted
|
markdown inline preview is wanted
|
||||||
|
|
@ -141,21 +142,38 @@ sudo systemctl restart forgejo
|
||||||
|
|
||||||
## Running the preview service
|
## Running the preview service
|
||||||
|
|
||||||
### As a systemd service (recommended)
|
The service is split into two systemd units for security isolation:
|
||||||
|
|
||||||
A systemd unit is provided at `server-config/ifcurl-preview.service`.
|
- **ifcurl-api** — handles HTTP requests, git fetching, and caching. Has network
|
||||||
|
access and can call git CLI tools. Holds credentials (`/etc/ifcurl/env`).
|
||||||
|
Delegates all IFC rendering to ifcurl-render over a Unix socket.
|
||||||
|
|
||||||
|
- **ifcurl-render** — handles IFC parsing and rendering via ifcopenshell and pyvista.
|
||||||
|
Has no network access, no credentials, and `execve`/`execveat` are blocked by
|
||||||
|
systemd. Communicates only over the Unix socket.
|
||||||
|
|
||||||
|
This split limits the blast radius if a crafted IFC file achieves code execution
|
||||||
|
in the render process: it cannot reach git hosts or read credentials.
|
||||||
|
|
||||||
|
### As systemd services (recommended)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Create a dedicated user
|
# Create the two service users
|
||||||
sudo useradd --system --no-create-home --shell /usr/sbin/nologin ifcurl
|
sudo useradd --system --no-create-home --shell /usr/sbin/nologin ifcurl
|
||||||
|
sudo useradd --system --no-create-home --shell /usr/sbin/nologin ifcurl-render
|
||||||
|
|
||||||
# Install the unit (edit AllowedHosts and port first)
|
# Add ifcurl user to ifcurl-render group so it can read the socket
|
||||||
sudo cp forgejo/server-config/ifcurl-preview.service /etc/systemd/system/
|
sudo usermod -aG ifcurl-render ifcurl
|
||||||
|
|
||||||
|
# Install the units (edit AllowedHosts and port in ifcurl-api.service first)
|
||||||
|
sudo cp forgejo/server-config/ifcurl-api.service /etc/systemd/system/
|
||||||
|
sudo cp forgejo/server-config/ifcurl-render.service /etc/systemd/system/
|
||||||
sudo systemctl daemon-reload
|
sudo systemctl daemon-reload
|
||||||
sudo systemctl enable --now ifcurl-preview
|
sudo systemctl enable --now ifcurl-render
|
||||||
|
sudo systemctl enable --now ifcurl-api
|
||||||
```
|
```
|
||||||
|
|
||||||
Edit the `ExecStart` line to set your Forgejo hostname:
|
Edit the `ExecStart` line in `ifcurl-api.service` to set your Forgejo hostname:
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
ExecStart=/usr/local/bin/ifcurl serve \
|
ExecStart=/usr/local/bin/ifcurl serve \
|
||||||
|
|
@ -164,7 +182,7 @@ ExecStart=/usr/local/bin/ifcurl serve \
|
||||||
--allowed-hosts git.example.com
|
--allowed-hosts git.example.com
|
||||||
```
|
```
|
||||||
|
|
||||||
Cache limits and other environment variables can be set in
|
Cache limits and other environment variables for the API service go in
|
||||||
`/etc/ifcurl/env` (one `KEY=value` per line):
|
`/etc/ifcurl/env` (one `KEY=value` per line):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -172,7 +190,19 @@ IFCURL_CACHE_MAX_GB=10
|
||||||
IFCURL_T2_MAX=16
|
IFCURL_T2_MAX=16
|
||||||
```
|
```
|
||||||
|
|
||||||
### Manually (for testing)
|
Render-side environment variables (timeouts, memory limits) go in
|
||||||
|
`/etc/ifcurl/render-env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
IFCURL_SANDBOX_TIMEOUT=180
|
||||||
|
IFCURL_SANDBOX_MEMORY_MB=2048
|
||||||
|
```
|
||||||
|
|
||||||
|
### Single-service mode (testing / simple deployments)
|
||||||
|
|
||||||
|
Without `IFCURL_RENDER_SOCKET` set, the API service handles rendering directly
|
||||||
|
in its own subprocess sandbox. This is simpler to set up but offers less
|
||||||
|
isolation.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ifcurl serve --allowed-hosts git.example.com
|
ifcurl serve --allowed-hosts git.example.com
|
||||||
|
|
|
||||||
54
forgejo/server-config/ifcurl-api.service
Normal file
54
forgejo/server-config/ifcurl-api.service
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
[Unit]
|
||||||
|
Description=ifcurl API service (git fetch + caching + SSRF protection)
|
||||||
|
Documentation=https://github.com/brunopostle/ifcurl
|
||||||
|
After=network.target ifcurl-render.service
|
||||||
|
Requires=ifcurl-render.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=ifcurl
|
||||||
|
Group=ifcurl
|
||||||
|
EnvironmentFile=-/etc/ifcurl/env
|
||||||
|
# Tell the API service where to find the render service Unix socket
|
||||||
|
Environment=IFCURL_RENDER_SOCKET=/run/ifcurl/render.sock
|
||||||
|
ExecStart=/usr/local/bin/ifcurl serve \
|
||||||
|
--host 127.0.0.1 \
|
||||||
|
--port 8000 \
|
||||||
|
--allowed-hosts git.example.com
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
# Cache configuration — override in /etc/ifcurl/env:
|
||||||
|
# IFCURL_CACHE_MAX_GB=10 max bare-repo disk cache
|
||||||
|
# IFCURL_T2_MAX=8 max IFC blobs in memory
|
||||||
|
# IFCURL_T3_MAX=64 max selector results in memory
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Sandbox hardening
|
||||||
|
#
|
||||||
|
# The API service fetches from git remotes and reads tokens from
|
||||||
|
# /etc/ifcurl/env — it needs network access and execve for git CLI calls.
|
||||||
|
# IFC parsing and rendering are delegated to ifcurl-render.service over
|
||||||
|
# a Unix socket, so this service never loads ifcopenshell or pyvista.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
Environment=XDG_CACHE_HOME=/var/cache
|
||||||
|
CacheDirectory=ifcurl
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
RestrictSUIDSGID=yes
|
||||||
|
PrivateTmp=yes
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=yes
|
||||||
|
ReadWritePaths=/var/cache/ifcurl /run/ifcurl
|
||||||
|
ProtectKernelTunables=yes
|
||||||
|
ProtectKernelModules=yes
|
||||||
|
ProtectControlGroups=yes
|
||||||
|
RestrictNamespaces=yes
|
||||||
|
LockPersonality=yes
|
||||||
|
RestrictRealtime=yes
|
||||||
|
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
||||||
|
SystemCallArchitectures=native
|
||||||
|
SystemCallFilter=@system-service @process @files @io-event @network-io
|
||||||
|
SystemCallErrorNumber=EPERM
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
60
forgejo/server-config/ifcurl-render.service
Normal file
60
forgejo/server-config/ifcurl-render.service
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
[Unit]
|
||||||
|
Description=ifcurl render isolation service (IFC parsing + rendering)
|
||||||
|
Documentation=https://github.com/brunopostle/ifcurl
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=ifcurl-render
|
||||||
|
Group=ifcurl-render
|
||||||
|
RuntimeDirectory=ifcurl
|
||||||
|
RuntimeDirectoryMode=0775
|
||||||
|
RuntimeDirectoryPreserve=yes
|
||||||
|
ExecStart=/usr/local/bin/ifcurl render-service \
|
||||||
|
--socket /run/ifcurl/render.sock
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
# Render configuration — override in /etc/ifcurl/render-env:
|
||||||
|
# IFCURL_SANDBOX_TIMEOUT=120 max seconds per render subprocess
|
||||||
|
# IFCURL_SANDBOX_MEMORY_MB=0 address-space cap per subprocess (0=unlimited)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Sandbox hardening
|
||||||
|
#
|
||||||
|
# This service processes untrusted IFC data via ifcopenshell and pyvista.
|
||||||
|
# It has no credentials, no git access, and no outbound network.
|
||||||
|
#
|
||||||
|
# AF_UNIX only: the service receives requests from ifcurl-api over the
|
||||||
|
# render socket and communicates with its own sandbox child processes.
|
||||||
|
# AF_INET/AF_INET6 are excluded — a compromised subprocess cannot reach
|
||||||
|
# git hosts or any other external service.
|
||||||
|
#
|
||||||
|
# execve/execveat are blocked: pyvista and ifcopenshell use fork()/clone()
|
||||||
|
# only (verified with strace). git is never called from this service.
|
||||||
|
# The render sandbox (run_sandboxed) uses multiprocessing.Process which
|
||||||
|
# uses fork on Linux — safe with ~execve.
|
||||||
|
#
|
||||||
|
# ProtectSystem=strict + no ReadWritePaths: this service writes nothing to
|
||||||
|
# disk; all output is returned over the Unix socket to the API service.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
EnvironmentFile=-/etc/ifcurl/render-env
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
RestrictSUIDSGID=yes
|
||||||
|
PrivateTmp=yes
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=yes
|
||||||
|
ProtectKernelTunables=yes
|
||||||
|
ProtectKernelModules=yes
|
||||||
|
ProtectControlGroups=yes
|
||||||
|
RestrictNamespaces=yes
|
||||||
|
LockPersonality=yes
|
||||||
|
RestrictRealtime=yes
|
||||||
|
RestrictAddressFamilies=AF_UNIX
|
||||||
|
SystemCallArchitectures=native
|
||||||
|
SystemCallFilter=@system-service @process @files @io-event
|
||||||
|
SystemCallFilter=~execve ~execveat
|
||||||
|
SystemCallErrorNumber=EPERM
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
|
@ -124,6 +124,25 @@ def main() -> None:
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
render_service_parser = subparsers.add_parser(
|
||||||
|
"render-service",
|
||||||
|
help="Start the ifcurl render isolation service on a Unix socket",
|
||||||
|
description=(
|
||||||
|
"Start the render isolation service that handles all IFC parsing and\n"
|
||||||
|
"rendering. This service runs with restricted privileges (no network,\n"
|
||||||
|
"no execve) and communicates with the main ifcurl-api service over a\n"
|
||||||
|
"Unix-domain socket. Configure the API service with\n"
|
||||||
|
"IFCURL_RENDER_SOCKET pointing to the same socket path."
|
||||||
|
),
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
)
|
||||||
|
render_service_parser.add_argument(
|
||||||
|
"--socket",
|
||||||
|
default="/run/ifcurl/render.sock",
|
||||||
|
metavar="PATH",
|
||||||
|
help="Unix socket path to listen on (default: /run/ifcurl/render.sock)",
|
||||||
|
)
|
||||||
|
|
||||||
cache_parser = subparsers.add_parser(
|
cache_parser = subparsers.add_parser(
|
||||||
"cache",
|
"cache",
|
||||||
help="Inspect and manage the local git repository cache",
|
help="Inspect and manage the local git repository cache",
|
||||||
|
|
@ -168,6 +187,8 @@ def main() -> None:
|
||||||
_cmd_render(args)
|
_cmd_render(args)
|
||||||
elif args.command == "serve":
|
elif args.command == "serve":
|
||||||
_cmd_serve(args)
|
_cmd_serve(args)
|
||||||
|
elif args.command == "render-service":
|
||||||
|
_cmd_render_service(args)
|
||||||
elif args.command == "cache":
|
elif args.command == "cache":
|
||||||
_cmd_cache(args)
|
_cmd_cache(args)
|
||||||
|
|
||||||
|
|
@ -247,6 +268,22 @@ def _cmd_serve(args: argparse.Namespace) -> None:
|
||||||
uvicorn.run(app, host=args.host, port=args.port)
|
uvicorn.run(app, host=args.host, port=args.port)
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd_render_service(args: argparse.Namespace) -> None:
|
||||||
|
try:
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
from ifcurl.render_service import app
|
||||||
|
except ImportError:
|
||||||
|
print(
|
||||||
|
"Error: service dependencies are not installed.\n"
|
||||||
|
"Install with: pip install 'ifcurl[service]'",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
uvicorn.run(app, uds=args.socket)
|
||||||
|
|
||||||
|
|
||||||
def _cmd_cache(args: argparse.Namespace) -> None:
|
def _cmd_cache(args: argparse.Namespace) -> None:
|
||||||
import datetime
|
import datetime
|
||||||
import shutil as _shutil
|
import shutil as _shutil
|
||||||
|
|
|
||||||
274
ifcurl/render_service.py
Normal file
274
ifcurl/render_service.py
Normal file
|
|
@ -0,0 +1,274 @@
|
||||||
|
# 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/>.
|
||||||
|
|
||||||
|
"""Render isolation service — IFC parsing, selecting, and rendering.
|
||||||
|
|
||||||
|
This service receives raw IFC bytes over a Unix-domain socket and performs all
|
||||||
|
ifcopenshell and pyvista operations in a subprocess sandbox. It has no access
|
||||||
|
to git credentials, no network access, and (when deployed under systemd with
|
||||||
|
the provided unit file) has execve blocked.
|
||||||
|
|
||||||
|
Three endpoints:
|
||||||
|
POST /render → JSON {png: base64, guids: list|null}
|
||||||
|
POST /select → JSON {guids: list}
|
||||||
|
POST /render_diff → image/png
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
|
||||||
|
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
||||||
|
from fastapi.responses import Response
|
||||||
|
|
||||||
|
from ifcurl.sandbox import SandboxCrashError, SandboxTimeoutError, run_sandboxed
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Pipeline functions (serialisable callables — run inside run_sandboxed)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _sandboxed_pipeline(
|
||||||
|
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]:
|
||||||
|
"""Parse, optionally resolve selection, and render in a child process.
|
||||||
|
|
||||||
|
Returns ``(new_guids, png_bytes)`` where *new_guids* is populated only when
|
||||||
|
*selector* was given so the caller can update the T3 cache.
|
||||||
|
"""
|
||||||
|
import ifcopenshell
|
||||||
|
import ifcopenshell.util.selector
|
||||||
|
|
||||||
|
from ifcurl import render as render_mod
|
||||||
|
|
||||||
|
model = ifcopenshell.file.from_string(ifc_bytes.decode())
|
||||||
|
|
||||||
|
new_guids: frozenset[str] | None = None
|
||||||
|
element_ids: list[int] | None = None
|
||||||
|
|
||||||
|
if selector is not None:
|
||||||
|
matched = list(ifcopenshell.util.selector.filter_elements(model, selector))
|
||||||
|
new_guids = frozenset(
|
||||||
|
e.GlobalId for e in matched if hasattr(e, "GlobalId") and e.GlobalId
|
||||||
|
)
|
||||||
|
elif element_guids is not None:
|
||||||
|
ids = []
|
||||||
|
for guid in element_guids:
|
||||||
|
try:
|
||||||
|
ids.append(model.by_guid(guid).id())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
element_ids = ids if ids else None
|
||||||
|
|
||||||
|
png = render_mod.render(
|
||||||
|
model,
|
||||||
|
selector=selector,
|
||||||
|
element_ids=element_ids,
|
||||||
|
camera=camera,
|
||||||
|
fov=fov,
|
||||||
|
scale=scale,
|
||||||
|
clips=clips or None,
|
||||||
|
visibility=visibility,
|
||||||
|
)
|
||||||
|
return new_guids, png
|
||||||
|
|
||||||
|
|
||||||
|
def _sandboxed_select(ifc_bytes: bytes, selector: str) -> list[str]:
|
||||||
|
"""Parse and run selector in a child process. Returns list of GlobalIds."""
|
||||||
|
import ifcopenshell
|
||||||
|
import ifcopenshell.util.selector
|
||||||
|
|
||||||
|
model = ifcopenshell.file.from_string(ifc_bytes.decode())
|
||||||
|
matched = list(ifcopenshell.util.selector.filter_elements(model, selector))
|
||||||
|
return [e.GlobalId for e in matched if hasattr(e, "GlobalId") and e.GlobalId]
|
||||||
|
|
||||||
|
|
||||||
|
def _sandboxed_diff(
|
||||||
|
head_bytes: bytes,
|
||||||
|
base_bytes: bytes,
|
||||||
|
raw_diff_text: str,
|
||||||
|
camera: tuple | None,
|
||||||
|
fov: float | None,
|
||||||
|
scale: float | None,
|
||||||
|
clips: list,
|
||||||
|
) -> bytes:
|
||||||
|
"""Parse both models, expand diff IDs, and render the two-pass diff image."""
|
||||||
|
import ifcopenshell
|
||||||
|
|
||||||
|
from ifcurl import render as render_mod
|
||||||
|
from ifcurl.diff import expand_step_ids, step_ids_from_diff
|
||||||
|
|
||||||
|
model_head = ifcopenshell.file.from_string(head_bytes.decode())
|
||||||
|
model_base = ifcopenshell.file.from_string(base_bytes.decode())
|
||||||
|
|
||||||
|
raw_ids = step_ids_from_diff(raw_diff_text)
|
||||||
|
diff_ids = expand_step_ids(model_head, raw_ids)
|
||||||
|
|
||||||
|
return render_mod.render_diff(
|
||||||
|
model_head=model_head,
|
||||||
|
model_base=model_base,
|
||||||
|
diff_ids=diff_ids,
|
||||||
|
camera=camera,
|
||||||
|
fov=fov,
|
||||||
|
scale=scale,
|
||||||
|
clips=clips or None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# FastAPI app
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="ifcurl render service",
|
||||||
|
description="Internal render-isolation service. Receives IFC bytes, returns PNG.",
|
||||||
|
version="0.0.0",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/render")
|
||||||
|
async def render_endpoint(
|
||||||
|
ifc: UploadFile = File(...),
|
||||||
|
params: str = Form(...),
|
||||||
|
) -> Response:
|
||||||
|
"""Render an IFC model to PNG.
|
||||||
|
|
||||||
|
``params`` is a JSON object with keys:
|
||||||
|
``selector`` (str|null), ``element_guids`` (list|null),
|
||||||
|
``camera`` (list[float]|null), ``fov`` (float|null),
|
||||||
|
``scale`` (float|null), ``clips`` (list), ``visibility`` (str).
|
||||||
|
|
||||||
|
Returns JSON ``{png: base64_string, guids: list|null}``.
|
||||||
|
"""
|
||||||
|
ifc_bytes = await ifc.read()
|
||||||
|
p = json.loads(params)
|
||||||
|
|
||||||
|
selector: str | None = p.get("selector")
|
||||||
|
raw_guids = p.get("element_guids")
|
||||||
|
element_guids: frozenset[str] | None = frozenset(raw_guids) if raw_guids is not None else None
|
||||||
|
camera: tuple | None = tuple(p["camera"]) if p.get("camera") else None
|
||||||
|
fov: float | None = p.get("fov")
|
||||||
|
scale: float | None = p.get("scale")
|
||||||
|
clips: list = p.get("clips", [])
|
||||||
|
visibility: str = p.get("visibility", "highlight")
|
||||||
|
|
||||||
|
try:
|
||||||
|
new_guids, png_bytes = run_sandboxed(
|
||||||
|
_sandboxed_pipeline,
|
||||||
|
ifc_bytes,
|
||||||
|
selector,
|
||||||
|
element_guids,
|
||||||
|
camera,
|
||||||
|
fov,
|
||||||
|
scale,
|
||||||
|
clips,
|
||||||
|
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 ValueError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=json.dumps({
|
||||||
|
"png": base64.b64encode(png_bytes).decode(),
|
||||||
|
"guids": list(new_guids) if new_guids is not None else None,
|
||||||
|
}),
|
||||||
|
media_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/select")
|
||||||
|
async def select_endpoint(
|
||||||
|
ifc: UploadFile = File(...),
|
||||||
|
selector: str = Form(...),
|
||||||
|
) -> Response:
|
||||||
|
"""Run an IfcOpenShell selector against an IFC model.
|
||||||
|
|
||||||
|
Returns JSON ``{guids: list[str]}``.
|
||||||
|
"""
|
||||||
|
ifc_bytes = await ifc.read()
|
||||||
|
|
||||||
|
try:
|
||||||
|
guids = run_sandboxed(_sandboxed_select, ifc_bytes, 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
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=json.dumps({"guids": guids}),
|
||||||
|
media_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/render_diff")
|
||||||
|
async def render_diff_endpoint(
|
||||||
|
head_ifc: UploadFile = File(...),
|
||||||
|
base_ifc: UploadFile = File(...),
|
||||||
|
params: str = Form(...),
|
||||||
|
) -> Response:
|
||||||
|
"""Render a two-pass IFC diff image (green=added, blue=modified, red=removed).
|
||||||
|
|
||||||
|
``params`` is a JSON object with keys:
|
||||||
|
``raw_diff`` (str), ``camera`` (list[float]|null), ``fov`` (float|null),
|
||||||
|
``scale`` (float|null), ``clips`` (list).
|
||||||
|
|
||||||
|
Returns ``image/png``.
|
||||||
|
"""
|
||||||
|
head_bytes = await head_ifc.read()
|
||||||
|
base_bytes = await base_ifc.read()
|
||||||
|
p = json.loads(params)
|
||||||
|
|
||||||
|
raw_diff: str = p["raw_diff"]
|
||||||
|
camera: tuple | None = tuple(p["camera"]) if p.get("camera") else None
|
||||||
|
fov: float | None = p.get("fov")
|
||||||
|
scale: float | None = p.get("scale")
|
||||||
|
clips: list = p.get("clips", [])
|
||||||
|
|
||||||
|
try:
|
||||||
|
png_bytes = run_sandboxed(
|
||||||
|
_sandboxed_diff,
|
||||||
|
head_bytes,
|
||||||
|
base_bytes,
|
||||||
|
raw_diff,
|
||||||
|
camera,
|
||||||
|
fov,
|
||||||
|
scale,
|
||||||
|
clips,
|
||||||
|
)
|
||||||
|
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 ValueError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
return Response(content=png_bytes, media_type="image/png")
|
||||||
|
|
@ -35,8 +35,10 @@ Tier 4 sha256(url) → PNG bytes
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
import ipaddress
|
import ipaddress
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
|
@ -52,15 +54,19 @@ from ifcurl.auth import get_token_for_host
|
||||||
from ifcurl.bcf import build_bcf
|
from ifcurl.bcf import build_bcf
|
||||||
from ifcurl.git import diff_text as git_diff_text
|
from ifcurl.git import diff_text as git_diff_text
|
||||||
from ifcurl.git import fetch_ifc
|
from ifcurl.git import fetch_ifc
|
||||||
|
from ifcurl.render_service import _sandboxed_diff, _sandboxed_pipeline, _sandboxed_select
|
||||||
from ifcurl.sandbox import SandboxCrashError, SandboxTimeoutError, run_sandboxed
|
from ifcurl.sandbox import SandboxCrashError, SandboxTimeoutError, run_sandboxed
|
||||||
from ifcurl.url import IfcUrl
|
from ifcurl.url import IfcUrl
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Subprocess pipeline functions (run inside run_sandboxed — no service imports)
|
# 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 _sandboxed_pipeline(
|
|
||||||
|
def _render_via_socket(
|
||||||
ifc_bytes: bytes,
|
ifc_bytes: bytes,
|
||||||
selector: str | None,
|
selector: str | None,
|
||||||
element_guids: frozenset[str] | None,
|
element_guids: frozenset[str] | None,
|
||||||
|
|
@ -70,90 +76,95 @@ def _sandboxed_pipeline(
|
||||||
clips: list,
|
clips: list,
|
||||||
visibility: str,
|
visibility: str,
|
||||||
) -> tuple[frozenset[str] | None, bytes]:
|
) -> tuple[frozenset[str] | None, bytes]:
|
||||||
"""Parse, optionally resolve selection, and render in a child process.
|
"""Delegate render to the render service over the Unix socket."""
|
||||||
|
import httpx
|
||||||
|
|
||||||
Returns ``(new_guids, png_bytes)`` where *new_guids* is populated only when
|
params = {
|
||||||
*selector* was given (T3 cache miss path) so the caller can update the cache.
|
"selector": selector,
|
||||||
"""
|
"element_guids": list(element_guids) if element_guids is not None else None,
|
||||||
import ifcopenshell
|
"camera": list(camera) if camera else None,
|
||||||
import ifcopenshell.util.selector
|
"fov": fov,
|
||||||
|
"scale": scale,
|
||||||
from ifcurl import render as render_mod
|
"clips": clips,
|
||||||
|
"visibility": visibility,
|
||||||
model = ifcopenshell.file.from_string(ifc_bytes.decode())
|
}
|
||||||
|
|
||||||
# T3 cache miss: run selector and return GUIDs for caching
|
|
||||||
new_guids: frozenset[str] | None = None
|
|
||||||
element_ids: list[int] | None = None
|
|
||||||
|
|
||||||
if selector is not None:
|
|
||||||
matched = list(ifcopenshell.util.selector.filter_elements(model, selector))
|
|
||||||
new_guids = frozenset(
|
|
||||||
e.GlobalId for e in matched if hasattr(e, "GlobalId") and e.GlobalId
|
|
||||||
)
|
|
||||||
elif element_guids is not None:
|
|
||||||
# T3 cache hit: convert cached GUIDs to step IDs for this model instance
|
|
||||||
ids = []
|
|
||||||
for guid in element_guids:
|
|
||||||
try:
|
try:
|
||||||
ids.append(model.by_guid(guid).id())
|
with httpx.Client(transport=httpx.HTTPTransport(uds=_RENDER_SOCKET)) as client:
|
||||||
except Exception:
|
resp = client.post(
|
||||||
pass
|
"http://render/render",
|
||||||
element_ids = ids if ids else None
|
files={"ifc": ifc_bytes},
|
||||||
|
data={"params": json.dumps(params)},
|
||||||
png = render_mod.render(
|
timeout=_RENDER_TIMEOUT,
|
||||||
model,
|
|
||||||
selector=selector,
|
|
||||||
element_ids=element_ids,
|
|
||||||
camera=camera,
|
|
||||||
fov=fov,
|
|
||||||
scale=scale,
|
|
||||||
clips=clips or None,
|
|
||||||
visibility=visibility,
|
|
||||||
)
|
)
|
||||||
return new_guids, png
|
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 _sandboxed_select(ifc_bytes: bytes, selector: str) -> list[str]:
|
def _select_via_socket(ifc_bytes: bytes, selector: str) -> list[str]:
|
||||||
"""Parse and run selector in a child process. Returns list of GlobalIds."""
|
"""Delegate selector execution to the render service over the Unix socket."""
|
||||||
import ifcopenshell
|
import httpx
|
||||||
import ifcopenshell.util.selector
|
|
||||||
|
|
||||||
model = ifcopenshell.file.from_string(ifc_bytes.decode())
|
try:
|
||||||
matched = list(ifcopenshell.util.selector.filter_elements(model, selector))
|
with httpx.Client(transport=httpx.HTTPTransport(uds=_RENDER_SOCKET)) as client:
|
||||||
return [e.GlobalId for e in matched if hasattr(e, "GlobalId") and e.GlobalId]
|
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 _sandboxed_diff(
|
def _diff_via_socket(
|
||||||
head_bytes: bytes,
|
head_bytes: bytes,
|
||||||
base_bytes: bytes,
|
base_bytes: bytes,
|
||||||
raw_diff_text: str,
|
raw_diff: str,
|
||||||
camera: tuple | None,
|
camera: tuple | None,
|
||||||
fov: float | None,
|
fov: float | None,
|
||||||
scale: float | None,
|
scale: float | None,
|
||||||
clips: list,
|
clips: list,
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
"""Parse both models, expand diff IDs, and render the two-pass diff image."""
|
"""Delegate diff rendering to the render service over the Unix socket."""
|
||||||
import ifcopenshell
|
import httpx
|
||||||
|
|
||||||
from ifcurl import render as render_mod
|
params = {
|
||||||
from ifcurl.diff import expand_step_ids, step_ids_from_diff
|
"raw_diff": raw_diff,
|
||||||
|
"camera": list(camera) if camera else None,
|
||||||
model_head = ifcopenshell.file.from_string(head_bytes.decode())
|
"fov": fov,
|
||||||
model_base = ifcopenshell.file.from_string(base_bytes.decode())
|
"scale": scale,
|
||||||
|
"clips": clips,
|
||||||
raw_ids = step_ids_from_diff(raw_diff_text)
|
}
|
||||||
diff_ids = expand_step_ids(model_head, raw_ids)
|
try:
|
||||||
|
with httpx.Client(transport=httpx.HTTPTransport(uds=_RENDER_SOCKET)) as client:
|
||||||
return render_mod.render_diff(
|
resp = client.post(
|
||||||
model_head=model_head,
|
"http://render/render_diff",
|
||||||
model_base=model_base,
|
files={"head_ifc": head_bytes, "base_ifc": base_bytes},
|
||||||
diff_ids=diff_ids,
|
data={"params": json.dumps(params)},
|
||||||
camera=camera,
|
timeout=_RENDER_TIMEOUT,
|
||||||
fov=fov,
|
|
||||||
scale=scale,
|
|
||||||
clips=clips or None,
|
|
||||||
)
|
)
|
||||||
|
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(
|
app = FastAPI(
|
||||||
|
|
@ -443,6 +454,18 @@ def preview(request: PreviewRequest) -> Response:
|
||||||
|
|
||||||
# --- Sandboxed parse + select + render ---
|
# --- Sandboxed parse + select + render ---
|
||||||
try:
|
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(
|
new_guids, png_bytes = run_sandboxed(
|
||||||
_sandboxed_pipeline,
|
_sandboxed_pipeline,
|
||||||
ifc_bytes,
|
ifc_bytes,
|
||||||
|
|
@ -525,6 +548,9 @@ def bcf_export(request: BcfRequest) -> Response:
|
||||||
_t2_put(hexsha, ifc_url.path, ifc_bytes)
|
_t2_put(hexsha, ifc_url.path, ifc_bytes)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
if _RENDER_SOCKET:
|
||||||
|
guids = _select_via_socket(ifc_bytes, ifc_url.selector)
|
||||||
|
else:
|
||||||
guids = run_sandboxed(_sandboxed_select, ifc_bytes, ifc_url.selector)
|
guids = run_sandboxed(_sandboxed_select, ifc_bytes, ifc_url.selector)
|
||||||
except SandboxCrashError as exc:
|
except SandboxCrashError as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -647,6 +673,17 @@ def render_diff(request: DiffRequest) -> Response:
|
||||||
|
|
||||||
# --- Sandboxed parse + expand + render ---
|
# --- Sandboxed parse + expand + render ---
|
||||||
try:
|
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(
|
png_bytes = run_sandboxed(
|
||||||
_sandboxed_diff,
|
_sandboxed_diff,
|
||||||
head_bytes,
|
head_bytes,
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ dependencies = ["ifcopenshell", "gitpython", "platformdirs"]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
render = ["pyvista", "numpy", "Pillow"]
|
render = ["pyvista", "numpy", "Pillow"]
|
||||||
service = ["pyvista", "numpy", "Pillow", "fastapi", "uvicorn[standard]", "bcf"]
|
service = ["pyvista", "numpy", "Pillow", "fastapi", "uvicorn[standard]", "bcf", "httpx"]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
ifcurl = "ifcurl.__main__:main"
|
ifcurl = "ifcurl.__main__:main"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue