- RichEditorField: Reka toolbar, link popover with the http/https/mailto/tel and relative URL policy, Edit HTML view, lossy note, skeleton while the lazily imported TipTap chunk loads, HTML view fallback when it fails - untouched values are submitted byte-identical; after an edit the value is the editor HTML (empty paragraph -> ""); a value from outside (the save's sanitized answer) reloads the editor - read-only renders through a client display allowlist, never as stored - TipTap 3.31.4 (core, pm, starter-kit) pinned exact after the package check - richeditor.* phrase keys (en, pl), smoke specs, forms docs
105 lines
2.9 KiB
TypeScript
105 lines
2.9 KiB
TypeScript
// Link and display policy of the richeditor field (D-10, T-17-10, T-17-11).
|
|
// The server sanitizer (seaglass) is the gate on every save; these client
|
|
// checks keep the editor from offering what the gate would remove and keep a
|
|
// stored value that predates the gate from running in the admin.
|
|
import { EDITOR_TAGS, inertFragment } from './lossy'
|
|
|
|
/** Link schemes the editor applies; anything else must be a relative URL. */
|
|
export const LINK_SCHEMES: readonly string[] = ['http', 'https', 'mailto', 'tel']
|
|
|
|
// Browsers ignore ASCII control characters, spaces and DEL inside a scheme,
|
|
// so "java\tscript:" is read with them removed.
|
|
const URL_IGNORED = /[\x00-\x20\x7f]+/g
|
|
const URL_SCHEME = /^([a-z][a-z0-9+.-]*):/i
|
|
|
|
/**
|
|
* Whether url may be a link href: an http, https, mailto or tel URL, or a
|
|
* relative one (a path starting with /, #, ? or ./, or a bare relative path).
|
|
*/
|
|
export function isAllowedLink(url: string | null | undefined): boolean {
|
|
if (url == null) {
|
|
return false
|
|
}
|
|
const cleaned = url.replace(URL_IGNORED, '')
|
|
if (cleaned === '') {
|
|
return false
|
|
}
|
|
const scheme = URL_SCHEME.exec(cleaned)
|
|
if (scheme) {
|
|
return LINK_SCHEMES.includes(scheme[1]!.toLowerCase())
|
|
}
|
|
try {
|
|
new URL(cleaned, 'https://admin.invalid/')
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
/** Elements dropped with their content when a value is displayed. */
|
|
const DROPPED = new Set([
|
|
'script',
|
|
'style',
|
|
'template',
|
|
'noscript',
|
|
'iframe',
|
|
'frame',
|
|
'frameset',
|
|
'object',
|
|
'embed',
|
|
'applet',
|
|
'svg',
|
|
'math',
|
|
'title',
|
|
'head',
|
|
'meta',
|
|
'link',
|
|
'base',
|
|
])
|
|
|
|
function clean(parent: ParentNode): void {
|
|
for (const node of Array.from(parent.childNodes)) {
|
|
if (node.nodeType === Node.COMMENT_NODE) {
|
|
node.remove()
|
|
continue
|
|
}
|
|
if (node.nodeType !== Node.ELEMENT_NODE) {
|
|
continue
|
|
}
|
|
const element = node as Element
|
|
const name = element.localName.toLowerCase()
|
|
if (DROPPED.has(name)) {
|
|
element.remove()
|
|
continue
|
|
}
|
|
clean(element)
|
|
if (!EDITOR_TAGS.has(name)) {
|
|
// Another element keeps its text: its children take its place.
|
|
element.replaceWith(...Array.from(element.childNodes))
|
|
continue
|
|
}
|
|
for (const attribute of Array.from(element.attributes)) {
|
|
const keep = name === 'a' && attribute.name === 'href' && isAllowedLink(attribute.value)
|
|
if (!keep) {
|
|
element.removeAttribute(attribute.name)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The read-only rendering of a stored value: only the editor's elements,
|
|
* only allowed link hrefs, no other attribute, no comment. It is what may be
|
|
* bound as HTML in the admin; the stored value itself is never bound.
|
|
*/
|
|
export function displayHtml(html: string): string {
|
|
if (html === '') {
|
|
return ''
|
|
}
|
|
const fragment = inertFragment(html)
|
|
clean(fragment)
|
|
const holder = document.createElement('div')
|
|
holder.append(fragment)
|
|
return holder.innerHTML
|
|
}
|