mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
markdown: links open nothing they can reach back from
Every link with a target now gets rel="noopener noreferrer", and only _blank is kept as a target. A named target such as target="foo" left the page it opened a handle on this one — browsers imply noopener for _blank alone — which it could use to send the tab to a lookalike login. The share page turned links into the private workspace into plain text with a regex over the sanitised HTML. A pattern over markup can end inside an attribute, and on an engine that does not escape < and > in attribute values a crafted link title became markup. The rewrite now happens in renderMarkdown, on the parsed page.
This commit is contained in:
1 parent
a1bc0a9995
commit
7f15206ffb
2 files changed
+36
-18
No files matched your search
@@ -18,18 +18,8 @@
|
|||||||
let scroller = $state<HTMLElement>()
|
let scroller = $state<HTMLElement>()
|
||||||
let article = $state<HTMLElement>()
|
let article = $state<HTMLElement>()
|
||||||
let position = $state('All')
|
let position = $state('All')
|
||||||
/*
|
// Links into the private workspace come back as their text; see renderMarkdown.
|
||||||
* A link to another document is an address inside the private workspace,
|
const html = $derived(doc ? renderMarkdown(doc.body, { token, key: shareKey }) : '')
|
||||||
* and leads a reader to its login page. Those links are kept as their text.
|
|
||||||
*/
|
|
||||||
const html = $derived(
|
|
||||||
doc
|
|
||||||
? renderMarkdown(doc.body, { token, key: shareKey }).replace(
|
|
||||||
/<a href="\/n\/[^"]*"[^>]*>(.*?)<\/a>/g,
|
|
||||||
'<span class="private-link" title="a page of the private workspace">$1</span>',
|
|
||||||
)
|
|
||||||
: '',
|
|
||||||
)
|
|
||||||
const title = $derived(doc?.name.trim() ?? '')
|
const title = $derived(doc?.name.trim() ?? '')
|
||||||
const words = $derived(doc ? doc.body.split(/\s+/).filter(Boolean).length : 0)
|
const words = $derived(doc ? doc.body.split(/\s+/).filter(Boolean).length : 0)
|
||||||
// The outline gets a window of its own only when there is something to outline.
|
// The outline gets a window of its own only when there is something to outline.
|
||||||
|
|||||||
+34
-6
@@ -57,6 +57,23 @@ function tex(source: string, display: boolean): string {
|
|||||||
return katex.renderToString(source, { displayMode: display, throwOnError: false })
|
return katex.renderToString(source, { displayMode: display, throwOnError: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A link that opens another window leaves it no handle on this one. Only
|
||||||
|
* `_blank` is kept as a target: a named one reuses whatever window has that
|
||||||
|
* name, and browsers give `noopener` to `_blank` alone.
|
||||||
|
*/
|
||||||
|
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
||||||
|
if (node.tagName !== 'A' || !node.hasAttribute('target')) return
|
||||||
|
if (node.getAttribute('target') === '_blank') node.setAttribute('rel', 'noopener noreferrer')
|
||||||
|
else node.removeAttribute('target')
|
||||||
|
})
|
||||||
|
|
||||||
|
const SANITIZE = {
|
||||||
|
// `semantics` and `annotation` carry KaTeX's MathML, for screen readers.
|
||||||
|
ADD_TAGS: ['video', 'audio', 'source', 'semantics', 'annotation'],
|
||||||
|
ADD_ATTR: ['controls', 'target', 'loading', 'playsinline', 'preload', 'encoding'],
|
||||||
|
}
|
||||||
|
|
||||||
function escapeHtml(text: string): string {
|
function escapeHtml(text: string): string {
|
||||||
return text.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"')
|
return text.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"')
|
||||||
}
|
}
|
||||||
@@ -65,7 +82,9 @@ function escapeHtml(text: string): string {
|
|||||||
* Render entry markdown to sanitised HTML. When share credentials are given,
|
* Render entry markdown to sanitised HTML. When share credentials are given,
|
||||||
* media URLs are rewritten to the public route — token and key both, since that
|
* media URLs are rewritten to the public route — token and key both, since that
|
||||||
* route demands the same pair the entry itself did — so the page works for a
|
* route demands the same pair the entry itself did — so the page works for a
|
||||||
* reader who has no session.
|
* reader who has no session, and a link to another document, an address inside
|
||||||
|
* the private workspace that would lead the reader to its login page, is kept
|
||||||
|
* as its text.
|
||||||
*/
|
*/
|
||||||
export function renderMarkdown(source: string, share?: { token: string; key: string }): string {
|
export function renderMarkdown(source: string, share?: { token: string; key: string }): string {
|
||||||
const md = share
|
const md = share
|
||||||
@@ -76,12 +95,21 @@ export function renderMarkdown(source: string, share?: { token: string; key: str
|
|||||||
: source
|
: source
|
||||||
|
|
||||||
const html = marked.parse(md, { async: false }) as string
|
const html = marked.parse(md, { async: false }) as string
|
||||||
|
if (!share) return DOMPurify.sanitize(html, SANITIZE)
|
||||||
|
|
||||||
return DOMPurify.sanitize(html, {
|
// On the parsed page, not on its text: a pattern over HTML can end inside
|
||||||
// `semantics` and `annotation` carry KaTeX's MathML, for screen readers.
|
// an attribute and turn what was quoted there into markup.
|
||||||
ADD_TAGS: ['video', 'audio', 'source', 'semantics', 'annotation'],
|
const page = DOMPurify.sanitize(html, { ...SANITIZE, RETURN_DOM_FRAGMENT: true })
|
||||||
ADD_ATTR: ['controls', 'target', 'loading', 'playsinline', 'preload', 'encoding'],
|
for (const link of page.querySelectorAll('a[href^="/n/"]')) {
|
||||||
})
|
const text = document.createElement('span')
|
||||||
|
text.className = 'private-link'
|
||||||
|
text.title = 'a page of the private workspace'
|
||||||
|
text.append(...link.childNodes)
|
||||||
|
link.replaceWith(text)
|
||||||
|
}
|
||||||
|
const holder = document.createElement('div')
|
||||||
|
holder.append(page)
|
||||||
|
return holder.innerHTML
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The markdown snippet that embeds a freshly uploaded file. */
|
/** The markdown snippet that embeds a freshly uploaded file. */
|
||||||
|
|||||||
Reference in new issue
Block a user