From 47d92e5d28323b15382a96cae83387cd290fb181 Mon Sep 17 00:00:00 2001 From: Arnold Hendriks Date: Thu, 12 Nov 2026 13:02:37 +0100 Subject: [PATCH 1/2] Download pasted RTD images in the browser instead of on the server The RTD imported pasted images by asking the server to fetch their URLs (getImgFromRemoteURL), which let any client make the server fetch arbitrary URLs. The browser now downloads pasted images itself and uploads them through getUploadedFileFinalURL, and the server-side fetch is removed. - data: URLs are decoded directly as the CSP generally blocks fetching them - When 'Copy image' offers both a remote URL and the image file, use the file - Accept png, jpeg, gif, webp and avif, max 20MB, and verify they decode - Upload placeholders take the pasted image's dimensions - Server-side RTD cleanup now properly discards invalid or non-image data: URLs --- whtree/jssdk/forms/src/formservice.ts | 3 - .../publisher/lib/internal/forms/rpc.whlib | 52 +-------- .../publisher/lib/internal/rtd/service.whlib | 6 +- .../richeditor/internal/editorbase.tsx | 101 +++++++++++++++++- .../richeditor/internal/structurededitor.tsx | 12 ++- .../ui/components/richeditor/richeditor.scss | 6 ++ .../tests/tollium/richdoc/teststructured.ts | 57 +++++++++- 7 files changed, 179 insertions(+), 58 deletions(-) diff --git a/whtree/jssdk/forms/src/formservice.ts b/whtree/jssdk/forms/src/formservice.ts index 63c2450f67..8704d1d5ce 100644 --- a/whtree/jssdk/forms/src/formservice.ts +++ b/whtree/jssdk/forms/src/formservice.ts @@ -32,9 +32,6 @@ export interface PublisherFormService { formValidateEmail(target: RPCFormTarget & { field: string }, emailaddress: string): Promise; - /** Get an image from a remote URL */ - getImgFromRemoteURL(imageurl: string): Promise; - /** Get the final image URL to use */ getUploadedFileFinalURL(uploadurl: string): Promise; diff --git a/whtree/modules/publisher/lib/internal/forms/rpc.whlib b/whtree/modules/publisher/lib/internal/forms/rpc.whlib index 5088b2c5d1..7a68db48ff 100644 --- a/whtree/modules/publisher/lib/internal/forms/rpc.whlib +++ b/whtree/modules/publisher/lib/internal/forms/rpc.whlib @@ -3,7 +3,6 @@ LOADLIB "wh::files.whlib"; LOADLIB "wh::internal/any.whlib"; LOADLIB "wh::internal/graphics.whlib"; LOADLIB "wh::internet/urls.whlib"; -LOADLIB "wh::internet/webbrowser.whlib"; LOADLIB "mod::platform/lib/captcha/captchamgmt.whlib"; @@ -11,7 +10,6 @@ LOADLIB "mod::publisher/lib/forms/api.whlib"; LOADLIB "mod::publisher/lib/siteprofiles.whlib"; LOADLIB "mod::publisher/lib/internal/forms/support.whlib"; LOADLIB "mod::system/lib/cache.whlib"; -LOADLIB "mod::system/lib/configure.whlib"; LOADLIB "mod::system/lib/database.whlib"; LOADLIB "mod::system/lib/mailer.whlib"; LOADLIB "mod::system/lib/services.whlib"; @@ -60,53 +58,9 @@ PUBLIC STRING FUNCTION RPC_GetUploadedFileFinalURL(STRING uploadurl) RECORD file := ParseDataURL(uploadurl); RECORD wrapped := WrapBlob(file.data, ""); OpenPrimary(); - RETURN IsSupportedImageResizeType(wrapped.mimetype) ? GetCachedFSImageURL("", CreateTempUploadedFile(wrapped)->id, [method := "none", fixorientation := TRUE]) - : GetCachedFSFileURL("", CreateTempUploadedFile(wrapped)->id); -} -PUBLIC STRING FUNCTION RPC_GetImgFromRemoteURL(STRING uploadurl) -{ - OpenPrimary(); - - BLOB content; - IF(uploadurl LIKE "data:*") - { - RECORD res := ParseDataURL(uploadurl); - IF(NOT RecordExists(res)) - THROW NEW Exception("Invalid data url"); - - content := res.data; - } - ELSE - { - RECORD unp := UnpackURL(uploadurl); - IF(unp.user != "" OR unp.password != "") //we're not a password tryout service either - THROW NEW Exception("Authority information may not be specified on a URL"); - IF(unp.scheme NOT IN ['http','https']) - THROW NEW Exception("Unsupported URL: " || uploadurl); - IF(NOT unp.isdefaultport) //we're not a port knocker (though we allow it for this WebHare installation) - { - IF (GetDtapStage() != "development") - THROW NEW Exception("Not supporting URLs with a nonstandard port: " || uploadurl); - RECORD unp_primary := UnpackURL(GetPrimaryWebhareInterfaceURL()); - IF (unp.host != unp_primary.host) - THROW NEW Exception("Not supporting URLs with a nonstandard port: " || uploadurl || ". Please run tests on the primary interface host."); - } - - OBJECT browser := NEW WebBrowser; - browser->timeout := 30000; - - IF (NOT browser->GotoWebPage(uploadurl)) - THROW NEW Exception("Cannot retrieve URL " || uploadurl); - - content := browser->content; - } - - - RECORD wrapped := WrapBlob(content,""); - IF(wrapped.width = 0 OR wrapped.height = 0) - THROW NEW Exception("Unsupported image"); - - RETURN GetCachedFSImageURL("", CreateTempUploadedFile(wrapped)->id, [method := "none", fixorientation := TRUE]); + BOOLEAN isimage := IsSupportedImageResizeType(wrapped.mimetype) OR wrapped.mimetype IN ["image/webp", "image/avif"]; + RETURN isimage ? GetCachedFSImageURL("", CreateTempUploadedFile(wrapped)->id, [method := "none", fixorientation := TRUE]) + : GetCachedFSFileURL("", CreateTempUploadedFile(wrapped)->id); } PUBLIC RECORD FUNCTION RPC_RequestBuiltinForm(RECORD submitinfo, STRING filename, STRING formname) diff --git a/whtree/modules/publisher/lib/internal/rtd/service.whlib b/whtree/modules/publisher/lib/internal/rtd/service.whlib index ea68187195..c38fc5fbbc 100644 --- a/whtree/modules/publisher/lib/internal/rtd/service.whlib +++ b/whtree/modules/publisher/lib/internal/rtd/service.whlib @@ -277,9 +277,11 @@ PUBLIC STATIC OBJECTTYPE RichDocumentEditor < RETURN; //TODO: discard external urls though? RECORD img := ParseDataURL(src); - RECORD typeinfo := ScanBlob(img.data); - IF(typeinfo.mimetype NOT LIKE "image/*") //untrusted, discard + RECORD typeinfo := RecordExists(img) ? ScanBlob(img.data) : DEFAULT RECORD; + IF(NOT RecordExists(typeinfo) OR typeinfo.mimetype NOT LIKE "image/*") { //invalid or untrusted, discard imgnode->Dispose(); + RETURN; + } STRING hash := EncodeUFS(GetHashForBlob(img.data, "SHA-1")); IF(NOT RecordExists(SELECT FROM this->embedded WHERE contentid = hash)) diff --git a/whtree/modules/tollium/web/ui/components/richeditor/internal/editorbase.tsx b/whtree/modules/tollium/web/ui/components/richeditor/internal/editorbase.tsx index cb501fc922..53d342ef74 100644 --- a/whtree/modules/tollium/web/ui/components/richeditor/internal/editorbase.tsx +++ b/whtree/modules/tollium/web/ui/components/richeditor/internal/editorbase.tsx @@ -298,6 +298,38 @@ class UndoLock { const defaultimgplaceholder = "data:image/png;base64,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"; +/// Image types we accept when importing pasted images +const pastedImageTypes = ["image/png", "image/jpeg", "image/gif", "image/webp", "image/avif"]; +/// Maximum size for a pasted image +const pastedImageMaxSize = 20 * 1024 * 1024; +/// Timeout for downloading a pasted image (ms) +const pastedImageTimeout = 30_000; + +/** Copy the dimensions of a pasted image from its style to its width/height attributes (if not set), so the upload + * placeholder takes the size the image will have and the dimensions survive paste cleanup + * @param img - Pasted image + */ +export function copyPastedImageStyleDimensions(img: HTMLImageElement) { + for (const dim of ["width", "height"] as const) { + const match = img.style[dim].match(/^(\d+(?:\.\d+)?)px$/); + if (match && !img.hasAttribute(dim)) + img.setAttribute(dim, String(Math.round(parseFloat(match[1])))); + } +} + +/** Decode a data: URL containing an image without using fetch() */ +function decodeImageDataURL(src: string): Blob { + const match = src.match(/^data:([^,]*?)(;base64)?,(.*)$/s); + if (!match) + throw new Error("Invalid data: URL"); + if (match[3].length > pastedImageMaxSize * 4 / 3 + 4) + throw new Error("Image is too large"); + + const type = match[1].split(";")[0].trim().toLowerCase(); + const bytes = match[2] ? Uint8Array.from(atob(decodeURIComponent(match[3])), c => c.charCodeAt(0)) : new TextEncoder().encode(decodeURIComponent(match[3])); + return new Blob([bytes], { type }); +} + ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// @@ -347,6 +379,8 @@ export default class EditorBase extends RTECompBase implements RTEComponent { original_value = ""; /** URLs of images we have already seen and stored on the server */ knownimages: string[] = []; + /** Pasted image files by the object URL we created for them, revoked once imported */ + private pastedfiles = new Map(); language: string; bodydiv; undopos: number; @@ -2009,19 +2043,82 @@ export default class EditorBase extends RTECompBase implements RTEComponent { const replacementpromises = []; for (const img of imgs) { const downloadsrc = img.src; + //The placeholder keeps the pasted width/height so it takes the space the image will take (they're removed once it's uploaded) + copyPastedImageStyleDimensions(img); img.src = this._getImageDownloadURL(); img.classList.add("wh-rtd__img--uploading"); - replacementpromises.push(getFormService().getImgFromRemoteURL(downloadsrc) + replacementpromises.push(this._importPastedImage(downloadsrc) .then(result => this._handleUploadedRemoteImage(img, result)) - .catch(result => this._handleUploadedRemoteImage(img, null))); + .catch(error => { + if (dompack.debugflags.rte) + console.warn(`[rte] Unable to import pasted image ${downloadsrc.substring(0, 100)}`, error); + this._handleUploadedRemoteImage(img, null); + })); } await Promise.all(replacementpromises); + //TODO show a short notice ("n images could not be copied") instead of silently removing images we couldn't import } finally { busylock.release(); } } + /** Download a pasted image in the browser and upload it to the server. We download client-side so the server never + * has to fetch arbitrary URLs. Images that can't be downloaded (eg. a remote server without CORS headers) are rejected + * @param src - The src of the pasted image + * @returns Final (image cache) URL for the image + */ + private async _importPastedImage(src: string): Promise { + const blob = await this._getPastedImageBlob(src); + if (!pastedImageTypes.includes(blob.type)) + throw new Error(`Unsupported image type '${blob.type}'`); + if (blob.size > pastedImageMaxSize) + throw new Error(`Image is too large (${blob.size} bytes)`); + + (await createImageBitmap(blob)).close(); //throws if the image can't be decoded + + return await getFormService().getUploadedFileFinalURL(await getFileAsDataURL(new File([blob], "image", { type: blob.type }))); + } + + /** Get the content of a pasted image. data: and our own blob: URLs are resolved without fetch() as the CSP's connect-src + * (eg. the backend's) generally won't allow fetching those */ + private async _getPastedImageBlob(src: string): Promise { + const pastedfile = this.pastedfiles.get(src); + if (pastedfile) { + this.pastedfiles.delete(src); + URL.revokeObjectURL(src); + return pastedfile; + } + + const url = new URL(src, location.href); + if (url.protocol === "data:") + return decodeImageDataURL(src); + if (!["http:", "https:"].includes(url.protocol)) + throw new Error(`Unsupported image URL protocol '${url.protocol}'`); + + const response = await fetch(url, { + mode: "cors", + credentials: "omit", + referrerPolicy: "strict-origin", //send our origin (some servers/filters require a referrer) but not the full URL + signal: AbortSignal.timeout(pastedImageTimeout) + }); + if (!response.ok) + throw new Error(`Image download failed with status ${response.status}`); + + const contentlength = parseInt(response.headers.get("content-length") || "0"); + if (contentlength > pastedImageMaxSize) + throw new Error(`Image is too large (${contentlength} bytes)`); + + return await response.blob(); + } + + /** Create an object URL for a pasted image file so it will be imported like any other pasted image */ + protected createPastedObjectURL(file: File): string { + const url = URL.createObjectURL(file); + this.pastedfiles.set(url, file); + return url; + } + _handleUploadedRemoteImage(img, properurl) { img.classList.remove("wh-rtd__img--uploading"); if (!properurl) { diff --git a/whtree/modules/tollium/web/ui/components/richeditor/internal/structurededitor.tsx b/whtree/modules/tollium/web/ui/components/richeditor/internal/structurededitor.tsx index 930fd8e164..5a0070d263 100644 --- a/whtree/modules/tollium/web/ui/components/richeditor/internal/structurededitor.tsx +++ b/whtree/modules/tollium/web/ui/components/richeditor/internal/structurededitor.tsx @@ -13,7 +13,7 @@ import ParsedStructure from "./parsedstructure"; import Range from './dom/range'; import * as tableeditor from "./tableeditor"; import * as domlevel from "./domlevel"; -import EditorBase, { type TextFormattingState } from './editorbase'; +import EditorBase, { copyPastedImageStyleDimensions, type TextFormattingState } from './editorbase'; import PasteCleanup from './pastecleanup'; import type { RTEWidget } from "./types"; import { parseEmbeddedObjectNode } from "./support"; @@ -187,6 +187,13 @@ export default class StructuredEditor extends EditorBase { const pastecontent = document.createElement('div'); pastecontent.innerHTML = htmltext; + /* 'Copy image' in a browser offers both the HTML (with the remote image URL) and the image itself. Prefer the + image data, as we can't download the remote URL if its server doesn't send CORS headers */ + const pastedimgs = pastecontent.querySelectorAll("img"); + const imagefiles = Array.from(clipboardData.files as FileList).filter(file => file.type.startsWith("image/")); + if (pastedimgs.length === 1 && imagefiles.length === 1) + pastedimgs[0].src = this.createPastedObjectURL(imagefiles[0]); + this._pasteContent(pastecontent); return; } else if (types.includes("Files")) { @@ -267,6 +274,9 @@ export default class StructuredEditor extends EditorBase { _pasteContent(pastecontent: HTMLDivElement) { const undolock = this.getUndoLock(); + //Image cleanup only keeps width/height attributes, so preserve any dimensions set through style + pastecontent.querySelectorAll("img").forEach(copyPastedImageStyleDimensions); + //console.log('pasteContent preremove', richdebug.getStructuredOuterHTML(this.getBody(), { locator: locator })); const locator = this.removeSelection(); diff --git a/whtree/modules/tollium/web/ui/components/richeditor/richeditor.scss b/whtree/modules/tollium/web/ui/components/richeditor/richeditor.scss index d7e88c0145..9f99e77589 100755 --- a/whtree/modules/tollium/web/ui/components/richeditor/richeditor.scss +++ b/whtree/modules/tollium/web/ui/components/richeditor/richeditor.scss @@ -341,6 +341,12 @@ div.wh-rtd--margins-wide background: rgba(224, 255, 240, .7);*/ } +/* the placeholder may get the dimensions of the image being uploaded, don't stretch the loader */ +.wh-rtd-editor img.wh-rtd__img--uploading +{ + object-fit: none; +} + .wh-rtd-editor .wh-rtd__table { border-collapse: collapse; diff --git a/whtree/modules/webhare_testsuite/tests/tollium/richdoc/teststructured.ts b/whtree/modules/webhare_testsuite/tests/tollium/richdoc/teststructured.ts index 6d836a23f5..5b4817ee77 100644 --- a/whtree/modules/webhare_testsuite/tests/tollium/richdoc/teststructured.ts +++ b/whtree/modules/webhare_testsuite/tests/tollium/richdoc/teststructured.ts @@ -3,6 +3,7 @@ import * as tt from "@mod-webhare_testsuite/js/tolliumtest-wts"; import * as rtetest from "@mod-tollium/js/testframework-rte"; import { encodeString } from "@webhare/std"; import { prepareUpload } from '@webhare/test-frontend'; +import type StructuredEditor from '@mod-tollium/web/ui/components/richeditor/internal/structurededitor'; let instanceref = ""; // instance ref at the frontend side let instanceid = ""; // instance id at the backend site @@ -553,7 +554,61 @@ test.runTests( test.eq(`

test

`, rtetest.getRawHTMLCode(test.getWin())); test.clickTolliumButton("Cancel"); await test.wait("ui"); - } + }, + + "Reject unusable pasted images", + async function () { + const rte = rtetest.getRTE(test.getWin(), 'structured'); + rtetest.setStructuredContent(rte.getEditor() as StructuredEditor, '

"Image(*0*)here"

'); + + const imgpaste = document.createElement("div"); + imgpaste.innerHTML = `not an image")}">` + + `` + + `` + + ``; + rte.getEditor()._pasteContent(imgpaste); //FIXME white box test... + await test.wait(() => !rte.qS(".wh-rtd__img--uploading")); + await test.wait("ui"); + + test.eq(0, rte.qSA("img").length, "None of these images should have been imported"); + }, + + "Paste image from clipboard file", + async function () { + const rte = rtetest.getRTE(test.getWin(), 'structured'); + const editor = rte.getEditor() as StructuredEditor; + + const canvas = document.createElement("canvas"); + canvas.width = 20; + canvas.height = 10; + canvas.getContext("2d")!.fillRect(0, 0, 10, 10); + const webp = await new Promise(resolve => canvas.toBlob(resolve, "image/webp")); + test.eq("image/webp", webp?.type); + + for (const file of [ + new File([await (await fetch("/tollium_todd.res/webhare_testsuite/tollium/logo.png")).blob()], "image.png", { type: "image/png" }), + new File([webp!], "image.webp", { type: "image/webp" }) + ]) { + rtetest.setStructuredContent(editor, '

"Image(*0*)here"

'); + + //'Copy image' offers the image's remote URL and the image itself. The remote URL can't be downloaded, so we must use the file + await rtetest.paste(editor, { + typesdata: { "text/html": `` }, + files: [file] + }); + await test.wait(() => !rte.qS(".wh-rtd__img--uploading")); + await test.wait("ui"); + + test.eq(1, rte.qSA("img").length, `Pasting ${file.type}`); + test.assert(!rte.qR("img").src.startsWith("blob:"), "Image should have been uploaded"); + + test.clickTolliumButton("Edit raw html"); + await test.wait("ui"); + test.assert(rtetest.getRawHTMLCode(test.getWin()).includes('src="cid:')); + test.getCurrentScreen().clickCloser(); + await test.wait("ui"); + } + }, // ADDME: test dirtying via keyboard interaction (selenium!), editing blocks, some mouse interaction stuff ]); From 09da0ac8832d388daa2fe00b6fd5c0207d505582 Mon Sep 17 00:00:00 2001 From: Arnold Hendriks Date: Thu, 12 Nov 2026 16:31:33 +0100 Subject: [PATCH 2/2] Fix RTD paste with clipboardData lacking files, address review comments - Don't crash when clipboardData has no files (fixes testobjects-block/inline) - Percent-decode non-base64 data: URLs directly into bytes instead of through decodeURIComponent, which rejects non-UTF-8 data such as image bytes - Enforce the pasted image size limit while reading the response, as Content-Length may be absent - Release object URLs for clipboard images that weren't imported, and any remaining ones when the editor is destroyed - Move the pasted image helpers to a type-checked utils.ts --- .../richeditor/internal/editorbase.tsx | 91 +++----------- .../richeditor/internal/structurededitor.tsx | 24 ++-- .../components/richeditor/internal/utils.ts | 119 ++++++++++++++++++ .../tests/tollium/richdoc/teststructured.ts | 17 +++ 4 files changed, 171 insertions(+), 80 deletions(-) create mode 100644 whtree/modules/tollium/web/ui/components/richeditor/internal/utils.ts diff --git a/whtree/modules/tollium/web/ui/components/richeditor/internal/editorbase.tsx b/whtree/modules/tollium/web/ui/components/richeditor/internal/editorbase.tsx index 53d342ef74..071b86a35f 100644 --- a/whtree/modules/tollium/web/ui/components/richeditor/internal/editorbase.tsx +++ b/whtree/modules/tollium/web/ui/components/richeditor/internal/editorbase.tsx @@ -30,6 +30,7 @@ import { getFileAsDataURL, requestFile } from '@webhare/upload'; import type { ActionState, GetPlainTextMethod, GetPlainTextOptions, RTEComponent } from './types'; import { RTECompBase } from './rtecompbase'; import { handleCopyEvent } from "./clipboard"; +import { copyPastedImageStyleDimensions, downloadPastedImage, validatePastedImage } from "./utils"; ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // @@ -298,39 +299,6 @@ class UndoLock { const defaultimgplaceholder = "data:image/png;base64,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"; -/// Image types we accept when importing pasted images -const pastedImageTypes = ["image/png", "image/jpeg", "image/gif", "image/webp", "image/avif"]; -/// Maximum size for a pasted image -const pastedImageMaxSize = 20 * 1024 * 1024; -/// Timeout for downloading a pasted image (ms) -const pastedImageTimeout = 30_000; - -/** Copy the dimensions of a pasted image from its style to its width/height attributes (if not set), so the upload - * placeholder takes the size the image will have and the dimensions survive paste cleanup - * @param img - Pasted image - */ -export function copyPastedImageStyleDimensions(img: HTMLImageElement) { - for (const dim of ["width", "height"] as const) { - const match = img.style[dim].match(/^(\d+(?:\.\d+)?)px$/); - if (match && !img.hasAttribute(dim)) - img.setAttribute(dim, String(Math.round(parseFloat(match[1])))); - } -} - -/** Decode a data: URL containing an image without using fetch() */ -function decodeImageDataURL(src: string): Blob { - const match = src.match(/^data:([^,]*?)(;base64)?,(.*)$/s); - if (!match) - throw new Error("Invalid data: URL"); - if (match[3].length > pastedImageMaxSize * 4 / 3 + 4) - throw new Error("Image is too large"); - - const type = match[1].split(";")[0].trim().toLowerCase(); - const bytes = match[2] ? Uint8Array.from(atob(decodeURIComponent(match[3])), c => c.charCodeAt(0)) : new TextEncoder().encode(decodeURIComponent(match[3])); - return new Blob([bytes], { type }); -} - - ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // @@ -1005,6 +973,9 @@ export default class EditorBase extends RTECompBase implements RTEComponent { destroy() { this.setAllChangeObserver(false); //prevent teardown triggering change events + for (const url of this.pastedfiles.keys()) + this.releasePastedObjectURL(url); + this.toolbarnode.remove(); styleloader.unregister(this); @@ -2069,56 +2040,30 @@ export default class EditorBase extends RTECompBase implements RTEComponent { * @returns Final (image cache) URL for the image */ private async _importPastedImage(src: string): Promise { - const blob = await this._getPastedImageBlob(src); - if (!pastedImageTypes.includes(blob.type)) - throw new Error(`Unsupported image type '${blob.type}'`); - if (blob.size > pastedImageMaxSize) - throw new Error(`Image is too large (${blob.size} bytes)`); - - (await createImageBitmap(blob)).close(); //throws if the image can't be decoded - - return await getFormService().getUploadedFileFinalURL(await getFileAsDataURL(new File([blob], "image", { type: blob.type }))); - } - - /** Get the content of a pasted image. data: and our own blob: URLs are resolved without fetch() as the CSP's connect-src - * (eg. the backend's) generally won't allow fetching those */ - private async _getPastedImageBlob(src: string): Promise { const pastedfile = this.pastedfiles.get(src); - if (pastedfile) { - this.pastedfiles.delete(src); - URL.revokeObjectURL(src); - return pastedfile; - } - - const url = new URL(src, location.href); - if (url.protocol === "data:") - return decodeImageDataURL(src); - if (!["http:", "https:"].includes(url.protocol)) - throw new Error(`Unsupported image URL protocol '${url.protocol}'`); - - const response = await fetch(url, { - mode: "cors", - credentials: "omit", - referrerPolicy: "strict-origin", //send our origin (some servers/filters require a referrer) but not the full URL - signal: AbortSignal.timeout(pastedImageTimeout) - }); - if (!response.ok) - throw new Error(`Image download failed with status ${response.status}`); - - const contentlength = parseInt(response.headers.get("content-length") || "0"); - if (contentlength > pastedImageMaxSize) - throw new Error(`Image is too large (${contentlength} bytes)`); + if (pastedfile) + this.releasePastedObjectURL(src); - return await response.blob(); + const blob = pastedfile ?? await downloadPastedImage(src); + await validatePastedImage(blob); + return await getFormService().getUploadedFileFinalURL(await getFileAsDataURL(new File([blob], "image", { type: blob.type }))); } - /** Create an object URL for a pasted image file so it will be imported like any other pasted image */ + /** Create an object URL for a pasted image file so it will be imported like any other pasted image. Release it with + * releasePastedObjectURL once the paste is complete in case it wasn't imported (eg. removed by paste cleanup) + */ protected createPastedObjectURL(file: File): string { const url = URL.createObjectURL(file); this.pastedfiles.set(url, file); return url; } + /** Release an object URL created by createPastedObjectURL if it hasn't been imported */ + protected releasePastedObjectURL(url: string) { + if (this.pastedfiles.delete(url)) + URL.revokeObjectURL(url); + } + _handleUploadedRemoteImage(img, properurl) { img.classList.remove("wh-rtd__img--uploading"); if (!properurl) { diff --git a/whtree/modules/tollium/web/ui/components/richeditor/internal/structurededitor.tsx b/whtree/modules/tollium/web/ui/components/richeditor/internal/structurededitor.tsx index 5a0070d263..0d10996167 100644 --- a/whtree/modules/tollium/web/ui/components/richeditor/internal/structurededitor.tsx +++ b/whtree/modules/tollium/web/ui/components/richeditor/internal/structurededitor.tsx @@ -13,7 +13,8 @@ import ParsedStructure from "./parsedstructure"; import Range from './dom/range'; import * as tableeditor from "./tableeditor"; import * as domlevel from "./domlevel"; -import EditorBase, { copyPastedImageStyleDimensions, type TextFormattingState } from './editorbase'; +import EditorBase, { type TextFormattingState } from './editorbase'; +import { copyPastedImageStyleDimensions } from "./utils"; import PasteCleanup from './pastecleanup'; import type { RTEWidget } from "./types"; import { parseEmbeddedObjectNode } from "./support"; @@ -190,11 +191,15 @@ export default class StructuredEditor extends EditorBase { /* 'Copy image' in a browser offers both the HTML (with the remote image URL) and the image itself. Prefer the image data, as we can't download the remote URL if its server doesn't send CORS headers */ const pastedimgs = pastecontent.querySelectorAll("img"); - const imagefiles = Array.from(clipboardData.files as FileList).filter(file => file.type.startsWith("image/")); - if (pastedimgs.length === 1 && imagefiles.length === 1) - pastedimgs[0].src = this.createPastedObjectURL(imagefiles[0]); - - this._pasteContent(pastecontent); + const imagefiles = Array.from((clipboardData.files ?? []) as FileList).filter(file => file.type.startsWith("image/")); + const objecturl = pastedimgs.length === 1 && imagefiles.length === 1 ? this.createPastedObjectURL(imagefiles[0]) : null; + if (objecturl) + pastedimgs[0].src = objecturl; + + void this._pasteContentAsync(pastecontent).finally(() => { + if (objecturl) //release it if paste cleanup dropped the image (imported images have already released it) + this.releasePastedObjectURL(objecturl); + }); return; } else if (types.includes("Files")) { for (let idx = 0; idx < clipboardData.items.length; ++idx) { @@ -272,6 +277,11 @@ export default class StructuredEditor extends EditorBase { } _pasteContent(pastecontent: HTMLDivElement) { + void this._pasteContentAsync(pastecontent); + } + + /** Paste content, returning a promise that resolves once the paste (but not any image uploads) is complete */ + _pasteContentAsync(pastecontent: HTMLDivElement): Promise { const undolock = this.getUndoLock(); //Image cleanup only keeps width/height attributes, so preserve any dimensions set through style @@ -283,7 +293,7 @@ export default class StructuredEditor extends EditorBase { //console.log('pasteContent postremove', richdebug.getStructuredOuterHTML(this.getBody(), { locator: locator })); - this._pasteContentAt(pastecontent, locator).finally(() => undolock.close()); + return this._pasteContentAt(pastecontent, locator).finally(() => undolock.close()); } //validate all our embedded objects on the server, make sure none are broken (eg by bad pastes) diff --git a/whtree/modules/tollium/web/ui/components/richeditor/internal/utils.ts b/whtree/modules/tollium/web/ui/components/richeditor/internal/utils.ts new file mode 100644 index 0000000000..888fe6fb4f --- /dev/null +++ b/whtree/modules/tollium/web/ui/components/richeditor/internal/utils.ts @@ -0,0 +1,119 @@ +/* Support functions for the rich text editor */ + +/** Image types we accept when importing pasted images */ +export const pastedImageTypes = ["image/png", "image/jpeg", "image/gif", "image/webp", "image/avif"]; +/** Maximum size for a pasted image */ +export const pastedImageMaxSize = 20 * 1024 * 1024; +/** Timeout for downloading a pasted image (ms) */ +export const pastedImageTimeout = 30_000; + +/** Copy the dimensions of a pasted image from its style to its width/height attributes (if not set), so the upload + * placeholder takes the size the image will have and the dimensions survive paste cleanup + * @param img - Pasted image + */ +export function copyPastedImageStyleDimensions(img: HTMLImageElement) { + for (const dim of ["width", "height"] as const) { + const match = img.style[dim].match(/^(\d+(?:\.\d+)?)px$/); + if (match && !img.hasAttribute(dim)) + img.setAttribute(dim, String(Math.round(parseFloat(match[1])))); + } +} + +/** Decode a data: URL containing an image without using fetch() */ +export function decodeImageDataURL(src: string): Blob { + const match = src.match(/^data:([^,]*?)(;base64)?,(.*)$/s); + if (!match) + throw new Error("Invalid data: URL"); + if (match[3].length > pastedImageMaxSize * 4 / 3 + 4) + throw new Error("Image is too large"); + + const type = match[1].split(";")[0].trim().toLowerCase(); + const bytes = match[2] ? Uint8Array.from(atob(match[3].replace(/%([0-9a-f]{2})/gi, (_, hex: string) => String.fromCharCode(parseInt(hex, 16)))), c => c.charCodeAt(0)) + : percentDecodeToBytes(match[3]); + return new Blob([bytes], { type }); +} + +/** Percent-decode a string directly into bytes (decodeURIComponent would require the result to be valid UTF-8) */ +function percentDecodeToBytes(str: string): Uint8Array { + const input = new TextEncoder().encode(str); + const output = new Uint8Array(input.length); + let outpos = 0; + for (let i = 0; i < input.length; ++i) { + const hex = input[i] === 0x25 /* % */ ? String.fromCharCode(input[i + 1], input[i + 2]) : ""; + if (/^[0-9a-f]{2}$/i.test(hex)) { + output[outpos++] = parseInt(hex, 16); + i += 2; + } else { + output[outpos++] = input[i]; + } + } + return output.subarray(0, outpos); +} + +/** Read a response body, aborting as soon as it exceeds our maximum size (Content-Length may be absent or wrong) */ +async function readLimitedResponse(response: Response): Promise { + const type = (response.headers.get("content-type") ?? "").split(";")[0].trim().toLowerCase(); + if (!response.body) + return new Blob([], { type }); + + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + for (; ;) { + const { done, value } = await reader.read(); + if (done) + break; + + total += value.byteLength; + if (total > pastedImageMaxSize) { + void reader.cancel(); + throw new Error(`Image is too large (more than ${pastedImageMaxSize} bytes)`); + } + chunks.push(value); + } + return new Blob(chunks, { type }); +} + +/** Get the content of a pasted image. data: URLs are decoded without fetch() as the CSP's connect-src (eg. the backend's) + * generally won't allow fetching those. Other images are downloaded by the browser, which requires the remote server to + * allow CORS requests + * @param src - The src of the pasted image + * @returns The image data + */ +export async function downloadPastedImage(src: string): Promise { + const url = new URL(src, location.href); + if (url.protocol === "data:") + return decodeImageDataURL(src); + if (!["http:", "https:"].includes(url.protocol)) + throw new Error(`Unsupported image URL protocol '${url.protocol}'`); + + const response = await fetch(url, { + mode: "cors", + credentials: "omit", + referrerPolicy: "strict-origin", //send our origin (some servers/filters require a referrer) but not the full URL + signal: AbortSignal.timeout(pastedImageTimeout) + }); + if (!response.ok) + throw new Error(`Image download failed with status ${response.status}`); + + const contentlength = parseInt(response.headers.get("content-length") || "0"); + if (contentlength > pastedImageMaxSize) { //early exit, readLimitedResponse will enforce the actual limit + void response.body?.cancel(); + throw new Error(`Image is too large (${contentlength} bytes)`); + } + + return await readLimitedResponse(response); +} + +/** Verify a pasted image is of a supported type and size and can actually be decoded + * @param blob - The image data + * @throws If the image is unacceptable + */ +export async function validatePastedImage(blob: Blob): Promise { + if (!pastedImageTypes.includes(blob.type)) + throw new Error(`Unsupported image type '${blob.type}'`); + if (blob.size > pastedImageMaxSize) + throw new Error(`Image is too large (${blob.size} bytes)`); + + (await createImageBitmap(blob)).close(); //throws if the image can't be decoded +} diff --git a/whtree/modules/webhare_testsuite/tests/tollium/richdoc/teststructured.ts b/whtree/modules/webhare_testsuite/tests/tollium/richdoc/teststructured.ts index 5b4817ee77..748a5d1e63 100644 --- a/whtree/modules/webhare_testsuite/tests/tollium/richdoc/teststructured.ts +++ b/whtree/modules/webhare_testsuite/tests/tollium/richdoc/teststructured.ts @@ -610,5 +610,22 @@ test.runTests( } }, + "Paste percent-encoded image data URL", + async function () { + const rte = rtetest.getRTE(test.getWin(), 'structured'); + rtetest.setStructuredContent(rte.getEditor() as StructuredEditor, '

"Image(*0*)here"

'); + + //a non-base64 data URL containing bytes that aren't valid UTF-8 (eg. the PNG signature's %89) + const logo = new Uint8Array(await (await fetch("/tollium_todd.res/webhare_testsuite/tollium/logo.png")).arrayBuffer()); + const imgpaste = document.createElement("div"); + imgpaste.innerHTML = ``; + rte.getEditor()._pasteContent(imgpaste); //FIXME white box test... + await test.wait(() => !rte.qS(".wh-rtd__img--uploading")); + await test.wait("ui"); + + test.eq(1, rte.qSA("img").length); + test.assert(!rte.qR("img").src.startsWith("data:"), "Image should have been uploaded"); + }, + // ADDME: test dirtying via keyboard interaction (selenium!), editing blocks, some mouse interaction stuff ]);