Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 72 additions & 2 deletions src/backend/server/routes/von_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -6008,6 +6008,49 @@ def _load_authorised_file_copy_concept_doc(

@von_bp.route("/api/files/<path:file_copy_concept_id>/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/<path:file_copy_concept_id>/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().
Expand All @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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,
Expand Down
72 changes: 72 additions & 0 deletions src/frontend/web/von_interface/static/js/dynamicTabs.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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),
};
Expand Down Expand Up @@ -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';
Expand Down
24 changes: 24 additions & 0 deletions src/frontend/web/von_interface/templates/file_preview.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<!doctype html>
<html lang="en-NZ">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ title }} — Von preview</title>
<style>
:root { color-scheme: light dark; font-family: system-ui, sans-serif; }
body { margin: 0 auto; max-width: 960px; padding: 24px; line-height: 1.6; overflow-wrap: anywhere; }
header { border-bottom: 1px solid #8886; padding-bottom: 16px; margin-bottom: 24px; }
h1 { font-size: 1.6rem; } header h1 { margin: 0 0 8px; font-size: 1.2rem; }
a { color: light-dark(#1859a8, #9ecbff); }
pre { overflow: auto; padding: 12px; background: #8882; }
code { font-size: .9em; } table { display: block; overflow: auto; border-collapse: collapse; }
th, td { border: 1px solid #8886; padding: 8px; min-width: 120px; }
blockquote { border-left: 3px solid #8886; margin-left: 0; padding-left: 16px; }
@media (max-width: 600px) { body { padding: 16px; } }
</style>
</head>
<body>
<header><h1>{{ title }}</h1><a href="{{ download_url }}" target="_blank" rel="noopener noreferrer">Download original</a></header>
<main>{% if error %}<p role="alert">{{ error }}</p>{% elif body %}{{ body | safe }}{% else %}<p>This file is empty.</p>{% endif %}</main>
</body>
</html>
68 changes: 68 additions & 0 deletions tests/backend/test_von_file_upload_endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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<script>alert(1)</script>\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"<h1>Report</h1>" in response.data
assert b"<table>" in response.data
assert b"<script>" not in response.data
assert b"javascript:" not in response.data
assert b"Download original" in response.data
assert response.headers["Cache-Control"] == "no-store"
assert "default-src 'none'" in response.headers["Content-Security-Policy"]
assert "sandbox " in response.headers["Content-Security-Policy"]
assert client.get(f"/von/api/files/{encoded}/download").data == payload
with client.session_transaction() as sess:
sess["user_concept_id"] = "#V#other_user"
denied = client.get(url)
assert denied.status_code == 404
assert b"<h1>Report</h1>" not in denied.data
with client.session_transaction() as sess:
sess.clear()
assert client.get(url).status_code == 401


@pytest.mark.parametrize(
"filename,payload,status,message",
[
("report.pdf", b"%PDF-example", 415, b"Markdown files only"),
("report.md", b"\xff", 415, b"not UTF-8"),
("report.md", b"x" * (2 * 1024 * 1024 + 1), 413, b"too large"),
("report.md", b"", 200, b"This file is empty"),
],
ids=["unsupported", "invalid-utf8", "oversized", "empty"],
)
def test_preview_recoverable_outcomes(app, filename, payload, status, message):
client = app.test_client()
with client.session_transaction() as sess:
sess["user_concept_id"] = "#V#user"
uploaded = client.post(
"/von/api/files/upload",
data={"file": (io.BytesIO(payload or b" "), filename)},
).get_json()["uploaded"]
if not payload:
store = app.config["TEST_FAKE_STORE"]
store._bytes_by_key[store.last_put["key"]] = b""
encoded = urllib.parse.quote(uploaded["concept_id"], safe="")
response = client.get(f"/von/api/files/{encoded}/preview")
assert response.status_code == status
assert message in response.data
assert b"Download original" in response.data


def test_upload_returns_error_when_blob_store_initialisation_fails(app, monkeypatch):
client = app.test_client()

Expand Down
34 changes: 34 additions & 0 deletions tests/frontend/dynamicTabsFileCopyActions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,37 @@ describe('deriveFileCopyActionState', () => {
});
});

describe('file preview actions', () => {
beforeEach(() => {
document.body.innerHTML = '<div id="tabContainer"></div><div class="tab-content-area"></div><div id="header"></div>';
});

test('opens an encoded, sandboxed preview, reuses it and closes it', () => {
const { openFilePreviewTab } = require(dynamicTabsModulePath);
const conceptId = '#V#file_copy_report';
const tabId = openFilePreviewTab(conceptId, '<Report>');
expect(document.querySelector('iframe').getAttribute('src')).toBe('/von/api/files/%23V%23file_copy_report/preview');
expect(document.querySelector('iframe').getAttribute('sandbox')).not.toContain('allow-scripts');
expect(document.querySelector('.tab-button').textContent).toContain('<Report>');
expect(openFilePreviewTab(conceptId)).toBe(tabId);
expect(document.querySelectorAll('iframe')).toHaveLength(1);
document.querySelector('.close-tab').click();
expect(document.querySelector('iframe')).toBeNull();
});

test('offers both destinations alongside download for a backing file', () => {
const { attachAnalysisButtons } = require(dynamicTabsModulePath);
const header = document.getElementById('header');
attachAnalysisButtons(header, '#V#file_copy_actions', 'individual', {
raw_doc: { attributes: { blob_key: 'report.md' } }
});
const link = header.querySelector('.preview-file-copy-browser-link');
expect(link.getAttribute('href')).toBe('/von/api/files/%23V%23file_copy_actions/preview');
expect(link.target).toBe('_blank');
expect(link.rel).toContain('noopener');
header.querySelector('.preview-file-copy-button').click();
expect(document.querySelector('iframe')).not.toBeNull();
document.querySelector('.close-tab').click();
expect(header.querySelector('.download-file-copy-button')).not.toBeNull();
});
});
Loading