From 9c12c8120baac1442d7cdb8c8b76df6a33f258cf Mon Sep 17 00:00:00 2001 From: witbrock Date: Mon, 14 Sep 2026 18:12:10 -0400 Subject: [PATCH] Add Markdown file previews in Von and browser tabs --- src/backend/server/routes/von_routes.py | 74 ++++++++++++++++++- .../von_interface/static/js/dynamicTabs.js | 72 ++++++++++++++++++ .../von_interface/templates/file_preview.html | 24 ++++++ .../backend/test_von_file_upload_endpoint.py | 68 +++++++++++++++++ .../dynamicTabsFileCopyActions.test.js | 34 +++++++++ 5 files changed, 270 insertions(+), 2 deletions(-) create mode 100644 src/frontend/web/von_interface/templates/file_preview.html diff --git a/src/backend/server/routes/von_routes.py b/src/backend/server/routes/von_routes.py index 52674bab3..a1dbfea13 100644 --- a/src/backend/server/routes/von_routes.py +++ b/src/backend/server/routes/von_routes.py @@ -6008,6 +6008,49 @@ def _load_authorised_file_copy_concept_doc( @von_bp.route("/api/files//download", methods=["GET"]) def download_file_copy(file_copy_concept_id: str): + return _serve_file_copy(file_copy_concept_id) + + +@von_bp.route("/api/files//preview", methods=["GET"]) +def preview_file_copy(file_copy_concept_id: str): + """Render a file through the same actor-authorised read as download.""" + response = make_response(_serve_file_copy(file_copy_concept_id, preview=True)) + if response.is_json: + error = (response.get_json() or {}).get("error") + messages = { + "missing_user_context": "Sign in to Von, then reload this preview.", + "unsupported_preview": "Preview is currently available for Markdown files only. Download this file to open it.", + "invalid_text_encoding": "This file is not UTF-8 Markdown. Download it to open it.", + "file_too_large": "This file is too large to preview. Download it to open it.", + } + response.set_data( + render_template( + "file_preview.html", + title="File preview", + body=None, + error=messages.get( + error, "This file is unavailable or you do not have access." + ), + download_url=url_for( + "von.download_file_copy", file_copy_concept_id=file_copy_concept_id + ), + ) + ) + response.content_type = "text/html; charset=utf-8" + response.headers["Cache-Control"] = "no-store" + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["Referrer-Policy"] = "no-referrer" + # Files are untrusted documents, never application code. No scripts, + # embedded resources or forms; ordinary links and downloads still work. + response.headers["Content-Security-Policy"] = ( + "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'self'; " + "base-uri 'none'; form-action 'none'; " + "sandbox allow-popups allow-popups-to-escape-sandbox allow-downloads" + ) + return response + + +def _serve_file_copy(file_copy_concept_id: str, *, preview: bool = False): """Download an uploaded file-copy by its Vontology concept id. Security: user identity is derived server-side via get_effective_user_concept_id(). @@ -6033,7 +6076,11 @@ def download_file_copy(file_copy_concept_id: str): ) # Allow query-parameter override so callers don't need to place the full id in the path. - concept_id = request.args.get("concept_id") or file_copy_concept_id + concept_id = ( + file_copy_concept_id + if preview + else (request.args.get("concept_id") or file_copy_concept_id) + ) concept_id = unquote(str(concept_id or "")) concept_id = str(concept_id or "").strip() if not concept_id: @@ -6053,7 +6100,8 @@ def download_file_copy(file_copy_concept_id: str): result = fetch_file_copy_bytes( file_copy_concept_id=concept_id, user_concept_id=user_concept_id, - allow_large=True, + allow_large=not preview, + max_bytes=2 * 1024 * 1024 if preview else None, logger=current_app.logger, ) if not isinstance(result, dict) or result.get("success") is not True: @@ -6084,6 +6132,28 @@ def download_file_copy(file_copy_concept_id: str): mimetype = "application/octet-stream" if info is not None and getattr(info, "content_type", None): mimetype = str(info.content_type) + if preview: + if not ( + download_name.lower().endswith((".md", ".markdown", ".mdown")) + or mimetype.split(";", 1)[0].strip().lower() + in {"text/markdown", "text/x-markdown"} + ): + return jsonify({"error": "unsupported_preview"}), 415 + try: + text = data_bytes.decode("utf-8-sig") + except UnicodeDecodeError: + return jsonify({"error": "invalid_text_encoding"}), 415 + from ...services.markdown_render_service import render_markdown_to_safe_html + + return render_template( + "file_preview.html", + title=download_name, + body=render_markdown_to_safe_html(text), + error=None, + download_url=url_for( + "von.download_file_copy", file_copy_concept_id=concept_id + ), + ) resp = send_file( io.BytesIO(data_bytes), mimetype=mimetype, diff --git a/src/frontend/web/von_interface/static/js/dynamicTabs.js b/src/frontend/web/von_interface/static/js/dynamicTabs.js index 06dfd6d3d..cce778430 100644 --- a/src/frontend/web/von_interface/static/js/dynamicTabs.js +++ b/src/frontend/web/von_interface/static/js/dynamicTabs.js @@ -2115,6 +2115,50 @@ function closeDynamicAnnotationTab(tabId) { } } +const filePreviewTabs = new Map(); +let filePreviewCounter = 0; + +export function filePreviewUrl(conceptId) { + return `/von/api/files/${encodeURIComponent(conceptId)}/preview`; +} + +export function openFilePreviewTab(conceptId, name = 'File') { + const existing = filePreviewTabs.get(conceptId); + if (existing) { + activateTab(existing.content.id); + return existing.content.id; + } + const tabId = `filePreviewTab_${++filePreviewCounter}`; + const previousTabId = document.querySelector('.tab-button.active')?.dataset.tab || 'chatTab'; + const button = document.createElement('div'); + button.className = 'tab-button closable'; + button.dataset.tab = tabId; + button.appendChild(document.createTextNode(`Preview: ${name}`)); + const content = document.createElement('div'); + content.id = tabId; + content.className = 'tab-content file-preview-tab'; + const frame = document.createElement('iframe'); + frame.title = `File preview: ${name}`; + frame.src = filePreviewUrl(conceptId); + frame.setAttribute('sandbox', 'allow-popups allow-popups-to-escape-sandbox allow-downloads'); + frame.style.cssText = 'width:100%;height:100%;min-height:60vh;border:0;display:block'; + content.appendChild(frame); + const close = () => { + const wasActive = button.classList.contains('active'); + frame.src = 'about:blank'; + removeTabElements(button, content, { animate: false }); + filePreviewTabs.delete(conceptId); + if (wasActive) activateTab(document.getElementById(previousTabId) ? previousTabId : 'chatTab'); + }; + button.appendChild(createCloseTabButton('Close file preview', close)); + button.addEventListener('click', () => activateTab(tabId)); + filePreviewTabs.set(conceptId, { content, close }); + insertTabButton(button); + insertTabContent(content); + activateTab(tabId); + return tabId; +} + // Create and load a dynamic annotation tab function createAnnotationTab(text, conceptName, source) { if (!annotationEnabled) { @@ -3101,6 +3145,14 @@ async function initializeDynamicConceptTab(conceptId, uniqueIdSuffix) { export function initializeDynamicTabs() { console.log('[dynamicTabs] Initializing dynamic tabs functionality'); + document.addEventListener('authStatusChanged', () => { + for (const preview of filePreviewTabs.values()) preview.close(); + }); + document.addEventListener('open-file-preview', (event) => { + const { conceptId, name } = event.detail || {}; + if (conceptId) openFilePreviewTab(conceptId, name); + }); + // Static concept-tab markup is needed on every concept open. Begin its // retryable preload while the rest of the application initialises. void loadConceptTabTemplate().catch((error) => { @@ -7880,6 +7932,7 @@ export function deriveFileCopyActionState(nodePayload = null) { return { isFileCopyConcept: looksLikeFileCopyType || hasResolvableBlobRef, + displayName: attributes.original_filename || rawDoc.name || 'File', hasResolvableBlobRef, showFileActions: hasResolvableBlobRef && (looksLikeFileCopyType || hasResolvableBlobRef), }; @@ -7988,6 +8041,25 @@ function attachDeleteConceptButton(headerDiv, conceptId, kind, fileCopyActionSta if (headerDiv.querySelector('.delete-concept-button')) return; // already added const fileActionsEnabled = !!(fileCopyActionState && fileCopyActionState.showFileActions); + if (fileActionsEnabled && !headerDiv.querySelector('.preview-file-copy-button')) { + const previewButton = document.createElement('button'); + previewButton.className = 'preview-file-copy-button'; + previewButton.textContent = 'Preview in Von'; + previewButton.addEventListener('click', (event) => { + event.stopPropagation(); + openFilePreviewTab(conceptId, fileCopyActionState.displayName); + }); + const browserLink = document.createElement('a'); + browserLink.className = 'preview-file-copy-browser-link'; + browserLink.textContent = 'Preview in browser tab'; + browserLink.href = filePreviewUrl(conceptId); + browserLink.target = '_blank'; + browserLink.rel = 'noopener noreferrer'; + browserLink.style.marginLeft = '8px'; + browserLink.addEventListener('click', (event) => event.stopPropagation()); + headerDiv.append(previewButton, browserLink); + } + if (fileActionsEnabled && !headerDiv.querySelector('.download-file-copy-button')) { const downloadBtn = document.createElement('button'); downloadBtn.className = 'download-file-copy-button'; diff --git a/src/frontend/web/von_interface/templates/file_preview.html b/src/frontend/web/von_interface/templates/file_preview.html new file mode 100644 index 000000000..e7a97f3d9 --- /dev/null +++ b/src/frontend/web/von_interface/templates/file_preview.html @@ -0,0 +1,24 @@ + + + + + + {{ title }} — Von preview + + + +

{{ title }}

Download original
+
{% if error %}

{{ error }}

{% elif body %}{{ body | safe }}{% else %}

This file is empty.

{% endif %}
+ + diff --git a/tests/backend/test_von_file_upload_endpoint.py b/tests/backend/test_von_file_upload_endpoint.py index 3bc23a5a7..e51b08e3b 100644 --- a/tests/backend/test_von_file_upload_endpoint.py +++ b/tests/backend/test_von_file_upload_endpoint.py @@ -397,6 +397,74 @@ def boom(*args, **kwargs): assert workflow_calls == [] +@pytest.mark.parametrize( + "filename,content_type", + [ + ("report.md", "application/octet-stream"), + ("report", "text/markdown"), + ], +) +def test_markdown_preview_round_trip_and_denial(app, filename, content_type): + client = app.test_client() + with client.session_transaction() as sess: + sess["user_concept_id"] = "#V#user" + payload = b"# Report\n\n| A | B |\n|---|---|\n| one | two |\n\n\n\n[Bad](javascript:alert(1))" + uploaded = client.post( + "/von/api/files/upload", + data={"file": (io.BytesIO(payload), filename, content_type)}, + ).get_json()["uploaded"] + encoded = urllib.parse.quote(uploaded["concept_id"], safe="") + url = f"/von/api/files/{encoded}/preview" + response = client.get(url) + assert response.status_code == 200 + assert response.mimetype == "text/html" + assert b"

Report

" in response.data + assert b"" in response.data + assert b"