Private repo previews, BCF import, SERVICE_TOKEN support

- 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>
This commit is contained in:
Bruno Postle 2026-04-24 14:20:17 +01:00
parent 63c0cc7ada
commit 39a2786872
9 changed files with 194 additions and 20 deletions

View file

@ -2,14 +2,14 @@
{"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-9cr","title":"ifc:// URLs in Forgejo issues and PR comments not documented as a collaboration tool","description":"Forgejo's markdown extension renders ifc:// URLs as clickable 3D preview images in file README views, but it works equally in issue bodies, PR descriptions, and comment threads — which is where the actual collaboration happens. This is potentially the most useful feature: a reviewer can paste an ifc:// URL in a PR comment and everyone in the thread sees the 3D view inline. This is not mentioned anywhere in README.md, forgejo/README.md, or SPECIFICATION.md. Add a 'Collaboration workflow' section to the README showing: (1) navigate to view in viewer, (2) copy URL from address bar, (3) paste as [label](ifc://...) in a Forgejo comment/issue, (4) collaborators click the preview image to open the view.","status":"closed","priority":2,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T11:03:44Z","created_by":"Bruno Postle","updated_at":"2026-04-24T11:47:24Z","started_at":"2026-04-24T11:14:59Z","closed_at":"2026-04-24T11:47:24Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-n00","title":"Forgejo markdown previews fail silently for private repos — no auth path from preview service","description":"The Forgejo markdown extension generates \u003cimg src='http://localhost:8000/preview?url=ifc://...'\u003e tags. The preview service fetches the IFC file server-side using GET /preview with no authentication. For private repos, this returns HTTP 404 or 403 and the preview image shows as broken. There is no mechanism for the Forgejo markdown renderer to pass the user's session token to the preview service. Options to investigate: (1) the Forgejo integration could pass a signed request token; (2) the preview service could run as the Forgejo git user (which has read access to all repos); (3) SSH-based fetch from the service using the Forgejo server's own key. The current state is a silent failure that will confuse users.","status":"open","priority":2,"issue_type":"bug","owner":"bruno@postle.net","created_at":"2026-04-24T11:03:39Z","created_by":"Bruno Postle","updated_at":"2026-04-24T11:03:39Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-n00","title":"Forgejo markdown previews fail silently for private repos — no auth path from preview service","description":"The Forgejo markdown extension generates \u003cimg src='http://localhost:8000/preview?url=ifc://...'\u003e tags. The preview service fetches the IFC file server-side using GET /preview with no authentication. For private repos, this returns HTTP 404 or 403 and the preview image shows as broken. There is no mechanism for the Forgejo markdown renderer to pass the user's session token to the preview service. Options to investigate: (1) the Forgejo integration could pass a signed request token; (2) the preview service could run as the Forgejo git user (which has read access to all repos); (3) SSH-based fetch from the service using the Forgejo server's own key. The current state is a silent failure that will confuse users.","status":"closed","priority":2,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T11:03:39Z","created_by":"Bruno Postle","updated_at":"2026-04-24T12:39:46Z","started_at":"2026-04-24T11:56:30Z","closed_at":"2026-04-24T12:39:46Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-2y6","title":"Private Forgejo repos work in viewer via session cookie (same-origin) but this is undocumented and untested","description":"When the viewer is served at https://forgejo.example.com/assets/viewer.html, toRawUrl() constructs a same-origin URL like https://forgejo.example.com/org/repo/raw/branch/main/model.ifc. The browser automatically attaches the Forgejo session cookie to this fetch, so private repos on the same Forgejo instance should be accessible to logged-in users without any extra configuration. This is a significant capability that is not documented or tested. Needs: (1) a test or note confirming this works, (2) documentation in forgejo/README.md explaining that private repos work automatically for logged-in users when the viewer is served from the same Forgejo instance, (3) clear note that external repos (other Forgejo, GitHub, GitLab) have no auth path from the browser viewer.","status":"closed","priority":2,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T11:03:26Z","created_by":"Bruno Postle","updated_at":"2026-04-24T11:47:25Z","started_at":"2026-04-24T11:15:00Z","closed_at":"2026-04-24T11:47:25Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-u8a","title":"PR review: no before/after 3D render when an IFC file is modified","description":"When a Forgejo PR modifies a .ifc file, the Files Changed tab shows raw STEP text diff — completely unreadable. Reviewers cannot see what geometry changed. The system should automatically add before/after rendered PNG images to the PR description or as a PR check comment when an .ifc file is modified. The service can render both the base-branch version and the PR-branch version using the commit hexshas already available in the PR context. This is the single most important workflow gap for IFC code review — currently 100% of the visual review burden falls on the reviewer manually opening the viewer and navigating. This supersedes and expands the existing ifcurl-prc issue.","status":"open","priority":2,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-24T11:03:21Z","created_by":"Bruno Postle","updated_at":"2026-04-24T11:03:21Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-sgv","title":"BCF import in viewer: reconstruct ifc:// URL from a BCF viewpoint file","description":"BCF is a standard exchange format for BIM issue tracking. Reviewers using other tools (Solibri, Navisworks, BIMcollab) will generate BCF files. There is currently no way to open a BCF file in the viewer to see the referenced view. Implement BCF import: accept a .bcf zip via drag-and-drop or a file picker, parse the first viewpoint's camera/clips, construct an ifc:// URL from the Components/Selection GUIDs (as a GUID-based selector), and load the view. This also completes SPECIFICATION.md §6 direction 2. The ifc:// URL base (repo, ref, path) cannot be recovered from BCF alone — the viewer should keep the current model context and only apply the viewpoint.","status":"open","priority":2,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-24T11:03:07Z","created_by":"Bruno Postle","updated_at":"2026-04-24T11:03:07Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-sgv","title":"BCF import in viewer: reconstruct ifc:// URL from a BCF viewpoint file","description":"BCF is a standard exchange format for BIM issue tracking. Reviewers using other tools (Solibri, Navisworks, BIMcollab) will generate BCF files. There is currently no way to open a BCF file in the viewer to see the referenced view. Implement BCF import: accept a .bcf zip via drag-and-drop or a file picker, parse the first viewpoint's camera/clips, construct an ifc:// URL from the Components/Selection GUIDs (as a GUID-based selector), and load the view. This also completes SPECIFICATION.md §6 direction 2. The ifc:// URL base (repo, ref, path) cannot be recovered from BCF alone — the viewer should keep the current model context and only apply the viewpoint.","status":"closed","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T11:03:07Z","created_by":"Bruno Postle","updated_at":"2026-04-24T12:39:47Z","started_at":"2026-04-24T11:56:31Z","closed_at":"2026-04-24T12:39:47Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-qcw","title":"Viewer: no 'Create Forgejo issue' button — BCF export and Forgejo issues are disconnected","description":"The BCF export downloads a file that the reviewer must then manually attach to a Forgejo issue. There is no direct path from 'I found a problem in the viewer' to 'this is now a Forgejo issue'. The feedback loop — reviewer flags, designer fixes, reviewer verifies — requires the issue to live in Forgejo where the code/model review happens. A 'Report issue' button in the viewer should call the Forgejo API (POST /api/v1/repos/{owner}/{repo}/issues) with the current ifc:// URL pre-filled in the issue description, so the issue body automatically renders a clickable 3D preview. The viewer already knows the repo path (from the URL) and when served by Forgejo the user is already authenticated.","status":"closed","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T11:03:03Z","created_by":"Bruno Postle","updated_at":"2026-04-24T11:47:24Z","started_at":"2026-04-24T11:14:58Z","closed_at":"2026-04-24T11:47:24Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-unu","title":"Viewer: no 'Copy URL' / share button — relies on users knowing to use the address bar","description":"The viewer continuously updates the browser address bar via history.replaceState so the current view is always shareable. But there is no visible 'Copy link to this view' button. Non-technical users (clients, project managers) will not know to copy from the address bar, especially when the URL is long and truncated. A clipboard button alongside the BCF button — or a dedicated Share toolbar item — would make the core sharing workflow discoverable. Should copy the ifc:// URL (from urlInput, which reflects current camera state) to the clipboard and briefly confirm with a visual indicator.","status":"closed","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T11:02:49Z","created_by":"Bruno Postle","updated_at":"2026-04-24T11:47:23Z","started_at":"2026-04-24T11:14:38Z","closed_at":"2026-04-24T11:47:23Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-9rk","title":"pyproject.toml: bcf package not listed as optional service dependency","description":"service.py and bcf.py work correctly because the 'bcf' Python library is installed, but it is not listed in pyproject.toml's [service] or [render] optional dependencies. A fresh install with 'pip install ifcurl[service]' may not install it. Add bcf to the [service] extras to make the dependency explicit.","status":"closed","priority":2,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T10:41:19Z","created_by":"Bruno Postle","updated_at":"2026-04-24T10:56:27Z","started_at":"2026-04-24T10:50:11Z","closed_at":"2026-04-24T10:56:27Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-czd","title":"GET /preview has no token parameter — private repos inaccessible","description":"POST /preview accepts a token field in the JSON body for private repository authentication. GET /preview (used by Forgejo \u003cimg src=\u003e tags) only takes a url query parameter with no way to pass a token. The Go markdown extension generates img tags using the GET form. This means inline previews in Forgejo markdown can never authenticate to private repos — they will silently fail or return 404. Either the Go extension should use POST, or a separate signed/session-scoped token mechanism is needed for the GET path.","status":"open","priority":2,"issue_type":"bug","owner":"bruno@postle.net","created_at":"2026-04-24T10:40:17Z","created_by":"Bruno Postle","updated_at":"2026-04-24T10:40:17Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-czd","title":"GET /preview has no token parameter — private repos inaccessible","description":"POST /preview accepts a token field in the JSON body for private repository authentication. GET /preview (used by Forgejo \u003cimg src=\u003e tags) only takes a url query parameter with no way to pass a token. The Go markdown extension generates img tags using the GET form. This means inline previews in Forgejo markdown can never authenticate to private repos — they will silently fail or return 404. Either the Go extension should use POST, or a separate signed/session-scoped token mechanism is needed for the GET path.","status":"closed","priority":2,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T10:40:17Z","created_by":"Bruno Postle","updated_at":"2026-04-24T12:39:46Z","started_at":"2026-04-24T11:56:09Z","closed_at":"2026-04-24T12:39:46Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-3n4","title":"viewer-url.js: toRawUrl GitLab host detection too broad","description":"toRawUrl() uses host.includes('gitlab') to detect GitLab instances, which matches any hostname containing the string 'gitlab' — e.g. 'not-actually-gitlab.example.com' would be routed to the GitLab raw URL pattern. Should use a more specific check: host === 'gitlab.com' or host.startsWith('gitlab.') to avoid false positives on unrelated hostnames.","status":"closed","priority":2,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T10:40:01Z","created_by":"Bruno Postle","updated_at":"2026-04-24T10:56:28Z","started_at":"2026-04-24T10:50:14Z","closed_at":"2026-04-24T10:56:28Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-7wg","title":"service.py: /bcf endpoint doesn't include source URL in BCF description","description":"The viewer's client-side BCF generation records the source ifc:// URL as a \u003cDescription\u003e element in the markup, making the BCF traceable back to its origin. The service POST /bcf endpoint calls build_bcf() without passing any description, so service-generated BCF files contain no reference to the original URL. The URL should be included (in Description or a comment) so the BCF is self-documenting. build_bcf() needs a description parameter, or the url should be passed as comment/description automatically.","status":"closed","priority":2,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T09:05:17Z","created_by":"Bruno Postle","updated_at":"2026-04-24T10:56:29Z","started_at":"2026-04-24T10:50:17Z","closed_at":"2026-04-24T10:56:29Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-sto","title":"viewer.html: BCF export ignores visibility mode from URL","description":"The viewer's client-side generateBcf() always writes '\u003cVisibility DefaultVisibility=\"true\"/\u003e' and never adds Selection or Exceptions elements based on the visibility= URL parameter. A URL with visibility=isolate or visibility=ghost generates BCF with no component visibility data, so a BCF tool importing the file will show all elements with no selection. The service /bcf endpoint handles this correctly. The viewer-side BCF should at minimum write the selector string as a Description so the visibility intent is preserved.","status":"closed","priority":2,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T09:05:14Z","created_by":"Bruno Postle","updated_at":"2026-04-24T10:56:29Z","started_at":"2026-04-24T10:50:18Z","closed_at":"2026-04-24T10:56:29Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}

View file

@ -194,8 +194,21 @@ Add to `/etc/forgejo/conf/app.ini`:
```ini
[ifcurl]
PREVIEW_SERVICE_URL = http://localhost:8000
; Optional: Forgejo API token for a read-only machine user.
; The preview service will use this token to fetch IFC files from private
; repositories on this Forgejo instance. The token is appended as a query
; parameter in the <img src> URL generated by the markdown extension, so it
; is visible in page source. Only set this on trusted private instances.
; SERVICE_TOKEN = <forgejo-api-token>
```
**Private repositories:** for the preview service to access private repos on
this Forgejo instance, create a machine user with read access and set
`SERVICE_TOKEN` to its API token. The service will use this token when
fetching IFC files server-side. Alternatively, configure the preview service's
own credentials in `~/.config/ifcurl/tokens.json` under the service user
account — the service already reads host tokens from that file on startup.
---
## Browser viewer

View file

@ -39,7 +39,7 @@ export function buildIfcUrl(repo, ref, path, selector) {
const qs = new URLSearchParams();
if (path) qs.set("path", path);
if (selector) qs.set("selector", selector);
const qsStr = qs.toString().replace(/%2C/g, ",").replace(/%2B/g, "+");
const qsStr = qs.toString().replace(/%2C/g, ",").replace(/%2B/g, "+").replace(/%24/g, "$");
return `ifc://${repo}@${ref}${qsStr ? "?" + qsStr : ""}`;
}

View file

@ -64,7 +64,7 @@
}
#status:empty { display: none; }
body.drag-over::after {
content: "Drop ifc:// URL to load";
content: "Drop ifc:// URL or .bcf file";
position: fixed; inset: 0; z-index: 100;
display: flex; align-items: center; justify-content: center;
font-size: 24px; color: #f0f0f0;
@ -150,8 +150,10 @@
placeholder="IfcWall" spellcheck="false">
</div>
</div>
<!-- Row 3: BCF export form (hidden until BCF button clicked) -->
<!-- Row 3: BCF export/import form (hidden until BCF button clicked) -->
<div id="bcf-row" class="trow" style="display:none">
<button id="bcf-import-btn" class="tb-btn" title="Import a .bcf viewpoint to restore camera and selection">Import .bcf</button>
<input type="file" id="bcf-import-input" accept=".bcf" style="display:none">
<div class="field" style="flex:2">
<label>title</label>
<input id="bcf-title" class="ti" type="text" placeholder="IFC View" spellcheck="false">
@ -268,6 +270,8 @@
const bcfCommentIn = document.getElementById("bcf-comment");
const bcfCancelBtn = document.getElementById("bcf-cancel-btn");
const bcfExportBtn = document.getElementById("bcf-export-btn");
const bcfImportBtn = document.getElementById("bcf-import-btn");
const bcfImportInput = document.getElementById("bcf-import-input");
function bcfPanelOpen(open) {
bcfRow.style.display = open ? "" : "none";
@ -281,6 +285,13 @@
bcfPanelOpen(false);
});
bcfImportBtn.addEventListener("click", () => bcfImportInput.click());
bcfImportInput.addEventListener("change", async () => {
const file = bcfImportInput.files[0];
if (file) await importBcf(file);
bcfImportInput.value = "";
});
copyUrlBtn.addEventListener("click", async () => {
if (threeCamera) syncCameraUrl(threeCamera);
const url = urlInput.value.trim();
@ -347,9 +358,16 @@
document.body.addEventListener("dragleave", e => {
if (!e.relatedTarget) document.body.classList.remove("drag-over");
});
document.body.addEventListener("drop", e => {
document.body.addEventListener("drop", async e => {
e.preventDefault();
document.body.classList.remove("drag-over");
// Check for .bcf file drops first.
for (const file of (e.dataTransfer.files || [])) {
if (file.name.toLowerCase().endsWith(".bcf")) {
await importBcf(file);
return;
}
}
const candidates = [
e.dataTransfer.getData("text/uri-list"),
e.dataTransfer.getData("text/plain"),
@ -418,7 +436,8 @@
}
}
const newIfcUrl = base + "?" + qs.toString().replace(/%2C/g, ",").replace(/%2B/g, "+");
const newIfcUrl = base + "?" + qs.toString()
.replace(/%2C/g, ",").replace(/%2B/g, "+").replace(/%24/g, "$");
urlInput.value = newIfcUrl;
@ -657,6 +676,101 @@
URL.revokeObjectURL(a.href);
}
// -----------------------------------------------------------------------
// BCF 2.1 import — read a .bcf zip, extract the first viewpoint, apply
// camera/clips/GUIDs to the current ifc:// URL and reload the viewer.
// -----------------------------------------------------------------------
async function importBcf(file) {
const src = urlInput.value.trim();
if (!src) {
statusEl.textContent = "Load a model first, then import a BCF viewpoint";
return;
}
statusEl.textContent = "Importing BCF…";
try {
const zip = await JSZip.loadAsync(file);
// Find the first viewpoint.bcfv entry.
const vpPath = Object.keys(zip.files).find(p => p.endsWith("viewpoint.bcfv"));
if (!vpPath) {
statusEl.textContent = "BCF: no viewpoint found in file";
return;
}
const vpXml = await zip.file(vpPath).async("text");
const doc = new DOMParser().parseFromString(vpXml, "text/xml");
const getXYZ = (parent, tag) => {
const el = parent.querySelector(tag);
if (!el) return null;
const v = ["X", "Y", "Z"].map(c => parseFloat(el.querySelector(c)?.textContent));
return v.some(isNaN) ? null : v;
};
// Camera
let camera = null, fov = null, scale = null;
const perspCam = doc.querySelector("PerspectiveCamera");
const orthoCam = doc.querySelector("OrthogonalCamera");
const camEl = perspCam || orthoCam;
if (camEl) {
const pos = getXYZ(camEl, "CameraViewPoint");
const dir = getXYZ(camEl, "CameraDirection");
const up = getXYZ(camEl, "CameraUpVector");
if (pos && dir && up) {
camera = [...pos, ...dir, ...up];
if (perspCam) {
fov = parseFloat(perspCam.querySelector("FieldOfView")?.textContent);
if (isNaN(fov)) fov = null;
} else {
scale = parseFloat(orthoCam.querySelector("ViewToWorldScale")?.textContent);
if (isNaN(scale)) scale = null;
}
}
}
// Clipping planes
const clips = [];
for (const cp of doc.querySelectorAll("ClippingPlane")) {
const loc = getXYZ(cp, "Location");
const dir = getXYZ(cp, "Direction");
if (loc && dir) clips.push([...loc, ...dir]);
}
// Component GUIDs from Selection
const guids = Array.from(doc.querySelectorAll("Components Selection Component"))
.map(el => el.getAttribute("IfcGuid"))
.filter(Boolean);
// Build updated URL: keep repo/ref/path, replace viewpoint params.
const qIdx = src.indexOf("?");
const base = qIdx < 0 ? src : src.slice(0, qIdx);
const qs = new URLSearchParams(qIdx < 0 ? "" : src.slice(qIdx + 1));
qs.delete("camera"); qs.delete("fov"); qs.delete("scale"); qs.delete("clip");
qs.delete("selector"); qs.delete("visibility");
if (camera) {
const f = v => parseFloat(v.toFixed(4));
qs.set("camera", camera.map(f).join(","));
if (fov != null) qs.set("fov", fov.toString());
if (scale != null) qs.set("scale", scale.toString());
}
for (const clip of clips) {
const f = v => parseFloat(v.toFixed(4));
qs.append("clip", clip.map(f).join(","));
}
if (guids.length > 0) qs.set("selector", guids.join("+"));
const newUrl = base + "?" + qs.toString()
.replace(/%2C/g, ",").replace(/%2B/g, "+").replace(/%24/g, "$");
loadUrl(newUrl);
} catch (err) {
statusEl.textContent = `BCF import error: ${err.message}`;
console.error(err);
}
}
// -----------------------------------------------------------------------
// Element properties panel
// -----------------------------------------------------------------------

View file

@ -21,6 +21,7 @@ index 4ab9e7b2d1..c1fad254b4 100644
+// IfcURL holds configuration for the ifcurl preview service integration.
+var IfcURL = struct {
+ PreviewServiceURL string `ini:"PREVIEW_SERVICE_URL"`
+ ServiceToken string `ini:"SERVICE_TOKEN"`
+}{}
+
func loadMarkupFrom(rootCfg ConfigProvider) {

View file

@ -206,6 +206,7 @@ func inlineText(n ast.Node, src []byte) ([]byte, bool) {
type ifcURLRenderer struct {
previewServiceURL string
serviceToken string
}
func (r *ifcURLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
@ -219,6 +220,9 @@ func (r *ifcURLRenderer) render(w util.BufWriter, _ []byte, node ast.Node, enter
n := node.(*ifcURLNode)
previewURL := r.previewServiceURL + "/preview?url=" + url.QueryEscape(n.rawURL)
if r.serviceToken != "" {
previewURL += "&token=" + url.QueryEscape(r.serviceToken)
}
viewerURL := "/assets/viewer.html?url=" + url.QueryEscape(n.rawURL)
safeIfc := htmlEscapeString(n.rawURL)
safePreview := htmlEscapeString(previewURL)
@ -247,11 +251,13 @@ func htmlEscapeString(s string) string {
type ifcURLExtension struct {
previewServiceURL string
serviceToken string
}
func NewIfcURLExtension() goldmark.Extender {
return &ifcURLExtension{
previewServiceURL: setting.IfcURL.PreviewServiceURL,
serviceToken: setting.IfcURL.ServiceToken,
}
}
@ -263,7 +269,10 @@ func (e *ifcURLExtension) Extend(m goldmark.Markdown) {
)
m.Renderer().AddOptions(
renderer.WithNodeRenderers(
util.Prioritized(&ifcURLRenderer{previewServiceURL: e.previewServiceURL}, 999),
util.Prioritized(&ifcURLRenderer{
previewServiceURL: e.previewServiceURL,
serviceToken: e.serviceToken,
}, 999),
),
)
}

View file

@ -18,7 +18,10 @@ import (
func newTestMd(t *testing.T) goldmark.Markdown {
t.Helper()
setting.IfcURL.PreviewServiceURL = "http://localhost:8000"
t.Cleanup(func() { setting.IfcURL.PreviewServiceURL = "" })
t.Cleanup(func() {
setting.IfcURL.PreviewServiceURL = ""
setting.IfcURL.ServiceToken = ""
})
return goldmark.New(goldmark.WithExtensions(markdown.NewIfcURLExtension()))
}
@ -82,3 +85,21 @@ func TestIfcURL_InlineAmongText(t *testing.T) {
assert.Contains(t, out, `<figure class="ifcurl-preview">`)
assert.Contains(t, out, "for details.")
}
func TestIfcURL_ServiceTokenInPreviewURL(t *testing.T) {
setting.IfcURL.PreviewServiceURL = "http://localhost:8000"
setting.IfcURL.ServiceToken = "mytoken123"
t.Cleanup(func() {
setting.IfcURL.PreviewServiceURL = ""
setting.IfcURL.ServiceToken = ""
})
md := goldmark.New(goldmark.WithExtensions(markdown.NewIfcURLExtension()))
out := convert(t, md, "[label](ifc://example.com/org/repo@HEAD?path=model.ifc)")
assert.Contains(t, out, `&token=mytoken123`)
}
func TestIfcURL_NoTokenInPreviewURLWhenUnset(t *testing.T) {
md := newTestMd(t)
out := convert(t, md, "[label](ifc://example.com/org/repo@HEAD?path=model.ifc)")
assert.NotContains(t, out, `token=`)
}

View file

@ -267,13 +267,15 @@ class BcfRequest(BaseModel):
@app.get("/preview")
def preview_get(url: str) -> Response:
def preview_get(url: str, token: str | None = None) -> Response:
"""GET variant of POST /preview for use in HTML ``<img src>`` tags.
The ``url`` query parameter is the ifc:// URL to render. All caching
and authentication behaviour is identical to the POST endpoint.
The ``url`` query parameter is the ifc:// URL to render. The optional
``token`` parameter accepts a bearer token for private-repository access,
mirroring the JSON body ``token`` field of POST /preview. All caching
and authentication behaviour is otherwise identical to the POST endpoint.
"""
return preview(PreviewRequest(url=url))
return preview(PreviewRequest(url=url, token=token))
@app.post("/preview")

View file

@ -314,6 +314,20 @@ class TestGetEndpoint:
r = client.get("/preview", params={"url": "ifc:///some/path@HEAD?path=m.ifc"})
assert r.status_code == 403
def test_get_accepts_token_param(self, model_with_geometry):
ifc_bytes = model_with_geometry.to_string().encode()
received = {}
def mock_fetch(ifc_url, token=None):
received["token"] = token
return FAKE_HEXSHA, ifc_bytes
with patch("ifcurl.service.fetch_ifc", mock_fetch), \
patch("ifcurl.service.render_mod.render", return_value=FAKE_PNG):
r = client.get("/preview", params={"url": MUTABLE_URL, "token": "mytoken"})
assert r.status_code == 200
assert received["token"] == "mytoken"
# ---------------------------------------------------------------------------
# Tier 2: LRU eviction