Skip to content
Closed
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
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<video controls playsinline src="clips/demo.mp4" style="max-width: 100%"></video>
<video controls>
<source src="clips/demo.mp4" type="video/mp4">
</video>
```

The same handling applies to `<audio>` and its `<source>` 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
Expand Down
99 changes: 99 additions & 0 deletions scripts/preview.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
'<video controls src="clips/small.mp4"></video>',
'<video controls><source src="clips/small.mp4" type="video/mp4"></video>',
'<audio controls src="clips/small.mp4"></audio>',
'<audio controls><source src="clips/small.mp4" type="audio/mp4"></audio>',
'<p><img src="clips/small.mp4"></p>',
]) {
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(`<video controls src="${src}"></video>`).querySelector('video')?.getAttribute('src'))
.toBe(asset(expected));
}
});

test('a rewritten player retains controls, inline playback, preload and author styling', () => {
const markup = '<video controls playsinline preload="metadata" width="320" style="opacity: 0.9" src="clips/small.mp4"></video>';
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(`<video controls src="${src}"></video>`).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 [
`<video controls src="${src}"></video>`,
`<video controls><source src="${src}" type="video/mp4"></video>`,
]) {
expect(render(markup).querySelector('[src]')).toBeNull();
}
}
expect(render('<video src="clips/small.mp4"></video>', '//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('<video src="javascript:alert(1)" onerror="alert(1)"></video><iframe src="clips/small.mp4"></iframe><embed src="clips/small.mp4"><object data="clips/small.mp4"></object>');
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.
Expand Down
8 changes: 8 additions & 0 deletions scripts/preview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -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'"
},
Expand Down
63 changes: 45 additions & 18 deletions src/lib/utils/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)) {
Expand Down