contact messages: fix inline formatting loss, attachment uploads

The serializer treated only DIV/P as blocks: a formatting element
directly under the editable root (a bolded segment of an undivided
line is a top-level <b>) became a separate PLAIN block, losing both
the formatting and the paragraph structure. Top-level inline runs now
accumulate into one paragraph, and style-based formatting (Firefox's
font-weight/font-style spans) maps back to markers.

Attachments: the upload allowlist rejected everything but a handful of
types with a console-only 415. The logic inverts — raster images get
derivatives, script carriers (svg/html/js/xml...) are refused, and any
other file stores as a download chip. Upload failures now show in the
composer, and toolbar buttons keep the contenteditable selection on
click (mousedown.prevent).
This commit is contained in:
LeoVasanko committed 2026-09-26 01:31:45 +00:00
1 parent 975ebfe5e7
commit 49bdedd938
4 files changed
+133 -91

No files matched your search

+8 -6
View File
@@ -93,12 +93,14 @@ injection point, fed only by the strict renderer.
`PUT /_contact/files/{name}` (public, like the messaging socket) stores
panel uploads in the content-addressed file store: raster images through
the usual AVIF/WebP/JPEG derivative pipeline, a small allowlist of document
types (pdf/txt/md/csv/zip) as-is. **SVG and HTML are excluded** — script
carriers. Gates today: a session-shaped `x-contact-token` header, a 20 MB
size cap and the type allowlist. Admin uploads from the same composer go
to the admin-gated `/_api/files` instead. Files uploaded but never
referenced by a sent message currently linger (no orphan GC).
the usual AVIF/WebP/JPEG derivative pipeline, **anything else as-is**,
rendering as a download chip in the thread. The one exclusion is script
carriers (svg/html/xhtml/js/xml…) — an attachment URL can always be
opened directly in the site's origin, where such a file would execute.
Gates today: a session-shaped `x-contact-token` header, a 20 MB size cap
and the type blocklist. Admin uploads from the same composer go to the
admin-gated `/_api/files` instead. Files uploaded but never referenced by
a sent message currently linger (no orphan GC).
## Storage
+29 -9
View File
@@ -21,6 +21,14 @@ const box = ref(null)
const imageInput = ref(null)
const fileInput = ref(null)
const uploading = ref(0)
const error = ref('')
let errorTimer = null
function showError(text) {
error.value = text
clearTimeout(errorTimer)
errorTimer = setTimeout(() => { error.value = '' }, 6000)
}
onMounted(() => {
if (props.autofocus) box.value?.focus()
@@ -66,7 +74,7 @@ function onDrop(ev) {
async function uploadFiles(files) {
for (const file of files) {
if (file.size > 20 * 1024 * 1024) {
console.warn('contact upload skipped (over 20 MB):', file.name)
showError(`"${file.name}" is over 20 MB`)
continue
}
uploading.value++
@@ -75,10 +83,14 @@ async function uploadFiles(files) {
const init = { method: 'PUT', body: file }
if (props.token) init.headers = { 'x-contact-token': props.token }
const r = await (props.token ? fetch(path, init) : apiFetch(path, init))
if (!r.ok) throw new Error(`upload failed with status ${r.status}`)
if (!r.ok) {
const detail = await r.json().then((d) => d?.detail, () => null)
throw new Error(detail || `status ${r.status}`)
}
insertUpload(await r.json())
} catch (e) {
console.error('contact upload failed:', e)
showError(`could not upload "${file.name}": ${e.message}`)
} finally {
uploading.value--
}
@@ -123,15 +135,16 @@ defineExpose({ focus: () => box.value?.focus() })
@keydown="onKeydown"
></div>
<div class="composer-bar">
<button type="button" title="bold" @click="cmd('bold')"><b>B</b></button>
<button type="button" title="italic" @click="cmd('italic')"><i>I</i></button>
<button type="button" title="link" @click="link">🔗</button>
<button type="button" title="list" @click="cmd('insertUnorderedList')">≡</button>
<button type="button" title="image" @click="imageInput.click()">🖼</button>
<button type="button" title="attachment" @click="fileInput.click()">📎</button>
<button type="button" title="bold" @mousedown.prevent @click="cmd('bold')"><b>B</b></button>
<button type="button" title="italic" @mousedown.prevent @click="cmd('italic')"><i>I</i></button>
<button type="button" title="link" @mousedown.prevent @click="link">🔗</button>
<button type="button" title="list" @mousedown.prevent @click="cmd('insertUnorderedList')">≡</button>
<button type="button" title="image" @mousedown.prevent @click="imageInput.click()">🖼</button>
<button type="button" title="attachment" @mousedown.prevent @click="fileInput.click()">📎</button>
<span v-if="uploading" class="uploading">uploading…</span>
<button type="button" class="send" title="send (Ctrl+Enter)" @click="send">➤</button>
<button type="button" class="send" title="send (Ctrl+Enter)" @mousedown.prevent @click="send">➤</button>
</div>
<p v-if="error" class="composer-error">{{ error }}</p>
<input
ref="imageInput"
type="file"
@@ -220,4 +233,11 @@ defineExpose({ focus: () => box.value?.focus() })
margin-left: auto;
color: var(--accent);
}
.composer-error {
margin: 0;
padding: 0.2rem 0.7rem 0.35rem;
color: #b3261e;
font-size: 0.85em;
}
</style>
+82 -64
View File
@@ -11,91 +11,109 @@ export function escapeMd(text) {
return text.replace(ESCAPE_RE, '\\$1')
}
// One message's Markdown from the composer root's child nodes: each
// top-level node becomes a block, joined by blank lines.
// Tags that open a new block at the composer's top level. Everything else
// there — text, B/I/A/SPAN formatting elements, inline images — is an
// inline run and joins the paragraph being accumulated: browsers happily
// put formatting directly under the editable root (a bolded segment of an
// undivided first line is a top-level <b>), and treating those as blocks
// would split the paragraph and drop the formatting.
const BLOCK_TAGS = new Set([
'DIV', 'P', 'UL', 'OL', 'BLOCKQUOTE', 'PRE',
'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'HR', 'TABLE',
])
// One message's Markdown from the composer root: inline runs become
// paragraphs, block elements become their own blocks, blank lines between.
export function serializeRich(root) {
const blocks = []
for (const node of root.childNodes) block(node, blocks)
return blocks
.map((b) => b.trim())
.filter((b) => b)
.join('\n\n')
.replace(/\n{3,}/g, '\n\n')
.trim()
let run = []
const flush = () => {
const text = run.join('').trim()
run = []
if (text) blocks.push(text)
}
for (const node of root.childNodes) {
if (node.nodeType === Node.ELEMENT_NODE && BLOCK_TAGS.has(node.tagName)) {
flush()
block(node, blocks)
} else {
run.push(inline(node))
}
}
flush()
return blocks.join('\n\n').replace(/\n{3,}/g, '\n\n').trim()
}
function block(node, out) {
if (node.nodeType === Node.TEXT_NODE) {
out.push(escapeMd(node.textContent))
return
}
if (node.nodeType !== Node.ELEMENT_NODE) return
switch (node.tagName) {
case 'UL':
out.push([...node.children].map((li) => `- ${inline(li).trim()}`).join('\n'))
out.push([...node.children].map((li) => `- ${inlineChildren(li).trim()}`).join('\n'))
break
case 'OL':
out.push([...node.children].map((li, i) => `${i + 1}. ${inline(li).trim()}`).join('\n'))
out.push([...node.children].map((li, i) => `${i + 1}. ${inlineChildren(li).trim()}`).join('\n'))
break
case 'BLOCKQUOTE':
out.push(inline(node).split('\n').map((l) => `> ${l}`).join('\n'))
out.push(inlineChildren(node).split('\n').map((l) => `> ${l}`).join('\n'))
break
case 'PRE':
out.push('```\n' + node.textContent + '\n```')
break
case 'IMG':
// Uploaded image (data-path set by the composer on insert).
if (node.dataset.path) out.push(`![${escapeMd(node.alt || 'image')}](${node.dataset.path})`)
case 'HR':
break
case 'BR':
break
default:
// DIV, P and anything else: a paragraph of inline content.
out.push(inline(node))
default: {
// DIV, P, headings: a paragraph of inline content.
const text = inlineChildren(node).trim()
if (text) out.push(text)
}
}
}
function inlineChildren(node) {
return [...node.childNodes].map(inline).join('')
}
function inline(node) {
let out = ''
for (const child of node.childNodes) {
if (child.nodeType === Node.TEXT_NODE) {
out += escapeMd(child.textContent)
continue
if (node.nodeType === Node.TEXT_NODE) return escapeMd(node.textContent)
if (node.nodeType !== Node.ELEMENT_NODE) return ''
switch (node.tagName) {
case 'B':
case 'STRONG':
return `**${inlineChildren(node)}**`
case 'I':
case 'EM':
return `*${inlineChildren(node)}*`
case 'CODE':
return '`' + node.textContent.replace(/\\/g, '\\\\').replace(/`/g, '\\`') + '`'
case 'BR':
return '\n'
case 'A': {
const text = inlineChildren(node)
const href = node.getAttribute('href') || ''
return /^(https?:\/\/|mailto:|\/)/.test(href) ? `[${text}](${href})` : text
}
if (child.nodeType !== Node.ELEMENT_NODE) continue
switch (child.tagName) {
case 'B':
case 'STRONG':
out += `**${inline(child)}**`
break
case 'I':
case 'EM':
out += `*${inline(child)}*`
break
case 'CODE':
out += '`' + child.textContent.replace(/\\/g, '\\\\').replace(/`/g, '\\`') + '`'
break
case 'BR':
out += '\n'
break
case 'A': {
const text = inline(child)
const href = child.getAttribute('href') || ''
out += /^(https?:\/\/|mailto:|\/)/.test(href) ? `[${text}](${href})` : text
break
case 'IMG':
// Uploaded image (data-path set by the composer on insert).
return node.dataset.path ? `![${escapeMd(node.alt || 'image')}](${node.dataset.path})` : ''
case 'SPAN':
// Attachment chip (data-path + data-name); other spans unwrap.
if (node.dataset.path && node.dataset.name) {
return `[📎 ${escapeMd(node.dataset.name)}](${node.dataset.path})`
}
case 'IMG':
if (child.dataset.path) out += `![${escapeMd(child.alt || 'image')}](${child.dataset.path})`
break
case 'SPAN':
// Attachment chip (data-path + data-name); other spans unwrap.
out += child.dataset.path && child.dataset.name
? `[📎 ${escapeMd(child.dataset.name)}](${child.dataset.path})`
: inline(child)
break
default:
out += inline(child)
}
return wrapStyle(node, inlineChildren(node))
default:
return wrapStyle(node, inlineChildren(node))
}
}
// Some browsers style formatting instead of tagging it (Firefox's
// execCommand produces <span style="font-weight: bold">): keep span-level
// bold/italic alive into the Markdown.
function wrapStyle(node, text) {
if (!text.trim()) return text
const s = node.style
if (s) {
if (/bold|bolder|[6-9]00/.test(s.fontWeight)) text = `**${text}**`
if (s.fontStyle === 'italic' || s.fontStyle === 'oblique') text = `*${text}*`
}
return out
return text
}
+14 -12
View File
@@ -302,11 +302,12 @@ async def admin_frame(ws: WebSocket, raw: dict) -> None:
# --- Uploads -----------------------------------------------------------------
#: Contact-panel uploads: raster images get the usual derivative pipeline,
#: a few document types are stored as-is. SVG and HTML are excluded on
#: purpose — script carriers have no place in a thread whose HTML must
#: stay markdown-it output only.
#: everything else is stored as-is and renders as a download chip — except
#: script carriers, which are refused outright: an attachment URL can
#: always be opened directly in the site's origin, where served HTML/JS/
#: SVG/XML would execute.
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".heic", ".heif"}
FILE_EXTS = {".pdf", ".txt", ".md", ".csv", ".zip"}
BLOCKED_EXTS = {".svg", ".html", ".htm", ".xhtml", ".xht", ".js", ".mjs", ".xml"}
UPLOAD_MAX_BYTES = 20 * 1024 * 1024
@@ -315,11 +316,12 @@ async def contact_upload(name: str, request: Request) -> dict:
"""Store a contact-panel upload in the content-addressed store.
Public like the messaging socket (only /_api is gated): the caller
must present a session-shaped token, size and type are capped, and
abuse limiting is meant to ride on the proof-of-work stamp once
plugged in. Images get the usual AVIF/WebP/JPEG derivatives and are
referenced extension-less; other allowed types are stored as-is.
Returns the /_f/ path and whether the file is an inline-able image.
must present a session-shaped token, size is capped, script-carrying
types are refused, and abuse limiting is meant to ride on the
proof-of-work stamp once plugged in. Images get the usual
AVIF/WebP/JPEG derivatives and are referenced extension-less; any
other file is stored as-is for download. Returns the /_f/ path and
whether the file is an inline-able image.
"""
if not TOKEN_RE.fullmatch(request.headers.get("x-contact-token", "")):
raise HTTPException(403, "no session")
@@ -339,10 +341,10 @@ async def contact_upload(name: str, request: Request) -> dict:
if ext in IMAGE_EXTS:
stored = await asyncio.to_thread(store_image, body, ext, derive=ext != ".gif")
image = True
elif ext in FILE_EXTS:
elif ext in BLOCKED_EXTS:
raise HTTPException(415, "this file type is not allowed")
else:
stored = _hash_name(body, orig)
await asyncio.to_thread(file_store.put, stored, body)
image = False
else:
raise HTTPException(415, "unsupported file type")
return {"path": f"/_f/{stored}", "image": image, "name": orig}