feat: strip blocks that are disabled for a site and warn in the preview

scarlett
NGPixel 1 month ago
parent edfc4548db
commit c00b7007eb
No known key found for this signature in database

@ -1659,6 +1659,7 @@
"editor.assets.uploadAssetsDropZone": "Browse or Drop files here...", "editor.assets.uploadAssetsDropZone": "Browse or Drop files here...",
"editor.assets.uploadFailed": "File upload failed.", "editor.assets.uploadFailed": "File upload failed.",
"editor.backToEditor": "Back to Editor", "editor.backToEditor": "Back to Editor",
"editor.blockNotEnabled": "This block is not enabled for this site, and will be removed when the page is saved. Blocks are managed in the administration area.",
"editor.blockPicker.blockUnavailable": "This block is not available on this site. Blocks are managed in the administration area.", "editor.blockPicker.blockUnavailable": "This block is not available on this site. Blocks are managed in the administration area.",
"editor.blockPicker.insert": "Insert Block", "editor.blockPicker.insert": "Insert Block",
"editor.blockPicker.loadFailed": "Failed to load the list of blocks.", "editor.blockPicker.loadFailed": "Failed to load the list of blocks.",

@ -283,6 +283,25 @@ class Blocks {
}) })
} }
/**
* The keys of the blocks a site has switched on.
*
* Read from the database on every call rather than kept in a cache like this model's definitions.
* What this answer gates is which blocks survive a page being saved, and a stale `false` silently
* strips an author's block out of their page a wrong answer here destroys content rather than
* merely showing the wrong list. One indexed read of a handful of rows, on a path that has just
* sanitised a whole document, is not worth that risk.
*
* Child blocks never appear: they have no row of their own, and follow the block they sit in.
*/
async getEnabledKeys(siteId: string): Promise<Set<string>> {
const rows = await WIKI.db
.select({ block: blocksTable.block })
.from(blocksTable)
.where(and(eq(blocksTable.siteId, siteId), eq(blocksTable.isEnabled, true)))
return new Set(rows.map((row) => row.block))
}
/** /**
* Enable or disable blocks in bulk. * Enable or disable blocks in bulk.
* *

@ -392,10 +392,14 @@ class Pages {
} }
const alias = await this.validateAlias(siteId, input.alias) const alias = await this.validateAlias(siteId, input.alias)
const { render, toc, text } = WIKI.models.rendering.postProcess(input.render ?? '', { const { render, toc, text } = await WIKI.models.rendering.postProcess(
scripts: hasPermission(actor, 'write:scripts'), siteId,
styles: hasPermission(actor, 'write:styles') input.render ?? '',
}) {
scripts: hasPermission(actor, 'write:scripts'),
styles: hasPermission(actor, 'write:styles')
}
)
const pathParts = path.split('/') const pathParts = path.split('/')
const inserted = await WIKI.db const inserted = await WIKI.db
@ -551,7 +555,7 @@ class Pages {
// -> A render only means anything next to the content it came from, so the two move together // -> A render only means anything next to the content it came from, so the two move together
if (patch.render !== undefined) { if (patch.render !== undefined) {
const { render, toc, text } = WIKI.models.rendering.postProcess(patch.render, { const { render, toc, text } = await WIKI.models.rendering.postProcess(siteId, patch.render, {
scripts: hasPermission(actor, 'write:scripts'), scripts: hasPermission(actor, 'write:scripts'),
styles: hasPermission(actor, 'write:styles') styles: hasPermission(actor, 'write:styles')
}) })
@ -786,7 +790,7 @@ class Pages {
html: string, html: string,
permissions: RenderPermissions permissions: RenderPermissions
): Promise<void> { ): Promise<void> {
const { render, toc, text } = WIKI.models.rendering.postProcess(html, permissions) const { render, toc, text } = await WIKI.models.rendering.postProcess(siteId, html, permissions)
const updated = await WIKI.db const updated = await WIKI.db
.update(pagesTable) .update(pagesTable)

@ -284,17 +284,24 @@ class Rendering {
/** /**
* Clean up a render that came from a client, and pull out what is derived from it. * Clean up a render that came from a client, and pull out what is derived from it.
* *
* @param siteId Whose blocks decide which block elements may stay see `blockAllowances`
* @param html The HTML the editor produced * @param html The HTML the editor produced
* @param permissions What the author may embed. Anything not granted is stripped rather than * @param permissions What the author may embed. Anything not granted is stripped rather than
* rejected: an author pasting a snippet with a tracking script should get their * rejected: an author pasting a snippet with a tracking script should get their
* page saved without it, not an error they cannot act on. * page saved without it, not an error they cannot act on.
*/ */
postProcess(html: string, permissions: RenderPermissions): PostProcessResult { async postProcess(
const clean = this.sanitize(html ?? '', permissions) siteId: string,
html: string,
permissions: RenderPermissions
): Promise<PostProcessResult> {
const enabledBlocks = await WIKI.models.blocks.getEnabledKeys(siteId)
const clean = this.sanitize(html ?? '', permissions, enabledBlocks)
const $ = cheerio.load(clean, null, false) const $ = cheerio.load(clean, null, false)
this.stripEditorArtifacts($) this.stripEditorArtifacts($)
this.unwrapOrphanedChildBlocks($)
const toc = this.anchorHeadings($) const toc = this.anchorHeadings($)
return { return {
@ -313,11 +320,27 @@ class Rendering {
* each tag gets exactly the attributes its component declares as props, which is the same set the * each tag gets exactly the attributes its component declares as props, which is the same set the
* editor's block picker offers. The markup is inert either way: what makes a block do anything is * editor's block picker offers. The markup is inert either way: what makes a block do anything is
* the component fetched from `/_blocks` at view time. * the component fetched from `/_blocks` at view time.
*
* Installed is not sufficient: the block also has to be switched on for this site. Leaving the
* picker to decide that would only cover the authors who use it the content is markdown, so
* `::block-diagram` is a thing anybody can type, and a block an administrator turned off would
* otherwise render for every reader of that page. Being stripped on the way in is also what makes
* turning a block off take effect on the pages that already embed it, since each is re-rendered
* through here.
*
* Child blocks are exempt, having no switch of their own: a tab is part of the tabs it sits in,
* and is gated by `unwrapOrphanedChildBlocks` once the parent's fate is known.
*/ */
private blockAllowances(): { tags: string[]; attributes: Record<string, string[]> } { private blockAllowances(enabledBlocks: Set<string>): {
tags: string[]
attributes: Record<string, string[]>
} {
const tags: string[] = [] const tags: string[] = []
const attributes: Record<string, string[]> = {} const attributes: Record<string, string[]> = {}
for (const definition of WIKI.models.blocks.definitions) { for (const definition of WIKI.models.blocks.definitions) {
if (!definition.isChild && !enabledBlocks.has(definition.block)) {
continue
}
const tag = `block-${definition.block}` const tag = `block-${definition.block}`
tags.push(tag) tags.push(tag)
attributes[tag] = (definition.props ?? []).map((prop) => prop.name) attributes[tag] = (definition.props ?? []).map((prop) => prop.name)
@ -325,11 +348,47 @@ class Rendering {
return { tags, attributes } return { tags, attributes }
} }
/**
* Unwrap child blocks that no longer sit inside a block.
*
* A child block is allowed through the sanitiser unconditionally, because whether it may stay is
* not a question about itself: it is part of its parent, and the parent is what an administrator
* switches on and off. By this point the answer is visible in the document a parent that was
* disabled has already been dropped, leaving its children behind as orphans so a child with no
* block above it is one whose parent was turned off, or one an author typed on its own.
*
* Unwrapped rather than deleted, which is what the sanitiser does to every other tag it refuses:
* the element goes, the content the author wrote inside it stays.
*/
private unwrapOrphanedChildBlocks($: cheerio.CheerioAPI): void {
const definitions = WIKI.models.blocks.definitions
const childTags = definitions.filter((d) => d.isChild).map((d) => `block-${d.block}`)
if (childTags.length < 1) {
return
}
/*
Every non-child block, not merely the enabled ones: a disabled block is not in the document to
be matched, and naming the full set keeps this a question about nesting rather than a second
copy of the enabled-block rule that could disagree with the first.
*/
const parentTags = definitions.filter((d) => !d.isChild).map((d) => `block-${d.block}`)
$(childTags.join(',')).each((_, el) => {
if (parentTags.length > 0 && $(el).parents(parentTags.join(',')).length > 0) {
return
}
$(el).replaceWith($(el).contents())
})
}
/** /**
* Strip everything the author is not allowed to embed. * Strip everything the author is not allowed to embed.
*/ */
private sanitize(html: string, permissions: RenderPermissions): string { private sanitize(
const blocks = this.blockAllowances() html: string,
permissions: RenderPermissions,
enabledBlocks: Set<string>
): string {
const blocks = this.blockAllowances(enabledBlocks)
const allowedTags = [...BASE_ALLOWED_TAGS, ...blocks.tags] const allowedTags = [...BASE_ALLOWED_TAGS, ...blocks.tags]
const allowedAttributes: Record<string, string[]> = { const allowedAttributes: Record<string, string[]> = {
...BASE_ALLOWED_ATTRIBUTES, ...BASE_ALLOWED_ATTRIBUTES,

@ -358,6 +358,19 @@ let pasteCaptureNode = null
const monacoRef = ref(null) const monacoRef = ref(null)
const editorPreviewContainerRef = ref(null) const editorPreviewContainerRef = ref(null)
/**
* Blocks this site has switched off, as the tags they are written as.
*
* The preview fetches a component for every element it does not recognise, so a disabled block would
* draw here and then disappear the moment the page was saved the server strips one that is not
* enabled out of the render. Naming them lets the preview leave the element undefined, which is what
* the saved page comes back as: the block gone, the content the author wrote inside it still there.
*
* Only what the site lists as off. A tag that is not in the list at all is a child block, which has no
* switch of its own, or an unknown one this decides nothing about either.
*/
const disabledBlockTags = ref(new Set())
/* /*
Listed rather than built as `mdi:format-header-${lvl}`: a concatenated icon name is invisible to Listed rather than built as `mdi:format-header-${lvl}`: a concatenated icon name is invisible to
the build-time icon scan, so it would ship as six blank squares. the build-time icon scan, so it would ship as six blank squares.
@ -758,6 +771,53 @@ async function toggleMarkup({ start, end }) {
editor.executeEdits('', edits) editor.executeEdits('', edits)
} }
/**
* Read which blocks this site has switched off, once, before the first preview is drawn.
*
* Order matters more than it looks: a component only has to be fetched once to be defined for the
* rest of the session, so a list that arrives after the first render is too late to keep a disabled
* block from drawing.
*/
async function loadDisabledBlocks() {
try {
const blocks = (await API_CLIENT.get(`sites/${siteStore.id}/blocks`).json()) ?? []
disabledBlockTags.value = new Set(
blocks.filter((block) => !block.isEnabled).map((block) => `block-${block.block}`)
)
} catch (err) {
/*
Left empty, which draws everything as it did before. The preview being too generous is the
better failure: the server strips a disabled block on save either way, so the cost is a preview
that flatters the page, against hiding blocks the site really does have.
*/
console.warn(`Could not read which blocks this site has enabled: ${err.message}`)
}
}
/**
* Say why a block is sitting there doing nothing.
*
* A disabled block is left undefined, so it draws as its own contents and otherwise says nothing
* which reads as a block that is broken rather than one that is switched off. The notice names the
* reason and what saving will do about it; what the author wrote stays underneath, because that is
* what the saved page keeps once the server has stripped the element.
*
* Written into the preview's DOM rather than into the render, which is deliberate: `pageStore.render`
* is what `pageSave` sends, and a notice added to it would be a notice saved into the page. The
* preview is rebuilt from that string on every keystroke, so this is re-applied each time and nothing
* has to be cleaned up the same footing `enhanceRenderedContent` works on.
*/
function markDisabledBlock(el) {
if (el.dataset.blockDisabled !== undefined) {
return
}
el.dataset.blockDisabled = ''
const notice = document.createElement('p')
notice.className = 'block-disabled-notice'
notice.textContent = t('editor.blockNotEnabled')
el.prepend(notice)
}
function processContent(newContent) { function processContent(newContent) {
/* /*
A render that throws must not become a render that is empty. A render that throws must not become a render that is empty.
@ -784,7 +844,13 @@ function processContent(newContent) {
}) })
nextTick(() => { nextTick(() => {
for (const block of editorPreviewContainerRef.value.querySelectorAll(':not(:defined)')) { for (const block of editorPreviewContainerRef.value.querySelectorAll(':not(:defined)')) {
commonStore.loadBlocks([block.tagName.toLowerCase()]) const tag = block.tagName.toLowerCase()
// -> Left undefined on purpose, so the preview shows what saving is about to leave behind
if (disabledBlockTags.value.has(tag)) {
markDisabledBlock(block)
continue
}
commonStore.loadBlocks([tag])
} }
// -> The render was just replaced, so the copy buttons went with it // -> The render was just replaced, so the copy buttons went with it
enhanceRenderedContent(editorPreviewContainerRef.value) enhanceRenderedContent(editorPreviewContainerRef.value)
@ -903,6 +969,9 @@ onMounted(async () => {
hideSideNav: true hideSideNav: true
}) })
// -> Awaited here so it is settled well before the first preview render at the end of this hook
await loadDisabledBlocks()
md = new MarkdownRenderer(editorStore.editors.markdown) md = new MarkdownRenderer(editorStore.editors.markdown)
// -> Define Monaco Theme // -> Define Monaco Theme
@ -1296,6 +1365,47 @@ $editor-height-mobile: calc(100vh - 112px - 16px);
p.line { p.line {
overflow-wrap: break-word; overflow-wrap: break-word;
} }
/*
A block this site has switched off, marked by `markDisabledBlock`. Editor-only styling: the
server strips the element on save, so no reader ever meets one of these.
Built from the admonition palette `.page-contents` already declares -- the preview pane
carries that class, so both themes are covered by the tokens rather than by a rule here.
*/
[data-block-disabled] {
display: block;
margin: 1rem 0;
padding: 0.75rem 1rem;
border-left: 4px solid var(--content-danger);
border-radius: 3px;
background-color: var(--content-danger-wash);
color: var(--content-ink-muted);
}
.block-disabled-notice {
display: flex;
align-items: center;
gap: 0.4rem;
margin: 0;
color: var(--content-danger);
font-size: 0.85rem;
font-weight: 600;
/* -> `mdi:alert`, drawn as a mask so it takes the colour above rather than one of its own */
&::before {
content: '';
flex: 0 0 auto;
width: 1.1rem;
height: 1.1rem;
background-color: currentColor;
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M13 14h-2V9h2m0 9h-2v-2h2M1 21h22L12 2z'/%3E%3C/svg%3E");
mask-repeat: no-repeat;
mask-size: contain;
}
}
/* -> Whatever the author wrote inside, which is what the saved page is left holding */
[data-block-disabled] > .block-disabled-notice + * {
margin-top: 0.5rem;
}
.tabset { .tabset {
background-color: $teal-7; background-color: $teal-7;
color: $teal-2 !important; color: $teal-2 !important;

Loading…
Cancel
Save