Tiptap Media Picker: ImageSharp HMAC support (#19333)

* Tiptap Media Picker: Uses imaging repository

to get the resized URLs from the server.
This adds support for ImageSharp's HMAC security.

* Update src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/toolbar/media-picker.tiptap-toolbar-api.ts

* feat: uses the actual configured image SIZE for both width and height as documented

this also deprecates the public maxWidth property

* feat: verifies that a resized image exists and use the size of that

* feat: transfer the image size calculation to the drag'n'drop uploader and ensures that imageSize() accounts for maxHeight as well

* docs: adds comment to explain why it calculates the image size

* test: adds cases for imageSize

---------

Co-authored-by: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com>
This commit is contained in:
Lee Kelleher
2025-05-16 11:17:49 +01:00
committed by GitHub
parent df56f1985b
commit 28fc81756a
4 changed files with 139 additions and 24 deletions

View File

@@ -1,43 +1,39 @@
/**
* Get the dimensions of an image from a URL.
* @param {string} url The URL of the image. It can be a local file (blob url) or a remote file.
* @param {{maxWidth?: number}} opts Options for the image size.
* @param {number} opts.maxWidth The maximum width of the image. If the image is wider than this, it will be scaled down to this width while keeping the aspect ratio.
* @returns {Promise<{width: number, height: number, naturalWidth: number, naturalHeight: number}>} The width and height of the image as downloaded from the URL. The width and height can differ from the natural numbers if maxImageWidth is given.
* @param {{maxWidth?: number, maxHeight?: number}} opts Options for the image size.
* @param {number} opts.maxWidth The maximum width of the image.
* @param {number} opts.maxHeight The maximum height of the image.
* @returns {Promise<{width: number, height: number, naturalWidth: number, naturalHeight: number}>} The dimensions of the image.
*/
export function imageSize(
url: string,
opts?: { maxWidth?: number },
opts?: { maxWidth?: number; maxHeight?: number },
): Promise<{ width: number; height: number; naturalWidth: number; naturalHeight: number }> {
const img = new Image();
const promise = new Promise<{ width: number; height: number; naturalWidth: number; naturalHeight: number }>(
(resolve, reject) => {
img.onload = () => {
// Natural size is the actual image size regardless of rendering.
// The 'normal' `width`/`height` are for the **rendered** size.
const naturalWidth = img.naturalWidth;
const naturalHeight = img.naturalHeight;
let width = naturalWidth;
let height = naturalHeight;
if (opts?.maxWidth && opts.maxWidth > 0 && width > opts?.maxWidth) {
const ratio = opts.maxWidth / naturalWidth;
width = opts.maxWidth;
if ((opts?.maxWidth && opts.maxWidth > 0) || (opts?.maxHeight && opts.maxHeight > 0)) {
const widthRatio = opts?.maxWidth ? opts.maxWidth / naturalWidth : 1;
const heightRatio = opts?.maxHeight ? opts.maxHeight / naturalHeight : 1;
const ratio = Math.min(widthRatio, heightRatio, 1); // Never upscale
width = Math.round(naturalWidth * ratio);
height = Math.round(naturalHeight * ratio);
}
// Resolve promise with the width and height
resolve({ width, height, naturalWidth, naturalHeight });
};
// Reject promise on error
img.onerror = reject;
},
);
// Setting the source makes it start downloading and eventually call `onload`
img.src = url;
return promise;
}

View File

@@ -0,0 +1,83 @@
import { imageSize } from './image-size.function';
import { expect } from '@open-wc/testing';
describe('imageSize', () => {
let OriginalImage: typeof Image;
before(() => {
OriginalImage = window.Image;
});
after(() => {
window.Image = OriginalImage;
});
function mockImage(naturalWidth: number, naturalHeight: number) {
class MockImage {
naturalWidth = naturalWidth;
naturalHeight = naturalHeight;
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
set src(_url: string) {
setTimeout(() => this.onload && this.onload(), 0);
}
}
// @ts-ignore
window.Image = MockImage;
}
it('returns natural size if no maxWidth or maxHeight is given', async () => {
mockImage(800, 600);
const result = await imageSize('fake-url');
expect(result).to.deep.equal({
width: 800,
height: 600,
naturalWidth: 800,
naturalHeight: 600,
});
});
it('scales down to maxWidth and maxHeight, ratio locked', async () => {
mockImage(800, 600);
const result = await imageSize('fake-url', { maxWidth: 400, maxHeight: 300 });
expect(result).to.deep.equal({
width: 400,
height: 300,
naturalWidth: 800,
naturalHeight: 600,
});
});
it('never upscales if maxWidth/maxHeight are larger than natural', async () => {
mockImage(800, 600);
const result = await imageSize('fake-url', { maxWidth: 1000, maxHeight: 1000 });
expect(result).to.deep.equal({
width: 800,
height: 600,
naturalWidth: 800,
naturalHeight: 600,
});
});
it('scales down by width if width is limiting', async () => {
mockImage(800, 600);
const result = await imageSize('fake-url', { maxWidth: 400 });
expect(result).to.deep.equal({
width: 400,
height: 300,
naturalWidth: 800,
naturalHeight: 600,
});
});
it('scales down by height if height is limiting', async () => {
mockImage(800, 600);
const result = await imageSize('fake-url', { maxHeight: 150 });
expect(result).to.deep.equal({
width: 200,
height: 150,
naturalWidth: 800,
naturalHeight: 600,
});
});
});

View File

@@ -15,13 +15,19 @@ export default class UmbTiptapMediaUploadExtensionApi extends UmbTiptapExtension
#configuration?: UmbPropertyEditorConfigCollection;
/**
* @returns {number} The maximum width of uploaded images
* @returns {number} The configured maximum allowed image size
*/
get maxWidth(): number {
get maxImageSize(): number {
const maxImageSize = parseInt(this.#configuration?.getValueByAlias('maxImageSize') ?? '', 10);
return isNaN(maxImageSize) ? 500 : maxImageSize;
}
/**
* @deprecated Use `maxImageSize` instead.
* @returns {number} The maximum width of uploaded images
*/
maxWidth = this.maxImageSize;
/**
* @returns {Array<string>} The allowed mime types for uploads
*/
@@ -98,7 +104,7 @@ export default class UmbTiptapMediaUploadExtensionApi extends UmbTiptapExtension
this.dispatchEvent(new CustomEvent('rte.file.uploading', { composed: true, bubbles: true, detail: fileModels }));
const uploads = await this.#manager.upload(fileModels);
const maxImageSize = this.maxWidth;
const maxImageSize = this.maxImageSize;
uploads.forEach(async (upload) => {
if (upload.status !== TemporaryFileStatus.SUCCESS) {
@@ -112,7 +118,11 @@ export default class UmbTiptapMediaUploadExtensionApi extends UmbTiptapExtension
}
const blobUrl = URL.createObjectURL(upload.file);
const { width, height } = await imageSize(blobUrl, { maxWidth: maxImageSize });
// Get the image dimensions - this essentially simulates what the server would do
// when it resizes the image. The server will return the resized image URL.
// We need to use the blob URL here, as the server will not be able to access the local file.
const { width, height } = await imageSize(blobUrl, { maxWidth: maxImageSize, maxHeight: maxImageSize });
editor
.chain()

View File

@@ -1,6 +1,7 @@
import { UmbTiptapToolbarElementApiBase } from '../base.js';
import { getGuidFromUdi, getProcessedImageUrl, imageSize } from '@umbraco-cms/backoffice/utils';
import { getGuidFromUdi, imageSize } from '@umbraco-cms/backoffice/utils';
import { ImageCropModeModel } from '@umbraco-cms/backoffice/external/backend-api';
import { UmbImagingRepository } from '@umbraco-cms/backoffice/imaging';
import { UMB_MEDIA_CAPTION_ALT_TEXT_MODAL, UMB_MEDIA_PICKER_MODAL } from '@umbraco-cms/backoffice/media';
import { UMB_MODAL_MANAGER_CONTEXT } from '@umbraco-cms/backoffice/modal';
import type { Editor } from '@umbraco-cms/backoffice/external/tiptap';
@@ -8,16 +9,24 @@ import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import type { UmbMediaCaptionAltTextModalValue } from '@umbraco-cms/backoffice/media';
export default class UmbTiptapToolbarMediaPickerToolbarExtensionApi extends UmbTiptapToolbarElementApiBase {
#imagingRepository = new UmbImagingRepository(this);
#modalManager?: typeof UMB_MODAL_MANAGER_CONTEXT.TYPE;
/**
* @returns {number} The maximum width of uploaded images
* @returns {number} The configured maximum allowed image size
*/
get maxWidth(): number {
get maxImageSize(): number {
const maxImageSize = parseInt(this.configuration?.getValueByAlias('maxImageSize') ?? '', 10);
return isNaN(maxImageSize) ? 500 : maxImageSize;
}
/**
* @deprecated Use `maxImageSize` instead.
* @returns {number} The maximum width of uploaded images
*/
maxWidth = this.maxImageSize;
constructor(host: UmbControllerHost) {
super(host);
@@ -98,12 +107,29 @@ export default class UmbTiptapToolbarMediaPickerToolbarExtensionApi extends UmbT
async #insertInEditor(editor: Editor, mediaUnique: string, media: UmbMediaCaptionAltTextModalValue) {
if (!media?.url) return;
const { width, height } = await imageSize(media.url, { maxWidth: this.maxWidth });
const src = await getProcessedImageUrl(media.url, { width, height, mode: ImageCropModeModel.MAX });
const maxImageSize = this.maxImageSize;
// Get the resized image URL
const { data } = await this.#imagingRepository.requestResizedItems([mediaUnique], {
width: maxImageSize,
height: maxImageSize,
mode: ImageCropModeModel.MAX,
});
if (!data?.length || !data[0]?.url) {
console.error('No data returned from imaging repository');
return;
}
// Set the media URL to the first item in the data array
const src = data[0].url;
// Fetch the actual image dimensions
const { width, height } = await imageSize(src);
const img = {
alt: media.altText,
src,
alt: media.altText,
'data-udi': `umb://media/${mediaUnique.replace(/-/g, '')}`,
width: width.toString(),
height: height.toString(),