fix: content blocks conflicting prop casing with the sanitizer + avoid sending the render if untouched

pull/8104/head
NGPixel 2 weeks ago
parent a9940477df
commit 023711cd1a
No known key found for this signature in database

@ -457,7 +457,24 @@ class Rendering {
} }
const tag = `block-${definition.block}` const tag = `block-${definition.block}`
tags.push(tag) tags.push(tag)
attributes[tag] = (definition.props ?? []).map((prop) => prop.name) /*
Every prop under both spellings, because the two ends of this see different ones.
A prop is declared in camelCase (`showIcons`), which is what the renderer writes and what the
sanitiser is therefore asked about on the way in. But `postProcess` re-parses its own output
with cheerio, and an HTML parser lowercases attribute names -- so what is STORED is
`showicons`, and a later save that sends that render back up offers the sanitiser a name its
allow list has never heard of, which is dropped. A page properties change made with no editor
open sends exactly that, so the props of every block on the page vanished the first time
anybody retagged it, silently and for good: nothing renders them again until the page is
saved from an editor, which produces the render from the source afresh.
Matching both is what makes this pass idempotent. Nothing is lost by it either, since HTML
attribute names are case-insensitive and Lit observes the lowercased form regardless.
*/
attributes[tag] = [
...new Set((definition.props ?? []).flatMap((prop) => [prop.name, prop.name.toLowerCase()]))
]
} }
return { tags, attributes } return { tags, attributes }
} }

@ -1177,9 +1177,7 @@ async function processContent(newContent) {
(el) => el.active ?? 0 (el) => el.active ?? 0
) )
pageStore.$patch({ pageStore.setRender(html)
render: html
})
await nextTick() await nextTick()
// -> With the preview pane closed there is no DOM to attend to. The render is stored either way, so // -> With the preview pane closed there is no DOM to attend to. The render is stored either way, so
// the store still holds what a save would send // the store still holds what a save would send

@ -1346,9 +1346,7 @@ function processContent(newContent) {
(el) => el.active ?? 0 (el) => el.active ?? 0
) )
pageStore.$patch({ pageStore.setRender(html)
render: html
})
nextTick(async () => { nextTick(async () => {
// -> With the preview pane closed there is no DOM to attend to. The render is stored either way, so // -> With the preview pane closed there is no DOM to attend to. The render is stored either way, so
// the store still holds what a save would send // the store still holds what a save would send

@ -1189,9 +1189,9 @@ const syncContentToStore = debounce(() => {
content: markdown, content: markdown,
// -> What the author has written IS the source, whatever the load did or did not deliver; see // -> What the author has written IS the source, whatever the load did or did not deliver; see
// the guard in `pageSave` // the guard in `pageSave`
contentLoaded: true, contentLoaded: true
render: editor.getRender()
}) })
pageStore.setRender(editor.getRender())
}, 500) }, 500)
/** /**
@ -1362,7 +1362,7 @@ onMounted(async () => {
created has no render at all until this runs. created has no render at all until this runs.
*/ */
nextTick(() => { nextTick(() => {
pageStore.$patch({ render: editor.getRender() }) pageStore.setRender(editor.getRender())
refreshActive() refreshActive()
editor.view.focus() editor.view.focus()
}) })

@ -72,7 +72,18 @@
color="primary" color="primary"
padding="xs md" padding="xs md"
:label="t(`common.blog.newPost`)"> :label="t(`common.blog.newPost`)">
<page-new-menu hide-asset-btn :only="POST_EDITORS" :base-path="pageStore.path" /> <!--
What a post may be written with: the editors that author an ARTICLE, which is what a
post is. Not `redirect` and not `blog` — both write a page with no body, and
`models/blogs.ts` does not count either as a post, so creating one here would add
nothing to this listing. Read from the store rather than listed here, because the
same partition decides where `PageNewMenu` draws its divider and because a list
written out again is one that can fall behind an editor being added.
-->
<page-new-menu
hide-asset-btn
:only="siteStore.articleEditors"
:base-path="pageStore.path" />
</w-btn> </w-btn>
</template> </template>
</div> </div>
@ -178,16 +189,6 @@ import { useUserStore } from '@/stores/user'
import PageNewMenu from '@/components/PageNewMenu.vue' import PageNewMenu from '@/components/PageNewMenu.vue'
/**
* What a post may be written with, offered by the New Post button on an empty blog.
*
* The editors that author an ARTICLE, which is what a post is. Not `redirect` and not `blog` — both
* write a page with no body, and `models/blogs.ts` does not count either as a post: a redirection is
* a doorway and a nested blog is its own blog, so creating one here would add nothing to this
* listing. Filtered against what the site has enabled, so this is a ceiling and not a list.
*/
const POST_EDITORS = ['markdown', 'visual']
/** /**
* A blog's front page: its posts, rather than an article. * A blog's front page: its posts, rather than an article.
* *

@ -9,10 +9,14 @@
Narrowed by `only` where a caller has a reason to offer fewer -- see the prop. Narrowed by `only` where a caller has a reason to offer fewer -- see the prop.
--> -->
<w-item v-for="editor of offeredEditors" :key="editor" clickable @click="create(editor)"> <template v-for="editor of offeredEditors" :key="editor">
<blueprint-icon :icon="EDITOR_ICONS[editor]" /> <!-- -> Where the editors that write an article end; see `dividedAt` -->
<w-item-section class="pr-2">{{ t(`common.createPage.${editor}`) }}</w-item-section> <w-separator v-if="editor === dividedAt" class="my-2" inset />
</w-item> <w-item clickable @click="create(editor)">
<blueprint-icon :icon="EDITOR_ICONS[editor]" />
<w-item-section class="pr-2">{{ t(`common.createPage.${editor}`) }}</w-item-section>
</w-item>
</template>
<template v-if="props.hideAssetBtn === false"> <template v-if="props.hideAssetBtn === false">
<w-separator class="my-2" inset /> <w-separator class="my-2" inset />
<w-item clickable @click="openFileManager"> <w-item clickable @click="openFileManager">
@ -36,30 +40,18 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { loading } from '@/composables/loading' import { loading } from '@/composables/loading'
/*
The icons, shared with the other two screens that offer editors. WHICH are offered, and in what
order, is still `siteStore.activeEditors` alone. The wording lives in the locale file as
`common.createPage.<editor>`, keyed by the same ids, so an editor added to that list needs an icon
there and a string there.
*/
import { EDITOR_ICONS } from '@/helpers/editors'
import { useEditorStore } from '@/stores/editor' import { useEditorStore } from '@/stores/editor'
import { usePageStore } from '@/stores/page' import { usePageStore } from '@/stores/page'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
/**
* The icon each editor is offered under.
*
* WHICH of them are offered, and in what order, is `siteStore.activeEditors` -- only that list
* decides. The wording lives in the locale file as `common.createPage.<editor>`, keyed by the same
* ids, so an editor added to that list needs an icon here and a string there.
*
* `redirect` is not an editor the site can turn off, because it authors nothing: a redirection is a
* page with a target instead of a body.
*/
const EDITOR_ICONS = {
markdown: 'markdown',
visual: 'google-presentation',
asciidoc: 'asciidoc',
channel: 'chat',
blog: 'typewriter-with-paper',
api: 'api',
redirect: 'advance'
}
// PROPS // PROPS
@ -123,6 +115,28 @@ const offeredEditors = computed(() =>
: siteStore.activeEditors : siteStore.activeEditors
) )
/**
* The editor the divider is drawn above, or null where there is nothing to divide.
*
* It separates the editors that put an author in front of a blank article from the ones that write a
* settings document instead -- a blog, a redirection -- because those are a different kind of thing
* to be choosing between. `siteStore.articleEditors` is where that partition lives; it is the
* server's own, and the blog's New Post button is drawn from the same answer.
*
* Read off the list rather than fixed at a position, since which editors a site has on is the
* store's answer and this menu can be narrowed further by `only`. The first offered editor that is
* not an article editor -- and only when something above it was one, so the divider can never open
* the menu.
*
* Null for a menu that is all of one kind: a blog's New Post button offers article editors and
* nothing else, and a site with every article editor switched off has nothing above the line.
*/
const dividedAt = computed(() => {
const offered = offeredEditors.value
const first = offered.findIndex((editor) => !siteStore.articleEditors.includes(editor))
return first > 0 ? offered[first] : null
})
// METHODS // METHODS
async function create(editor) { async function create(editor) {

@ -8,26 +8,25 @@
<div class="welcome-actions"> <div class="welcome-actions">
<w-btn push color="primary" :label="t(`welcome.createHome`)" icon="la:plus" no-caps> <w-btn push color="primary" :label="t(`welcome.createHome`)" icon="la:plus" no-caps>
<w-menu class="translucent-menu" auto-close anchor="top left" self="bottom left"> <w-menu class="translucent-menu" auto-close anchor="top left" self="bottom left">
<!--
The editors that author an ARTICLE, which is what a home page is -- so a blog and a
redirection are not offered, and nor is anything the site has switched off.
`siteStore.articleEditors` is the same answer the New Page menu draws its top group
from and the same one a blog's New Post button uses, which is what keeps the three
screens showing one set rather than three lists that drift.
The wording is this screen's own (`welcome.createHome<Editor>`, "Using the Markdown
Editor" rather than "New Markdown Page"), because here the choice is how to write the
wiki's first page rather than what to add to it.
-->
<w-list padding> <w-list padding>
<w-item clickable @click="createHomePage(`visual`)" v-if="siteStore.editors.visual">
<blueprint-icon icon="google-presentation" />
<w-item-section class="pr-2">{{ t(`welcome.createHomeVisual`) }}</w-item-section>
<w-item-section side><w-icon name="mdi:chevron-right" /></w-item-section>
</w-item>
<w-item
clickable
@click="createHomePage(`markdown`)"
v-if="siteStore.editors.markdown">
<blueprint-icon icon="markdown" />
<w-item-section class="pr-2">{{ t(`welcome.createHomeMarkdown`) }}</w-item-section>
<w-item-section side><w-icon name="mdi:chevron-right" /></w-item-section>
</w-item>
<w-item <w-item
v-for="editor of siteStore.articleEditors"
:key="editor"
clickable clickable
@click="createHomePage(`asciidoc`)" @click="createHomePage(editor)">
v-if="siteStore.editors.asciidoc"> <blueprint-icon :icon="EDITOR_ICONS[editor]" />
<blueprint-icon icon="asciidoc" /> <w-item-section class="pr-2">{{ t(labelFor(editor)) }}</w-item-section>
<w-item-section class="pr-2">{{ t(`welcome.createHomeAsciidoc`) }}</w-item-section>
<w-item-section side><w-icon name="mdi:chevron-right" /></w-item-section> <w-item-section side><w-icon name="mdi:chevron-right" /></w-item-section>
</w-item> </w-item>
</w-list> </w-list>
@ -59,14 +58,16 @@ import { loading } from '@/composables/loading'
import { notify } from '@/composables/notify' import { notify } from '@/composables/notify'
import { useMeta } from '@/composables/meta' import { useMeta } from '@/composables/meta'
import { useFlagsStore } from '@/stores/flags' import { EDITOR_ICONS } from '@/helpers/editors'
import { capitalize } from 'es-toolkit/string'
import { usePageStore } from '@/stores/page' import { usePageStore } from '@/stores/page'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
// STORES // STORES
const flagsStore = useFlagsStore()
const pageStore = usePageStore() const pageStore = usePageStore()
const siteStore = useSiteStore() const siteStore = useSiteStore()
const userStore = useUserStore() const userStore = useUserStore()
@ -87,6 +88,19 @@ useMeta(() => ({
// METHODS // METHODS
/**
* The locale key naming an editor on this screen.
*
* Composed rather than tabulated, the same way the admin area names an editor
* (`admin.editors.<id>Name`) and the search filter reads it back. The three that exist —
* `createHomeMarkdown`, `createHomeVisual`, `createHomeAsciidoc` — are keyed by the editor id with
* its first letter raised, so an article editor added later needs a string of its own here or the
* menu shows the key.
*/
function labelFor(editor) {
return `welcome.createHome${capitalize(editor)}`
}
async function createHomePage(editor) { async function createHomePage(editor) {
loading.show() loading.show()
siteStore.overlay = '' siteStore.overlay = ''

@ -0,0 +1,27 @@
/**
* The icon each editor is offered under.
*
* Shared because three screens now offer the same editors and would otherwise each keep their own
* copy of this table: the New Page menu, the New Post button on a blog, and the Welcome screen a
* wiki with no home page yet greets its first author with.
*
* WHICH editors are offered, and in what order, is never decided here — `siteStore.activeEditors` and
* `siteStore.articleEditors` are the only things that answer that, so what a page can be created with
* and what a search can be filtered by cannot drift apart. This is only what each one looks like.
*
* `redirect` is in the table though no site can create one without turning it on, because the table
* has to cover every editor a page may already have been written with.
*
* The names are blueprint icons rather than Iconify references — `BlueprintIcon` resolves one to
* `img:/_assets/icons/ultraviolet-<name>.svg` — so they are outside the build-time icon scan, and
* composing one from a variable is safe here in a way it would not be for an `mdi:` name.
*/
export const EDITOR_ICONS = {
markdown: 'markdown',
visual: 'google-presentation',
asciidoc: 'asciidoc',
channel: 'chat',
blog: 'typewriter-with-paper',
api: 'api',
redirect: 'advance'
}

@ -635,6 +635,16 @@ const showTocPanelBtn = computed(() => tocIsPanel.value && showSidebar.value &&
*/ */
const isBlog = computed(() => pageStore.editor === 'blog') const isBlog = computed(() => pageStore.editor === 'blog')
/**
* Whether the page on screen is a redirection, which is a page with nowhere to read: it takes the
* reader on rather than showing them anything -- see `PageRedirect.vue`, which is drawn in place of
* the article column entirely.
*
* Its own computed rather than the comparison written out at each site, because three things now ask
* it and they have to keep agreeing: the two view tabs and the sidebar.
*/
const isRedirect = computed(() => pageStore.editor === 'redirect')
/** /**
* Whether a blog wants the column beside its listing at all. * Whether a blog wants the column beside its listing at all.
* *
@ -656,7 +666,7 @@ const showSidebar = computed(() => {
// -> Contents, tags and a rating, all of a page that is not there // -> Contents, tags and a rating, all of a page that is not there
!pageStore.notFound && !pageStore.notFound &&
// -> Nor of one nobody stays on: a redirection has no headings to list and is gone in a moment // -> Nor of one nobody stays on: a redirection has no headings to list and is gone in a moment
pageStore.editor !== 'redirect' && !isRedirect.value &&
// -> A blog keeps the column but fills it with its own thing, and only where it asked for one // -> A blog keeps the column but fills it with its own thing, and only where it asked for one
(!isBlog.value || blogWantsSidebar.value) (!isBlog.value || blogWantsSidebar.value)
) )
@ -750,6 +760,13 @@ const showTalkTab = computed(
// -> A blog's front page is a listing rather than an article: there is nothing here to discuss, // -> A blog's front page is a listing rather than an article: there is nothing here to discuss,
// and the discussion a reader wants belongs on the post they are reading // and the discussion a reader wants belongs on the post they are reading
!isBlog.value && !isBlog.value &&
/*
Nor a redirection, for a stronger version of the same reason: it is a doorway rather than a
page, the reader is on their way through it, and the discussion they want is about wherever it
leads. There is no article column for a second view to sit beside either -- `PageRedirect`
replaces it -- so the strip was chrome over a page nobody is reading.
*/
!isRedirect.value &&
userStore.pagePermissions.includes('read:comments') userStore.pagePermissions.includes('read:comments')
) )
@ -777,6 +794,8 @@ const showLinksTab = computed(
!editorStore.isActive && !editorStore.isActive &&
// -> As above: the strip is gone on a blog, and a tab with no strip to sit in cannot be reached // -> As above: the strip is gone on a blog, and a tab with no strip to sit in cannot be reached
!isBlog.value && !isBlog.value &&
// -> And on a redirection, which is drawn in place of the column the tab would switch
!isRedirect.value &&
Boolean(pageStore.id) Boolean(pageStore.id)
) )

@ -127,6 +127,20 @@ export const usePageStore = defineStore('page', {
*/ */
localeRelations: [], localeRelations: [],
render: '', render: '',
/**
* Whether `render` above is HTML an editor in this session produced, rather than the page's own.
*
* Every page load fills `render` from the server, and what comes back has already been through
* `postProcess` — so sending it up again re-sanitizes an output rather than an input, which is not
* the no-op it looks like. It is done with the SAVING session's permissions, so a save by somebody
* without `write:scripts` on the page strips the author's `<script>` out of a render nobody
* touched. And the properties panel, the tags editor and the locale relations dialog all reach the
* Save button with no editor open at all, which is exactly that case.
*
* So the render goes up only when an editor made one — `updatePage` leaves the column alone for a
* key it was not sent. See the guard in `pageSave`, which is the same shape as `contentLoaded`'s.
*/
renderProduced: false,
scriptJsLoad: '', scriptJsLoad: '',
scriptJsUnload: '', scriptJsUnload: '',
scriptCss: '', scriptCss: '',
@ -257,6 +271,8 @@ export const usePageStore = defineStore('page', {
// -> The field is present exactly when the source came with the page, which is what makes // -> The field is present exactly when the source came with the page, which is what makes
// the copy in this store safe to save; a view-mode load leaves the previous one in place // the copy in this store safe to save; a view-mode load leaves the previous one in place
contentLoaded: Object.hasOwn(pageData, 'content'), contentLoaded: Object.hasOwn(pageData, 'content'),
// -> `...pageData` above brought the stored render with it; see `renderProduced`
renderProduced: false,
relations: pageData.relations.map((r) => relations: pageData.relations.map((r) =>
pick(r, ['id', 'position', 'label', 'caption', 'icon', 'target']) pick(r, ['id', 'position', 'label', 'caption', 'icon', 'target'])
), ),
@ -321,6 +337,7 @@ export const usePageStore = defineStore('page', {
this.$patch({ this.$patch({
...pageData, ...pageData,
contentLoaded: Object.hasOwn(pageData, 'content'), contentLoaded: Object.hasOwn(pageData, 'content'),
renderProduced: false,
relations: pageData.relations.map((r) => relations: pageData.relations.map((r) =>
pick(r, ['id', 'position', 'label', 'caption', 'icon', 'target']) pick(r, ['id', 'position', 'label', 'caption', 'icon', 'target'])
), ),
@ -411,6 +428,7 @@ export const usePageStore = defineStore('page', {
content: '', content: '',
contentLoaded: false, contentLoaded: false,
render: '', render: '',
renderProduced: false,
toc: [], toc: [],
tags: [], tags: [],
relations: [], relations: [],
@ -608,6 +626,7 @@ export const usePageStore = defineStore('page', {
// -> A page being created has no stored source to lose: whatever it starts with IS the source // -> A page being created has no stored source to lose: whatever it starts with IS the source
contentLoaded: true, contentLoaded: true,
render: '', render: '',
renderProduced: false,
/* /*
A redirection is in neither the browse menu nor search by default: the first because it is a A redirection is in neither the browse menu nor search by default: the first because it is a
doorway rather than a page to land on, the second because a result for one would stand in doorway rather than a page to land on, the second because a result for one would stand in
@ -886,6 +905,20 @@ export const usePageStore = defineStore('page', {
this.$patch({ title }) this.$patch({ title })
} }
}, },
/**
* Take the HTML an editor has just produced for the page in this store.
*
* The one way `render` is written from the client, so that the flag saying it may be saved cannot
* drift from the value it describes — see `renderProduced`.
*
* @param {string} html
*/
setRender(html) {
this.$patch({
render: html,
renderProduced: true
})
},
/** /**
* PAGE SAVE * PAGE SAVE
*/ */
@ -951,6 +984,21 @@ export const usePageStore = defineStore('page', {
console.warn('Page source was never loaded; saving without touching the stored content.') console.warn('Page source was never loaded; saving without touching the stored content.')
} }
/*
And never send back a render this store did not make.
Every page load fills `render` from the server, so unless an editor has replaced it the store
is holding the page's own stored HTML — already sanitized, already anchored, already reduced
to a table of contents. Sending that up runs `postProcess` over its own output, with this
session's permissions and not the author's, which is how changing a tag from the page view
strips the `<script>` out of a page somebody else wrote. Dropping the key leaves the column
alone, and the only save that has nothing to say about the render is one that did not touch
the source.
*/
if (!this.renderProduced) {
delete body.render
}
let pageData let pageData
if (editorStore.mode === 'create') { if (editorStore.mode === 'create') {
const resp = unwrap( const resp = unwrap(
@ -993,6 +1041,8 @@ export const usePageStore = defineStore('page', {
pick(r, ['locale', 'path', 'title']) pick(r, ['locale', 'path', 'title'])
), ),
tocDepth: pick(pageData.tocDepth, ['min', 'max']), tocDepth: pick(pageData.tocDepth, ['min', 'max']),
// -> The reply carries the stored render, as any other load does; see `renderProduced`
renderProduced: false,
// -> What was pending is now what is stored, which is the whole of what a save means here // -> What was pending is now what is stored, which is the whole of what a save means here
storedProps: storedPropsOf(pageData) storedProps: storedPropsOf(pageData)
}) })

@ -53,6 +53,20 @@ function describeLocales(codes, installed) {
}) })
} }
/**
* The editors whose pages have no article of their own.
*
* The client's copy of `BODYLESS_EDITORS` in `backend/models/pages.ts`, which is what decides on the
* server whether an empty content column is an error and — through `isBodylessEditor` — which pages
* under a blog count as its posts. The two have to agree: an editor this list calls bodyless while
* the server does not is one the blog offers to write a post with and then never lists.
*
* Longer than the server's by the two behind the experimental flag, which have no editor there to
* have an opinion about them yet. Both plainly belong here: a discussion and a set of API docs are
* not somebody being put in front of a blank article, which is the whole of what this divides.
*/
const BODYLESS_EDITORS = ['blog', 'channel', 'api', 'redirect']
export const useSiteStore = defineStore('site', { export const useSiteStore = defineStore('site', {
state: () => ({ state: () => ({
id: null, id: null,
@ -299,6 +313,21 @@ export const useSiteStore = defineStore('site', {
...(this.editors.redirect ? ['redirect'] : []) ...(this.editors.redirect ? ['redirect'] : [])
] ]
}, },
/**
* The active editors that put an author in front of a blank article.
*
* `activeEditors` minus the ones that write a settings document instead — a blog's front page is
* a listing of the posts under it, a redirection is a target. Derived from that list rather than
* written out again, so an editor a site has switched off is missing from both and the order is
* the one the menu already uses.
*
* Two callers, and they want it for the same reason. A blog's **New Post** button offers exactly
* these, since a post is an article and `models/blogs.ts` does not count anything else as one.
* And `PageNewMenu` draws its divider where this list ends.
*/
articleEditors() {
return this.activeEditors.filter((editor) => !BODYLESS_EDITORS.includes(editor))
},
/** Whether `code` is one of the locales this site has enabled. */ /** Whether `code` is one of the locales this site has enabled. */
isActiveLocale: (state) => (code) => state.locales.active.some((lc) => lc.code === code), isActiveLocale: (state) => (code) => state.locales.active.some((lc) => lc.code === code),
/** /**

Loading…
Cancel
Save