mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-25 18:40:06 -04:00
Do not load images from other sites in the markdown preview
An image from another site (http:, https:, or protocol-relative //) loaded as soon as the preview opened, telling that site who was reading the file and when. Such an image now becomes a link labeled with its alt text, so the reader chooses whether to follow it. Inside a link (a README badge) it becomes the outer link's text, since links cannot nest. Media and embedded data: images still load. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
61e4807e42
commit
f1872a4d79
2 changed files with 86 additions and 8 deletions
|
|
@ -45,22 +45,59 @@ function rewriteURL(token, attr, fileURL) {
|
|||
}
|
||||
}
|
||||
|
||||
function openInNewTab(token) {
|
||||
const href = token.attrGet('href');
|
||||
// Following a link would close the preview; a fragment stays within the document.
|
||||
if (href && !href.startsWith('#')) {
|
||||
token.attrSet('target', '_blank');
|
||||
token.attrSet('rel', 'noopener noreferrer');
|
||||
}
|
||||
}
|
||||
|
||||
/** An image the preview may load: one served by this WROLPi, or embedded in the file. */
|
||||
function isLocalImage(src) {
|
||||
return !src || src.startsWith('data:') || (src.startsWith('/') && !src.startsWith('//'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace an image from another site with a link to it. Loading it would tell that site who is
|
||||
* reading the file and when (a tracking pixel), so the reader chooses whether to follow it.
|
||||
*
|
||||
* Inside a link (a README badge) the image becomes the outer link's text; links cannot nest.
|
||||
*/
|
||||
function replaceRemoteImage(state, image, insideLink) {
|
||||
const src = image.attrGet('src');
|
||||
const text = new state.Token('text', '', 0);
|
||||
text.content = image.content || src;
|
||||
if (insideLink) {
|
||||
return [text];
|
||||
}
|
||||
const linkOpen = new state.Token('link_open', 'a', 1);
|
||||
linkOpen.attrSet('href', src);
|
||||
openInNewTab(linkOpen);
|
||||
return [linkOpen, text, new state.Token('link_close', 'a', -1)];
|
||||
}
|
||||
|
||||
markdown.core.ruler.push('wrolpi_media_urls', (state) => {
|
||||
const {fileURL} = state.env;
|
||||
for (const token of state.tokens) {
|
||||
for (const child of token.children || []) {
|
||||
if (!token.children) {
|
||||
continue;
|
||||
}
|
||||
let insideLink = false;
|
||||
token.children = token.children.flatMap(child => {
|
||||
if (child.type === 'image') {
|
||||
rewriteURL(child, 'src', fileURL);
|
||||
return isLocalImage(child.attrGet('src')) ? [child] : replaceRemoteImage(state, child, insideLink);
|
||||
} else if (child.type === 'link_open') {
|
||||
insideLink = true;
|
||||
rewriteURL(child, 'href', fileURL);
|
||||
const href = child.attrGet('href');
|
||||
// Following a link would close the preview; a fragment stays within the document.
|
||||
if (href && !href.startsWith('#')) {
|
||||
child.attrSet('target', '_blank');
|
||||
child.attrSet('rel', 'noopener noreferrer');
|
||||
}
|
||||
openInNewTab(child);
|
||||
} else if (child.type === 'link_close') {
|
||||
insideLink = false;
|
||||
}
|
||||
}
|
||||
return [child];
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -74,6 +74,47 @@ describe('renderMarkdown', () => {
|
|||
expect(div.querySelector('img').hasAttribute('src')).toBe(false);
|
||||
});
|
||||
|
||||
test.each([
|
||||
['https://tracker.example/pixel.gif'],
|
||||
['http://tracker.example/pixel.gif'],
|
||||
// Protocol-relative: starts with a slash, but loads from another host.
|
||||
['//tracker.example/pixel.gif'],
|
||||
])('an image from another site (%s) is not loaded, it becomes a link', (src) => {
|
||||
const div = renderToDOM(``);
|
||||
|
||||
expect(div.querySelector('img')).toBeNull();
|
||||
const a = div.querySelector('a');
|
||||
expect(a.getAttribute('href')).toBe(src);
|
||||
expect(a.textContent).toBe('pixel');
|
||||
expect(a.getAttribute('target')).toBe('_blank');
|
||||
expect(a.getAttribute('rel')).toBe('noopener noreferrer');
|
||||
});
|
||||
|
||||
test('an image from another site with no alt text is labeled by its URL', () => {
|
||||
const a = renderToDOM('').querySelector('a');
|
||||
|
||||
expect(a.textContent).toBe('https://example.com/a.png');
|
||||
});
|
||||
|
||||
test('an image from another site inside a link becomes the link text, not a nested link', () => {
|
||||
// The badge pattern common in READMEs.
|
||||
const div = renderToDOM('[](https://ci.example/project)');
|
||||
|
||||
expect(div.querySelector('img')).toBeNull();
|
||||
const links = div.querySelectorAll('a');
|
||||
expect(links).toHaveLength(1);
|
||||
expect(links[0].getAttribute('href')).toBe('https://ci.example/project');
|
||||
expect(links[0].textContent).toBe('build status');
|
||||
});
|
||||
|
||||
test('media and embedded data images still load', () => {
|
||||
const [media, data] = renderToDOM('\n\n')
|
||||
.querySelectorAll('img');
|
||||
|
||||
expect(media.getAttribute('src')).toBe('/media/x/a.png');
|
||||
expect(data.getAttribute('src')).toBe('data:image/png;base64,AAAA');
|
||||
});
|
||||
|
||||
test('external links open in a new tab without an opener', () => {
|
||||
const a = renderToDOM('<https://example.com/page>').querySelector('a');
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue