- auth.py: catch OSError (not just FileNotFoundError) so ProtectHome=yes
doesn't crash token lookup — returns None and falls back to anonymous
- git.py: wipe partial bare-clone dir before HTTP fallback so git can retry
after HTTPS fails (port 443 not available on local HTTP-only Forgejo)
- pyproject.toml: add python-multipart to service deps (FastAPI form data)
- ifcurl-api.service: fix ExecStart path (/opt/ifcurl), set allowed-hosts
to localhost, add MPLCONFIGDIR to silence matplotlib cache warning
- ifcurl-render.service: fix ExecStart path, add CacheDirectory + MPLCONFIGDIR
- nginx-ifcurl.conf: new file tracking the nginx reverse-proxy config;
listen on [::]:80 so IPv6 loopback requests reach the Forgejo proxy block
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
On /owner/repo/commit/{sha} pages, Case 4 injects an image below the
diff-file-header of each IFC file: render_diff for modifications,
plain preview for adds and deletes. Parent SHA is read from the
existing "parent" commit link in the DOM — no extra API calls.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
9 tests exercise _cmd_cache() directly with fake cache dirs and a
monkeypatched user_cache_dir, covering output formatting, empty states,
prune with --max-gb arg, prune via IFCURL_CACHE_MAX_GB env, and the
error exit when neither is set.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The ref input now has a datalist populated from the Forgejo branches+tags
API on load. A ▾ button clears and focuses the field to expose all refs.
Selecting a datalist option auto-navigates; pressing Enter in any structured
field now preserves camera/clip/fov/scale from the current URL so switching
refs keeps the same viewpoint.
Closes ifcurl-4rj.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
New GET+POST /select endpoint resolves an ifc:// URL selector to a JSON
list of GlobalIds, with T3 caching. The viewer now detects non-type-name
selectors (property filters, Name=, pset notation) and falls back to a
/select round-trip, applying the returned GUIDs via getLocalIdsByGuids().
Closes ifcurl-ov6.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
A new '☰' toolbar button opens a panel showing the IFC type breakdown
(sorted by element count) and storey list. Clicking a storey isolates
its elements via OBC.Hider; clicking again restores full visibility.
Closes ifcurl-dag.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When an ifc:// URL uses a bare commit hash as the ref the toolbar now
shows an amber badge with the abbreviated hash and (after a Forgejo API
lookup) a '→ heads/main' button that reloads with the branch-tip ref.
Closes ifcurl-gy8.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
three 0.177→0.184, web-ifc 0.0.75→0.0.77, @thatopen/components
3.3.1→3.4.2, @thatopen/fragments 3.3.1→3.4.3, + new camera-controls
3.1.2 peer dep. Reduces fast-xml-parser findings from 2 critical to
1 high + 1 low; remaining issues are upstream in @thatopen >= 3.3.3
with no fix released yet.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace importmap pointing at esm.sh/unpkg with locally-bundled files
built from pinned npm packages (package.json + lockfile). Run
`cd forgejo && npm install && npm run build` to regenerate after a
version bump. Tracked in ifcurl-dkz: fast-xml-parser CVEs in
@thatopen/components 3.3.1 should be addressed in a follow-up upgrade.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
IFCURL_RATE_LIMIT (default 60 req/min, 0=off) — sliding-window middleware
returns 429 before any expensive work is done. IFCURL_MAX_IFC_MB (default
256, 0=off) — rejects oversized fetches with 413 in preview, bcf, and
render_diff endpoints.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Client-side BCF generation cannot resolve IFC GlobalIds from a selector
string. When a selector is present, POST to /bcf instead — the service
runs the selector through IfcOpenShell and writes resolved GUIDs into the
viewpoint. Fails visibly if unreachable; no silent fallback to a GUIDless
file. Also adds /bcf to the Nginx proxy block in forgejo/README.md.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Namespace clean, no prior art, compound schemes would break transport-inference
design. Created ifcurl-nd1 to file provisional IANA registration post-Phase 1.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>
When a branch/HEAD fetch fails (network error, auth failure, server down),
_open_remote now logs a warning rather than silently passing. A boolean
is_stale propagates through _get_repo → fetch_ifc so callers know the data
comes from a stale local cache.
The service adds X-Ifcurl-Cache: stale to /preview and /render_diff responses
when stale data was served, so Forgejo or a browser viewer can surface a
warning to the user.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
execve is actually permitted — it arrives via @default which is included in
@system-service. The main service needs it for GitPython's git CLI calls.
Blocking execve only in render subprocesses would require per-process seccomp,
which is not worth the complexity for this threat model.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When no explicit camera is given, compute the combined bounding box of all
changed elements (added/modified from head model, removed from base model)
using quick subset iterators before pass 1 renders. Pass
plotter.reset_camera(bounds=...) so the view zooms to the area of change
rather than the whole scene. Falls back to reset_camera() (whole scene) only
if no changed elements have renderable geometry.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ifcmerge defaults to preserving remote IDs; --prioritise-local preserves local
IDs instead. For Forgejo merge commits, %A is main (local) so ifcmerge_ours
(--prioritise-local) is the right driver — main's IDs are preserved and the
PR branch is renumbered. For developer rebases from main, %B is main (remote)
so the plain ifcmerge driver (no flag) preserves main's IDs by default.
Update /etc/gitattributes to use ifcmerge_ours for server-side merges, and
update README with a direction table clarifying which driver to use when.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Makes explicit which features need the Go patch (markdown preview only) vs
which are static assets that survive Forgejo upgrades without recompilation.
Documents that the Nginx proxy is required for PR diff images because the
browser fetches them and can't reach localhost directly, and notes that most
production deployments already have Nginx for TLS anyway.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Case 3 in footer.tmpl detects /{owner}/{repo}/pulls/{N}/files, fetches base/
head SHAs from the Forgejo API, then injects <img src="/render_diff?…"> below
the file header of each .ifc file in the diff. Returns early so Case 2 (history
page) does not also run on the PR diff page.
README documents the required Nginx proxy for /preview and /render_diff, and
clarifies that PREVIEW_SERVICE_URL is server-side-only (localhost:8000 is
correct for it); the browser-side diff images need the proxy.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implements the ifcgit/Bonsai diff algorithm for the preview service:
diff.py: step_ids_from_diff() parses git diff text (regex on +#NNN= / -#NNN=
lines) to classify step IDs as added/modified/removed. expand_step_ids()
walks IfcShapeRepresentation, IfcObjectPlacement, IfcPropertySet, and
IfcTypeProduct relationships to promote changed sub-entities to the parent
IfcProduct that is visually affected.
render.py: render_diff() does two passes over the same camera. Pass 1 renders
the head model with green=added, blue=modified, ghost=unchanged. Pass 2
renders only the removed elements from the base model in red, using the camera
auto-fitted to pass 1. PIL composites the passes: wherever pass 2 is not
white background it stamps onto pass 1. Moved elements (modified) appear once
in blue with no doubling artefact.
git.py: diff_text() runs git diff against the cached bare repo clone and
returns the raw text for diff.py to parse.
service.py: GET+POST /render_diff endpoint. Fetches both commits, gets
diff text, runs the full pipeline sandboxed. Caches on disk when both refs
are immutable. Pillow added to render/service extras.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Use CacheDirectory=ifcurl (systemd creates /var/cache/ifcurl owned by the
service user) and XDG_CACHE_HOME=/var/cache so platformdirs resolves to
that path. Remove the /root/.cache/ifcurl ReadWritePaths entry which was
inaccessible to the unprivileged ifcurl user.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add sandboxing directives to limit blast radius from a compromised render
subprocess:
- NoNewPrivileges + RestrictSUIDSGID: no privilege escalation
- ProtectSystem=strict + ProtectHome: Forgejo data tree is unwritable
- PrivateTmp: isolated /tmp
- RestrictAddressFamilies=AF_INET AF_INET6: no Unix socket access to Forgejo
- SystemCallFilter=@system-service @process @files @io-event @network-io:
allows fork/clone (needed by ifcopenshell geom workers) but excludes
execve/execveat so an attacker cannot exec a shell
- RestrictNamespaces, LockPersonality, ProtectKernelTunables, etc.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
All ifcopenshell calls (from_string, filter_elements, geom.iterator) now
run inside a short-lived child process via run_sandboxed(). A SIGSEGV or
SIGABRT in the child returns HTTP 422 instead of killing the service worker.
Configurable timeout (IFCURL_SANDBOX_TIMEOUT, default 120s) and optional
resource limits (IFCURL_SANDBOX_MEMORY_MB, IFCURL_SANDBOX_CPU_SECS).
The T3 GUID cache still works: the sandboxed pipeline returns resolved
GUIDs on selector miss so the parent can populate the cache; on hit the
parent passes cached GUIDs and the child converts them to step IDs.
Tests use a sync_sandbox fixture that runs run_sandboxed synchronously so
mock patches remain visible; four dedicated sandbox tests cover normal
return, exception propagation, segfault detection, and timeout.
Closes ifcurl-vm5.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ifcopenshell can parse IFC bytes in-memory via from_string(); no need to
write a temp file, read it back, and unlink it. Simpler and faster.
Update README library example to match.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- render.py: replace bare cpu_count() with _WORKER_COUNT = min(cpu_count, 4),
configurable via IFCURL_RENDER_WORKERS env var; prevents N concurrent
requests from spawning N×cpu_count geometry worker processes
- auth.py: document that inject_token() embeds the token in git CLI arguments,
making it visible in the process list; note SSH as the workaround
- README: replace placeholder comment with actual tempfile.mkstemp pattern for
loading IFC bytes into ifcopenshell (which requires a file path, not bytes)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- footer.tmpl: refactor to inject per-commit "View in 3D" links on the file
history page, alongside the existing file view page button
- ifc_url.go: add ')' to urlTerminators so bare ifc:// URLs followed by a
closing parenthesis are correctly terminated
- ifc_url_test.go: add tests for @ in query value, ) terminator, trailing
period, and SERVICE_TOKEN presence/absence in preview URL
- viewer.html: selector input placeholder and title attribute now document
that the browser viewer only supports type-name union selectors
- README: document viewer selector limitation vs full server-side behaviour
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- GET /preview now accepts a token= query parameter, matching POST /preview
- Forgejo Go: add SERVICE_TOKEN config field; renderer appends it to <img src>
preview URLs so a machine-user token can authenticate private repos
- Go tests: verify token appears in preview URL when set, absent when unset
- viewer.html: BCF import via drag-and-drop or Import .bcf button in BCF row;
parses first viewpoint camera/clips/GUIDs and reloads with the viewpoint
applied to the current model context
- viewer-url.js, viewer.html: unescape %24 → $ in URL building so GlobalIds
with $ characters are human-readable in ifc:// URLs
- README: document SERVICE_TOKEN and private-repo preview configuration
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add ⎘ button to copy the current ifc:// URL to clipboard
- Add Issue button to open a pre-filled new issue on the git host
- Add click-to-identify via OBC.Highlighter: clicking an element shows
its IFC type, name, and GlobalId in a panel with a copy button
- Replace arrayBuffer() with ReadableStream loop showing fetch progress
as percentage (when Content-Length known) or MB transferred
- README: document viewer features, collaboration workflow, and that
private Forgejo repos work via same-origin session cookie
- CLAUDE.md: document that [attr=val] bracket selector syntax is invalid;
correct form is comma-separated (IfcWall, Name="...") and bare GlobalId
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- pyproject.toml: add bcf to [service] dependencies (ifcurl-9rk)
- README.md: replace bogus GET /viewer row with POST /bcf (ifcurl-ttb)
- viewer-url.js: fix GitLab host detection (includes → startsWith) (ifcurl-3n4)
- git.py: call _evict_if_needed() after fetch, not only after clone (ifcurl-t1k)
- bcf.py: add description param to build_bcf(); markup.bcf now includes
<Description> when provided (ifcurl-7wg)
- service.py: pass request URL as description to build_bcf() (ifcurl-7wg)
- viewer.html: BCF export reads visibility= from URL and sets
DefaultVisibility accordingly (ifcurl-sto)
- viewer.html: applyCameraParam now switches to orthographic projection
and applies scale when scale= is present in URL (ifcurl-4l7)
- tests: 3 new tests for description field; update JS tests pass
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Covers bugs, documentation holes, and missing coverage found across
all layers: Python library, service, viewer, Go extension, deployment.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- ifcurl/bcf.py: build_bcf() builds BCF 2.1 zips with perspective/
orthographic camera, clipping planes, component selection, and
isolate visibility mode
- ifcurl/service.py: /bcf POST endpoint parses ifc:// URL, resolves
selector → GUIDs when present, delegates to build_bcf()
- viewer.html: BCF toolbar button + collapsible title/comment form;
client-side zip generation via JSZip (esm.sh CDN) so the browser
does not need service access
- tests/test_bcf.py: 21 tests covering build_bcf() unit behaviour and
the /bcf endpoint including SSRF rejection and selector resolution
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Investigation confirmed: Forgejo's git merge-tree --write-tree (git ≥2.38)
does invoke configured merge drivers for .ifc files.
Key finding: bare repositories do NOT read committed .gitattributes — the
merge driver is silently skipped without a system-wide core.attributesFile.
Changes:
- server-config/gitattributes: new file to deploy at /etc/gitattributes,
containing "*.ifc merge=ifcmerge"
- server-config/gitconfig-ifcmerge: adds [core] attributesFile = /etc/gitattributes
and explains the bare-repo constraint
- forgejo/README.md: rewrites ifcmerge section with investigation findings,
deployment steps, and distinction between server-side vs client-side setup
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- forgejo/server-config/ifcurl-preview.service: systemd unit for the preview
service with dedicated user, EnvironmentFile for cache config, and
--allowed-hosts in ExecStart
- forgejo/server-config/gitconfig-ifcmerge: registers two [merge] driver
variants (ifcmerge and ifcmerge_ours) for the asymmetric .ifc merge case
- forgejo/README.md: systemd deployment section (manual + service), ifcmerge
section covering Perl script install, driver registration, per-repo
gitattributes, and merge-direction constraint (merge-commit preserves main's IDs)
- README.md: remove stale "IFC file proxy" description; update quick-summary
to show systemd install path
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Disk cache (bare repo clones under ~/.cache/ifcurl/):
- IFCURL_CACHE_MAX_GB env var sets the max total size; when exceeded after a
new clone the oldest-accessed repos are removed until under the limit
- remote_url stored per clone entry so ifcurl cache list can show the URL
- _touch_cache() updates mtime on every repo open for LRU tracking
In-memory caches (service.py):
- _T2_MAX and _T3_MAX now read from IFCURL_T2_MAX / IFCURL_T3_MAX env vars
(defaults: 8 IFC blobs, 64 selector results) — configurable without rebuild
New CLI: ifcurl cache list | prune [--max-gb N] | clear
Tests: 6 new unit tests for _dir_size, _touch_cache, _repo_cache_entries,
_evict_if_needed. 118 → 124 passing.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- SSRF: test local transport rejection, allowlist enforcement, private/loopback/
link-local IP blocking, allowlist with port, public host permitted without list
- GET /preview: happy path, invalid scheme, local transport rejection
- Tier 2 LRU: eviction at capacity, LRU promotion, cache miss
- git: heads/<branch> ref form (was only tested via HEAD/tag/hash)
- clear_caches fixture now also resets _allowed_hosts between tests
103 → 118 passing tests.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
viewer.html, viewer-url.js, and footer.tmpl are intended for Forgejo
contribution and carry MIT headers matching Forgejo's per-file convention.
ThatOpen @thatopen/components (loaded from CDN) is also MIT-licensed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The preview endpoint is co-hosted with a specific Forgejo instance, so it
should only be allowed to fetch from that host.
- Local file transport (ifc:///path) is always rejected with 403 — the service
must not read from the server's own filesystem
- New --allowed-hosts CLI option for `ifcurl serve` takes a comma-separated
list of permitted git hostnames (e.g. git.example.com or localhost:3000);
any unlisted host is rejected with 403
- Defense-in-depth: literal private/loopback/link-local IP addresses are
blocked even without an allowlist (169.254.x.x, 127.x, RFC1918, etc.)
- README and forgejo/README.md updated to include --allowed-hosts in the
standard deployment invocation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The /proxy endpoint was removed in a previous session (ifcurl-cuh).
Remove it from the endpoints table in README.md.
New beads issues:
- ifcurl-2v9 P2: SSRF audit for /preview endpoint
- ifcurl-69l P3: licence headers for viewer.html, viewer-url.js, footer.tmpl
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Rewrite to lead with the vision and architecture, then cover each of the
four components (Python library+CLI, preview service, Forgejo integration,
browser viewer) with enough detail to understand what each does and how
to use it. Add a roadmap table showing phases 1-6. Keep the ifc:// URL
reference but trim it to the essentials — SPECIFICATION.md has the full
detail.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add the missing Go source files and a README covering the full apply
procedure. Previously only custom assets and the test file were tracked
here; ifc_url.go and the two-file diff were only in the Forgejo tree.
- forgejo/modules/markup/markdown/ifc_url.go — goldmark extension source
- forgejo/go.patch — diff for markdown.go + markup.go (7 lines total)
- forgejo/README.md — step-by-step: apply patch, run tests, build,
deploy assets, configure app.ini, upgrade procedure
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
7 tests in forgejo/modules/markup/markdown/ifc_url_test.go covering:
link form, bare URL, bare URL with underscores (sibling-node reassembly),
disabled when no service URL configured, HTML escaping, non-ifc links
unchanged, and inline-among-text placement.
Tests use goldmark.New() directly — no Forgejo DB/git test infrastructure
needed. Only dependencies are setting.IfcURL (ours) and goldmark (stable).
File lives in the same package as ifc_url.go and is part of the patch.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
parseIfcUrl, buildIfcUrl, toRawUrl extracted from viewer.html into
viewer-url.js so they can be imported and tested independently.
viewer.html imports from the module. 32 Node test-runner tests in
tests/test_viewer_url.mjs cover all three functions across GitHub,
GitLab, Forgejo/Gitea, SSH user@ prefix, and round-trip cases.
Caught a bug: + (selector union operator) was being URL-encoded as
%2B in buildIfcUrl; fixed by adding .replace(/%2B/g, "+") alongside
the existing comma fix, in both viewer-url.js and syncCameraUrl.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces the raw renderer.clippingPlanes approach with OBC.Clipper:
URL-specified clip= planes are seeded as draggable SimplePlanes.
Toolbar '✂ clip' toggle + double-click on model surface to add planes;
'⊗ clips' button (shown when planes exist) to clear all. syncCameraUrl
now serialises current plane state back to the ifc:// URL on every
camera move or plane drag/create/delete.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
applyClipPlanes() parses all repeatable clip= params (6 floats: point +
normal in IFC world coords), transforms each via toThree() into Three.js
space, and sets world.renderer.three.clippingPlanes. syncCameraUrl already
preserves clip= params when rebuilding the URL on camera movement.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
applySelector() classifies elements via OBC.Classifier.byCategory(), then
maps the selector type names to matching categories using prefix matching
(so IfcWall also covers IfcWallStandardCase), and calls hider.isolate() to
show only matching elements. Supports simple type names and + union syntax.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>