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..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"; ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // @@ -299,7 +300,6 @@ class UndoLock { const defaultimgplaceholder = "data:image/png;base64,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"; - ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // // Undo stuff @@ -347,6 +347,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; @@ -971,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); @@ -2009,19 +2014,56 @@ 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 pastedfile = this.pastedfiles.get(src); + if (pastedfile) + this.releasePastedObjectURL(src); + + 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. 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 930fd8e164..0d10996167 100644 --- a/whtree/modules/tollium/web/ui/components/richeditor/internal/structurededitor.tsx +++ b/whtree/modules/tollium/web/ui/components/richeditor/internal/structurededitor.tsx @@ -14,6 +14,7 @@ import Range from './dom/range'; import * as tableeditor from "./tableeditor"; import * as domlevel from "./domlevel"; import EditorBase, { type TextFormattingState } from './editorbase'; +import { copyPastedImageStyleDimensions } from "./utils"; import PasteCleanup from './pastecleanup'; import type { RTEWidget } from "./types"; import { parseEmbeddedObjectNode } from "./support"; @@ -187,7 +188,18 @@ export default class StructuredEditor extends EditorBase { const pastecontent = document.createElement('div'); pastecontent.innerHTML = htmltext; - this._pasteContent(pastecontent); + /* '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/")); + 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) { @@ -265,15 +277,23 @@ 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 + pastecontent.querySelectorAll("img").forEach(copyPastedImageStyleDimensions); + //console.log('pasteContent preremove', richdebug.getStructuredOuterHTML(this.getBody(), { locator: locator })); const locator = this.removeSelection(); //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/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..748a5d1e63 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,78 @@ 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"); + } + }, + + "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 ]);