diff --git a/docs/messages.md b/docs/messages.md index a39d673..e994f79 100644 --- a/docs/messages.md +++ b/docs/messages.md @@ -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 diff --git a/frontend/src/Composer.vue b/frontend/src/Composer.vue index 7536426..ccbac9e 100644 --- a/frontend/src/Composer.vue +++ b/frontend/src/Composer.vue @@ -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" >
+{{ error }}
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; +} diff --git a/frontend/src/richtext.js b/frontend/src/richtext.js index 044b497..80475af 100644 --- a/frontend/src/richtext.js +++ b/frontend/src/richtext.js @@ -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 ), 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 ): 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 } diff --git a/pagerite/messages.py b/pagerite/messages.py index f291280..f07ac21 100644 --- a/pagerite/messages.py +++ b/pagerite/messages.py @@ -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}