From d00acbd312908b77daeed56c13bd2eca400bebe4 Mon Sep 17 00:00:00 2001
From: August-At-Play <54287845+August-At-Play@users.noreply.github.com>
Date: Sun, 8 Nov 2026 14:45:02 -0800
Subject: [PATCH] fix(preview): resolve local HTML media paths and permit asset
playback
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
---
README.md | 13 +++++
scripts/preview.spec.ts | 99 +++++++++++++++++++++++++++++++++++++++
scripts/preview.test.ts | 8 ++++
src-tauri/tauri.conf.json | 1 +
src/lib/utils/markdown.ts | 61 ++++++++++++++++--------
5 files changed, 163 insertions(+), 19 deletions(-)
diff --git a/README.md b/README.md
index 89641f0e..c5df5c44 100644
--- a/README.md
+++ b/README.md
@@ -88,6 +88,19 @@ The two Windows files differ only in that one installs and one does not — `-se
Every syntax Markpad renders is in one document — [**download `markdown-syntax.md`**](https://raw.githubusercontent.com/sftwrdotdev/Markpad/master/samples/markdown-syntax.md) and open it in Markpad: the preview shows each feature working, the editor beside it shows how each one is written ([read it on GitHub](samples/markdown-syntax.md) first if you like). Give the same file to an AI and it knows exactly what Markpad can render: ask it to reformat a document you already have, or to write a new one that uses the whole range — callouts, tables, footnotes, maths, diagrams — and open the result here.
+## Local video and audio
+
+Raw HTML players resolve local paths relative to the Markdown document, including `../` references and local `file:` URLs:
+
+```html
+
+
+
+
+```
+
+The same handling applies to `` and its `` elements. Controls, dimensions and inline styles are preserved. Media on another UNC host is not loaded; iframe, embed and object elements remain disallowed.
+
## Installation from source
- Clone the repository
diff --git a/scripts/preview.spec.ts b/scripts/preview.spec.ts
index afefc088..87e79e2c 100644
--- a/scripts/preview.spec.ts
+++ b/scripts/preview.spec.ts
@@ -20,6 +20,105 @@ import blocks from './fixtures/markdown-syntax.blocks.json';
import DOMPurify from 'dompurify';
import { readSource } from './sourceTree.js';
+describe('local media paths in the preview', () => {
+ const PATH = 'C:\\notes\\profiles\\core\\person\\memoirs\\day.md';
+ const asset = (path: string) => `http://asset.localhost/${encodeURIComponent(path)}`;
+ let previous: PropertyDescriptor | undefined;
+
+ beforeEach(() => {
+ previous = Object.getOwnPropertyDescriptor(window, '__TAURI_INTERNALS__');
+ Object.defineProperty(window, '__TAURI_INTERNALS__', {
+ configurable: true,
+ value: { convertFileSrc: asset },
+ });
+ });
+
+ afterEach(() => {
+ if (previous) Object.defineProperty(window, '__TAURI_INTERNALS__', previous);
+ else Reflect.deleteProperty(window, '__TAURI_INTERNALS__');
+ });
+
+ function render(markup: string, path = PATH): HTMLDivElement {
+ const host = document.createElement('div');
+ patchPreview(host, markup, path, new Set());
+ return host;
+ }
+
+ test('video and audio sources resolve against the document, not the webview', () => {
+ for (const markup of [
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ '
',
+ ]) {
+ const host = render(markup);
+ expect(host.querySelector('[src]')?.getAttribute('src')).toBe(
+ asset('C:/notes/profiles/core/person/memoirs/clips/small.mp4'),
+ );
+ expect(host.querySelector('img')).toBeNull();
+ }
+ });
+
+ test('dot segments, encoded filenames and absolute file URLs name the right file', () => {
+ for (const [src, expected] of [
+ ['./clips/small.mp4', 'C:/notes/profiles/core/person/memoirs/clips/small.mp4'],
+ ['../../../../media/shorts/clips/small.mp4', 'C:/notes/media/shorts/clips/small.mp4'],
+ ['clips/a%20b%23c.mp4', 'C:/notes/profiles/core/person/memoirs/clips/a b#c.mp4'],
+ ['file:///C:/notes/clips/small.mp4', 'C:/notes/clips/small.mp4'],
+ ['C:\\notes\\clips\\small.mp4', 'C:\\notes\\clips\\small.mp4'],
+ ]) {
+ expect(render(` `).querySelector('video')?.getAttribute('src'))
+ .toBe(asset(expected));
+ }
+ });
+
+ test('a rewritten player retains controls, inline playback, preload and author styling', () => {
+ const markup = ' ';
+ const host = render(markup);
+ const video = host.querySelector('video')!;
+ for (const name of ['controls', 'playsinline']) expect(video.hasAttribute(name)).toBe(true);
+ expect(video.getAttribute('preload')).toBe('metadata');
+ expect(video.getAttribute('width')).toBe('320');
+ expect(video.style.opacity).toBe('0.9');
+ expect(host.innerHTML).toBe(sanitizeMarkdownHtml(processMarkdownHtml(markup, PATH, new Set())));
+ });
+
+ test('asset URLs are not converted twice and network URLs are left to the existing policy', () => {
+ for (const src of [
+ asset('C:/notes/clips/small.mp4'),
+ 'asset://localhost/clip.mp4',
+ 'https://example.com/clip.mp4',
+ ]) {
+ expect(render(` `).querySelector('video')?.getAttribute('src')).toBe(src);
+ }
+ });
+
+ test('raw media cannot load a different UNC host, including through file URLs', () => {
+ for (const src of [
+ '//other.example/share/clip.mp4',
+ '%5C%5Cother.example%5Cshare%5Cclip.mp4',
+ 'file://other.example/share/clip.mp4',
+ ]) {
+ for (const markup of [
+ ` `,
+ ` `,
+ ]) {
+ expect(render(markup).querySelector('[src]')).toBeNull();
+ }
+ }
+ expect(render(' ', '//server/share/doc.md')
+ .querySelector('video')?.getAttribute('src')).toBe(asset('//server/share/clips/small.mp4'));
+ });
+
+ test('media support does not admit script URLs, event handlers or plugin embeds', () => {
+ const host = render(' ');
+ expect(host.querySelector('video')?.hasAttribute('src')).toBe(false);
+ expect(host.querySelector('video')?.hasAttribute('onerror')).toBe(false);
+ expect(host.querySelector('iframe, embed, object')).toBeNull();
+ });
+});
+
/**
* Clicking a code block's Copy label twice within 1.5 s left it reading
* "Copied!" for good: the second click saved "Copied!" as the label to restore.
diff --git a/scripts/preview.test.ts b/scripts/preview.test.ts
index 53905289..001f3b39 100644
--- a/scripts/preview.test.ts
+++ b/scripts/preview.test.ts
@@ -940,6 +940,14 @@ describe('webview permissions the preview depends on', () => {
};
const csp = tauriConf.app.security.csp;
+ test('the CSP permits media from both platform spellings of the local asset protocol', () => {
+ const sources = (csp['media-src'] ?? csp['default-src']).split(/\s+/);
+ for (const source of ["'self'", 'asset:', 'http://asset.localhost']) {
+ assert.ok(sources.includes(source), `media policy does not permit ${source}`);
+ }
+ assert.ok(!sources.includes('*'));
+ });
+
test('the capability still grants the command this depends on', () => {
// `open_path` needs both the command grant and a path scope. Granting the
// command alone leaves the plugin resolving
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
index 6a492e69..47b66e89 100644
--- a/src-tauri/tauri.conf.json
+++ b/src-tauri/tauri.conf.json
@@ -17,6 +17,7 @@
"script-src": "'self' 'unsafe-inline'",
"style-src": "'self' 'unsafe-inline'",
"img-src": "'self' asset: https: http://asset.localhost blob: data:",
+ "media-src": "'self' asset: http://asset.localhost",
"connect-src": "'self'",
"font-src": "'self'"
},
diff --git a/src/lib/utils/markdown.ts b/src/lib/utils/markdown.ts
index de3dae2e..3f11f8b0 100644
--- a/src/lib/utils/markdown.ts
+++ b/src/lib/utils/markdown.ts
@@ -698,6 +698,44 @@ export function processMarkdownHtml(
/** `+` or `-`, on a foldable callout: what the document opens it as. */
const CALLOUT_FOLD_ATTR = "data-callout-fold";
+function resolveElementSrc(element: Element, filePath: string): string | null {
+ const src = element.getAttribute("src");
+ if (!src) return src;
+ const isFileUrl = /^file:/i.test(src);
+ if (!isFileUrl && /^[a-z][a-z0-9+.-]*:/i.test(src) && !/^[a-z]:[\\/]/i.test(src))
+ return src;
+
+ try {
+ let decodedSrc = decodeURIComponent(src);
+ if (isFileUrl) {
+ const url = new URL(src);
+ decodedSrc = decodeURIComponent(url.pathname);
+ if (url.hostname && url.hostname !== "localhost")
+ decodedSrc = `//${url.hostname}${decodedSrc}`;
+ else if (/^\/[a-z]:\//i.test(decodedSrc))
+ decodedSrc = decodedSrc.slice(1);
+ }
+ // Resolving must not turn a UNC reference into an apparently local path.
+ if (isOffHostUncPath(decodedSrc, filePath)) {
+ element.removeAttribute("src");
+ return null;
+ }
+ if (decodedSrc.startsWith("\\\\")) decodedSrc = decodedSrc.replace(/\\/g, "/");
+ const resolved = resolveDocumentRelativePath(filePath, decodedSrc);
+ if (isOffHostUncPath(resolved, filePath)) {
+ element.removeAttribute("src");
+ return null;
+ }
+ const finalSrc = convertFileSrc(resolved);
+ element.setAttribute("src", finalSrc);
+ return finalSrc;
+ } catch (e) {
+ console.error("Failed to decode/resolve media src:", src, e);
+ element.removeAttribute("src");
+ return null;
+ }
+}
+
/**
* Everything `processMarkdownHtml` does that only looks inside one top-level
* block. Fold keys and the heading wrappers need the whole document and are
@@ -707,25 +745,7 @@ export function processBlockMarkup(root: Element, filePath: string): void {
const doc = root.ownerDocument;
for (const img of root.querySelectorAll("img")) {
const src = img.getAttribute("src");
- let finalSrc = src;
- if (src && !src.startsWith("http") && !src.startsWith("data:")) {
- try {
- const decodedSrc = decodeURIComponent(src);
- const resolved = resolveDocumentRelativePath(filePath, decodedSrc);
- // Rendering alone fetches this, so a UNC path on another host
- // would leak the user's NTLM hash without a click. Left as
- // written, `//host/x` would instead load as a web address.
- if (isOffHostUncPath(resolved, filePath)) {
- finalSrc = null;
- img.removeAttribute("src");
- } else {
- finalSrc = convertFileSrc(resolved);
- img.setAttribute("src", finalSrc);
- }
- } catch (e) {
- console.error("Failed to decode/resolve image src:", src, e);
- }
- }
+ const finalSrc = resolveElementSrc(img, filePath);
if (src) {
const ext = src.split(".").pop()?.toLowerCase();
@@ -761,6 +781,9 @@ export function processBlockMarkup(root: Element, filePath: string): void {
}
}
+ for (const media of root.querySelectorAll("video[src], audio[src], video source[src], audio source[src]"))
+ resolveElementSrc(media, filePath);
+
for (const a of root.querySelectorAll("a")) {
const href = a.getAttribute("href");
if (href && isYoutubeLink(href)) {