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 02729785..599cc566 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 03ead60b..b12493ea 100644
--- a/scripts/preview.test.ts
+++ b/scripts/preview.test.ts
@@ -948,6 +948,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 6f78dbfc..b3335908 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;
+ }
+}
+
/**
* Reload re-reads the document, not its images (#1066). An image edited on
* disk keeps its URL, so the webview shows the copy it already decoded, and a
@@ -728,24 +766,10 @@ export function processBlockMarkup(root: Element, filePath: string): void {
const src = img.getAttribute("src");
const generation = img.getAttribute(GENERATION_ATTR);
img.removeAttribute(GENERATION_ATTR);
- 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) + (generation ? `?v=${generation}` : "");
- img.setAttribute("src", finalSrc);
- }
- } catch (e) {
- console.error("Failed to decode/resolve image src:", src, e);
- }
+ let finalSrc = resolveElementSrc(img, filePath);
+ if (generation && finalSrc && finalSrc !== src) {
+ finalSrc += `?v=${generation}`;
+ img.setAttribute("src", finalSrc);
}
if (src) {
@@ -782,6 +806,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)) {