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:
1 parent
975ebfe5e7
commit
49bdedd938
4 files changed
+133
-91
No files matched your search
+8
-6
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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(``)
|
||||
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 ? `` : ''
|
||||
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 += ``
|
||||
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
@@ -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}
|
||||
Reference in new issue
Block a user