Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions whtree/jssdk/forms/src/formservice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,6 @@ export interface PublisherFormService {

formValidateEmail(target: RPCFormTarget & { field: string }, emailaddress: string): Promise<EmailValidationResult>;

/** Get an image from a remote URL */
getImgFromRemoteURL(imageurl: string): Promise<string>;

/** Get the final image URL to use */
getUploadedFileFinalURL(uploadurl: string): Promise<string>;

Expand Down
52 changes: 3 additions & 49 deletions whtree/modules/publisher/lib/internal/forms/rpc.whlib
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,13 @@ 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";

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";
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 4 additions & 2 deletions whtree/modules/publisher/lib/internal/rtd/service.whlib
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//
Expand Down Expand Up @@ -299,7 +300,6 @@ class UndoLock {
const defaultimgplaceholder = "data:image/png;base64,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";



/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//
// Undo stuff
Expand Down Expand Up @@ -347,6 +347,8 @@ export default class EditorBase extends RTECompBase implements RTEComponent {
original_value = "<neverset>";
/** 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<string, File>();
language: string;
bodydiv;
undopos: number;
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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<string> {
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);
Comment on lines +2056 to +2057
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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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<void> {
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)
Expand Down
119 changes: 119 additions & 0 deletions whtree/modules/tollium/web/ui/components/richeditor/internal/utils.ts
Original file line number Diff line number Diff line change
@@ -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<ArrayBuffer> {
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<Blob> {
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<ArrayBuffer>[] = [];
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<Blob> {
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<void> {
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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading