Fix handling of SVGs (#31359)
* Fix handling of SVGs 1. Ensure we always include thumbnails for them 2. Show `m.file` handler if we cannot render the SVG 3. When opening ImageView use svg thumbnail if the SVG cannot be rendered Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Fix UploadConfirmDialog choking under React devmode Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Fix test Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Add tests Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --------- Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
committed by
GitHub
parent
afa186cdf4
commit
45ab536737
@@ -140,7 +140,7 @@ const IMAGE_THUMBNAIL_MIN_REDUCTION_PERCENT = 0.1; // 10%
|
||||
// and videos tend to be much larger.
|
||||
|
||||
// Image mime types for which to always include a thumbnail for even if it is larger than the input for wider support.
|
||||
const ALWAYS_INCLUDE_THUMBNAIL = ["image/avif", "image/webp"];
|
||||
const ALWAYS_INCLUDE_THUMBNAIL = ["image/avif", "image/webp", "image/svg+xml"];
|
||||
|
||||
/**
|
||||
* Read the metadata for an image file and create and upload a thumbnail of the image.
|
||||
|
||||
@@ -11,7 +11,6 @@ import React, { type JSX } from "react";
|
||||
import { FilesIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { getBlobSafeMimeType } from "../../../utils/blobs";
|
||||
import BaseDialog from "./BaseDialog";
|
||||
import DialogButtons from "../elements/DialogButtons";
|
||||
import { fileSize } from "../../../utils/FileUtils";
|
||||
@@ -23,10 +22,11 @@ interface IProps {
|
||||
onFinished: (uploadConfirmed: boolean, uploadAll?: boolean) => void;
|
||||
}
|
||||
|
||||
export default class UploadConfirmDialog extends React.Component<IProps> {
|
||||
private readonly objectUrl: string;
|
||||
private readonly mimeType: string;
|
||||
interface IState {
|
||||
objectUrl?: string;
|
||||
}
|
||||
|
||||
export default class UploadConfirmDialog extends React.Component<IProps, IState> {
|
||||
public static defaultProps: Partial<IProps> = {
|
||||
totalFiles: 1,
|
||||
currentIndex: 0,
|
||||
@@ -35,15 +35,22 @@ export default class UploadConfirmDialog extends React.Component<IProps> {
|
||||
public constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
// Create a fresh `Blob` for previewing (even though `File` already is
|
||||
// one) so we can adjust the MIME type if needed.
|
||||
this.mimeType = getBlobSafeMimeType(props.file.type);
|
||||
const blob = new Blob([props.file], { type: this.mimeType });
|
||||
this.objectUrl = URL.createObjectURL(blob);
|
||||
this.state = {};
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
if (this.props.file.type.startsWith("image/") || this.props.file.type.startsWith("video/")) {
|
||||
this.setState({
|
||||
// We do not filter the mimetype using getBlobSafeMimeType here as if the user is uploading the file
|
||||
// themselves they should be trusting it enough to open/load it, and it will be rendered into a hidden
|
||||
// canvas for thumbnail generation anyway
|
||||
objectUrl: URL.createObjectURL(this.props.file),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
if (this.objectUrl) URL.revokeObjectURL(this.objectUrl);
|
||||
if (this.state.objectUrl) URL.revokeObjectURL(this.state.objectUrl);
|
||||
}
|
||||
|
||||
private onCancelClick = (): void => {
|
||||
@@ -70,17 +77,23 @@ export default class UploadConfirmDialog extends React.Component<IProps> {
|
||||
}
|
||||
|
||||
const fileId = `mx-uploadconfirmdialog-${this.props.file.name}`;
|
||||
const mimeType = this.props.file.type;
|
||||
|
||||
let preview: JSX.Element | undefined;
|
||||
let placeholder: JSX.Element | undefined;
|
||||
if (this.mimeType.startsWith("image/")) {
|
||||
if (mimeType.startsWith("image/")) {
|
||||
preview = (
|
||||
<img className="mx_UploadConfirmDialog_imagePreview" src={this.objectUrl} aria-labelledby={fileId} />
|
||||
<img
|
||||
className="mx_UploadConfirmDialog_imagePreview"
|
||||
src={this.state.objectUrl}
|
||||
aria-labelledby={fileId}
|
||||
/>
|
||||
);
|
||||
} else if (this.mimeType.startsWith("video/")) {
|
||||
} else if (mimeType.startsWith("video/")) {
|
||||
preview = (
|
||||
<video
|
||||
className="mx_UploadConfirmDialog_imagePreview"
|
||||
src={this.objectUrl}
|
||||
src={this.state.objectUrl}
|
||||
playsInline
|
||||
controls={false}
|
||||
/>
|
||||
|
||||
@@ -35,6 +35,7 @@ import MediaProcessingError from "./shared/MediaProcessingError";
|
||||
import { DecryptError, DownloadError } from "../../../utils/DecryptFile";
|
||||
import { HiddenMediaPlaceholder } from "./HiddenMediaPlaceholder";
|
||||
import { useMediaVisible } from "../../../hooks/useMediaVisible";
|
||||
import { isMimeTypeAllowed } from "../../../utils/blobs.ts";
|
||||
|
||||
enum Placeholder {
|
||||
NoImage,
|
||||
@@ -101,7 +102,16 @@ export class MImageBodyInner extends React.Component<IProps, IState> {
|
||||
}
|
||||
|
||||
const content = this.props.mxEvent.getContent<ImageContent>();
|
||||
const httpUrl = this.state.contentUrl;
|
||||
|
||||
let httpUrl = this.state.contentUrl;
|
||||
if (
|
||||
this.props.mediaEventHelper?.media.isEncrypted &&
|
||||
!isMimeTypeAllowed(this.props.mediaEventHelper.sourceBlob.cachedValue?.type ?? "")
|
||||
) {
|
||||
// contentUrl will be a blob URI mime-type=application/octet-stream so fall back to the thumbUrl instead
|
||||
httpUrl = this.state.thumbUrl;
|
||||
}
|
||||
|
||||
if (!httpUrl) return;
|
||||
const params: Omit<ComponentProps<typeof ImageView>, "onFinished"> = {
|
||||
src: httpUrl,
|
||||
@@ -647,6 +657,15 @@ export class MImageBodyInner extends React.Component<IProps, IState> {
|
||||
public render(): React.ReactNode {
|
||||
const content = this.props.mxEvent.getContent<ImageContent>();
|
||||
|
||||
// Fall back to MFileBody if we are unable to render this image e.g. in the case of a blob svg
|
||||
if (
|
||||
this.props.mediaEventHelper?.media.isEncrypted &&
|
||||
!isMimeTypeAllowed(content.info?.mimetype ?? "") &&
|
||||
!content.info?.thumbnail_info
|
||||
) {
|
||||
return <MFileBody {...this.props} />;
|
||||
}
|
||||
|
||||
if (this.state.error) {
|
||||
let errorText = _t("timeline|m.image|error");
|
||||
if (this.state.error instanceof DecryptError) {
|
||||
|
||||
@@ -64,8 +64,7 @@ export async function decryptFile(file?: EncryptedFile, info?: MediaEventInfo):
|
||||
// they introduce XSS attacks if the Blob URI is viewed directly in the
|
||||
// browser (e.g. by copying the URI into a new tab or window.)
|
||||
// See warning at top of file.
|
||||
let mimetype = info?.mimetype ? info.mimetype.split(";")[0].trim() : "";
|
||||
mimetype = getBlobSafeMimeType(mimetype);
|
||||
const mimetype = getBlobSafeMimeType(info?.mimetype?.split(";")[0].trim() ?? "");
|
||||
|
||||
return new Blob([dataArray], { type: mimetype });
|
||||
} catch (e) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { LazyValue } from "./LazyValue";
|
||||
import { type Media, mediaFromContent } from "../customisations/Media";
|
||||
import { decryptFile } from "./DecryptFile";
|
||||
import { type IDestroyable } from "./IDestroyable";
|
||||
import { getBlobSafeMimeType } from "./blobs.ts";
|
||||
|
||||
// TODO: We should consider caching the blobs. https://github.com/vector-im/element-web/issues/17192
|
||||
|
||||
@@ -82,7 +83,7 @@ export class MediaEventHelper implements IDestroyable {
|
||||
.downloadSource()
|
||||
.then((r) => r.blob())
|
||||
// Set the mime type from the event info on the blob
|
||||
.then((blob) => blob.slice(0, blob.size, content.info?.mimetype ?? blob.type))
|
||||
.then((blob) => blob.slice(0, blob.size, getBlobSafeMimeType(content.info?.mimetype ?? blob.type)))
|
||||
);
|
||||
};
|
||||
|
||||
@@ -107,7 +108,9 @@ export class MediaEventHelper implements IDestroyable {
|
||||
fetch(thumbnailHttp)
|
||||
.then((r) => r.blob())
|
||||
// Set the mime type from the event info on the blob
|
||||
.then((blob) => blob.slice(0, blob.size, content.info?.thumbnail_info?.mimetype ?? blob.type))
|
||||
.then((blob) =>
|
||||
blob.slice(0, blob.size, getBlobSafeMimeType(content.info?.thumbnail_info?.mimetype ?? blob.type)),
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -66,8 +66,20 @@ const ALLOWED_BLOB_MIMETYPES = [
|
||||
"audio/x-flac",
|
||||
];
|
||||
|
||||
/**
|
||||
* Checks whether the given mime type is in the allowed mimetype list
|
||||
* @param mimetype - the mimetype to check
|
||||
*/
|
||||
export function isMimeTypeAllowed(mimetype: string): boolean {
|
||||
return ALLOWED_BLOB_MIMETYPES.includes(mimetype);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the input mimetype if it is allowed, `application/octet-stream` otherwise
|
||||
* @param mimetype - the mimetype to check
|
||||
*/
|
||||
export function getBlobSafeMimeType(mimetype: string): string {
|
||||
if (!ALLOWED_BLOB_MIMETYPES.includes(mimetype)) {
|
||||
if (!isMimeTypeAllowed(mimetype)) {
|
||||
return "application/octet-stream";
|
||||
}
|
||||
return mimetype;
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
Copyright 2025 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { render } from "jest-matrix-react";
|
||||
import { secureRandomString } from "matrix-js-sdk/src/randomstring";
|
||||
|
||||
import UploadConfirmDialog from "../../../../../src/components/views/dialogs/UploadConfirmDialog.tsx";
|
||||
|
||||
describe("<UploadConfirmDialog />", () => {
|
||||
it("should display image preview", () => {
|
||||
const url = "blob:null/1234-5678-9101-1121";
|
||||
jest.spyOn(URL, "createObjectURL").mockReturnValue(url);
|
||||
|
||||
const file = new File([secureRandomString(1024 * 124)], "image.png", { type: "image/png" });
|
||||
const { asFragment, getByRole } = render(
|
||||
<UploadConfirmDialog file={file} currentIndex={0} totalFiles={1} onFinished={jest.fn()} />,
|
||||
);
|
||||
|
||||
expect(getByRole("img")).toHaveAttribute("src", url);
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
|
||||
exports[`<UploadConfirmDialog /> should display image preview 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
data-focus-guard="true"
|
||||
style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"
|
||||
tabindex="0"
|
||||
/>
|
||||
<div
|
||||
aria-describedby="mx_Dialog_content"
|
||||
aria-labelledby="mx_BaseDialog_title"
|
||||
class="mx_UploadConfirmDialog"
|
||||
data-focus-lock-disabled="false"
|
||||
role="dialog"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="mx_Dialog_header"
|
||||
>
|
||||
<h1
|
||||
class="mx_Heading_h3 mx_Dialog_title"
|
||||
id="mx_BaseDialog_title"
|
||||
>
|
||||
Upload files
|
||||
</h1>
|
||||
</div>
|
||||
<div
|
||||
id="mx_Dialog_content"
|
||||
>
|
||||
<div
|
||||
class="mx_UploadConfirmDialog_previewOuter"
|
||||
>
|
||||
<div
|
||||
class="mx_UploadConfirmDialog_previewInner"
|
||||
>
|
||||
<div>
|
||||
<img
|
||||
aria-labelledby="mx-uploadconfirmdialog-image.png"
|
||||
class="mx_UploadConfirmDialog_imagePreview"
|
||||
src="blob:null/1234-5678-9101-1121"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
id="mx-uploadconfirmdialog-image.png"
|
||||
>
|
||||
image.png (124 KB)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mx_Dialog_buttons"
|
||||
>
|
||||
<span
|
||||
class="mx_Dialog_buttons_row"
|
||||
>
|
||||
<button
|
||||
class="mx_Dialog_primary"
|
||||
data-testid="dialog-primary-button"
|
||||
type="button"
|
||||
>
|
||||
Upload
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
aria-label="Close dialog"
|
||||
class="mx_AccessibleButton mx_Dialog_cancelButton"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
data-focus-guard="true"
|
||||
style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"
|
||||
tabindex="0"
|
||||
/>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { fireEvent, render, screen, waitFor, waitForElementToBeRemoved } from "jest-matrix-react";
|
||||
import { fireEvent, render, screen, waitFor, waitForElementToBeRemoved, within } from "jest-matrix-react";
|
||||
import { EventType, getHttpUriForMxc, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
import encrypt from "matrix-encrypt-attachment";
|
||||
@@ -70,6 +70,7 @@ describe("<MImageBody/>", () => {
|
||||
info: {
|
||||
w: 40,
|
||||
h: 50,
|
||||
mimetype: "image/png",
|
||||
},
|
||||
file: {
|
||||
url: "mxc://server/encrypted-image",
|
||||
@@ -304,4 +305,76 @@ describe("<MImageBody/>", () => {
|
||||
|
||||
expect(container.querySelector(".mx_MImageBody_banner")).toHaveTextContent("...alt for a test image");
|
||||
});
|
||||
|
||||
it("should render MFileBody for svg with no thumbnail", async () => {
|
||||
const event = new MatrixEvent({
|
||||
room_id: "!room:server",
|
||||
sender: senderUserId,
|
||||
type: EventType.RoomMessage,
|
||||
content: {
|
||||
info: {
|
||||
w: 40,
|
||||
h: 50,
|
||||
mimetype: "image/svg+xml",
|
||||
},
|
||||
file: {
|
||||
url: "mxc://server/encrypted-svg",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { container, asFragment } = render(
|
||||
<MImageBody {...props} mxEvent={event} mediaEventHelper={new MediaEventHelper(event)} />,
|
||||
withClientContextRenderOptions(cli),
|
||||
);
|
||||
|
||||
expect(container.querySelector(".mx_MFileBody")).toHaveTextContent("Attachment");
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should open ImageView using thumbnail for encrypted svg", async () => {
|
||||
const url = "https://server/_matrix/media/v3/download/server/encrypted-svg";
|
||||
fetchMock.getOnce(url, { status: 200 });
|
||||
const thumbUrl = "https://server/_matrix/media/v3/download/server/svg-thumbnail";
|
||||
fetchMock.getOnce(thumbUrl, { status: 200 });
|
||||
|
||||
const event = new MatrixEvent({
|
||||
room_id: "!room:server",
|
||||
sender: senderUserId,
|
||||
type: EventType.RoomMessage,
|
||||
origin_server_ts: 1234567890,
|
||||
content: {
|
||||
info: {
|
||||
w: 40,
|
||||
h: 50,
|
||||
mimetype: "image/svg+xml",
|
||||
thumbnail_file: {
|
||||
url: "mxc://server/svg-thumbnail",
|
||||
},
|
||||
thumbnail_info: { mimetype: "image/png" },
|
||||
},
|
||||
file: {
|
||||
url: "mxc://server/encrypted-svg",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const mediaEventHelper = new MediaEventHelper(event);
|
||||
mediaEventHelper.thumbnailUrl["prom"] = Promise.resolve(thumbUrl);
|
||||
mediaEventHelper.sourceUrl["prom"] = Promise.resolve(url);
|
||||
|
||||
const { findByRole } = render(
|
||||
<MImageBody {...props} mxEvent={event} mediaEventHelper={mediaEventHelper} />,
|
||||
withClientContextRenderOptions(cli),
|
||||
);
|
||||
|
||||
fireEvent.click(await findByRole("link"));
|
||||
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await expect(within(dialog).findByRole("img")).resolves.toHaveAttribute(
|
||||
"src",
|
||||
"https://server/_matrix/media/v3/download/server/svg-thumbnail",
|
||||
);
|
||||
expect(dialog).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,6 +47,147 @@ exports[`<MImageBody/> should generate a thumbnail if one isn't included for ani
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<MImageBody/> should open ImageView using thumbnail for encrypted svg 1`] = `
|
||||
<div
|
||||
aria-label="Image view"
|
||||
class="mx_ImageView"
|
||||
data-focus-lock-disabled="false"
|
||||
role="dialog"
|
||||
>
|
||||
<div
|
||||
class="mx_ImageView_panel"
|
||||
>
|
||||
<div
|
||||
class="mx_ImageView_info_wrapper"
|
||||
>
|
||||
<button
|
||||
aria-label="Profile picture"
|
||||
aria-live="off"
|
||||
class="_avatar_1qbcf_8 mx_BaseAvatar mx_Dialog_nonDialogButton _avatar-imageless_1qbcf_52"
|
||||
data-color="2"
|
||||
data-testid="avatar-img"
|
||||
data-type="round"
|
||||
role="button"
|
||||
style="--cpd-avatar-size: 32px;"
|
||||
>
|
||||
o
|
||||
</button>
|
||||
<div
|
||||
class="mx_ImageView_info"
|
||||
>
|
||||
<div
|
||||
class="mx_ImageView_info_sender"
|
||||
>
|
||||
@other_use:server
|
||||
</div>
|
||||
<a
|
||||
aria-live="off"
|
||||
class="mx_MessageTimestamp"
|
||||
href="https://matrix.to/#/!room:server/undefined"
|
||||
>
|
||||
Thu, Jan 15, 1970, 06:56
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mx_ImageView_title"
|
||||
>
|
||||
Image
|
||||
</div>
|
||||
<div
|
||||
class="mx_ImageView_toolbar"
|
||||
>
|
||||
<div
|
||||
aria-label="Zoom out"
|
||||
class="mx_AccessibleButton mx_ImageView_button mx_ImageView_button_zoomOut"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
/>
|
||||
<div
|
||||
aria-label="Zoom in"
|
||||
class="mx_AccessibleButton mx_ImageView_button mx_ImageView_button_zoomIn"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
/>
|
||||
<div
|
||||
aria-label="Rotate Left"
|
||||
class="mx_AccessibleButton mx_ImageView_button mx_ImageView_button_rotateCCW"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
/>
|
||||
<div
|
||||
aria-label="Rotate Right"
|
||||
class="mx_AccessibleButton mx_ImageView_button mx_ImageView_button_rotateCW"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
/>
|
||||
<div
|
||||
aria-label="Download"
|
||||
class="mx_AccessibleButton mx_ImageView_button mx_ImageView_button_download"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
/>
|
||||
<div
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-label="Options"
|
||||
class="mx_AccessibleButton mx_ImageView_button mx_ImageView_button_more"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
/>
|
||||
<div
|
||||
aria-label="Close"
|
||||
class="mx_AccessibleButton mx_ImageView_button mx_ImageView_button_close"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mx_ImageView_image_wrapper"
|
||||
>
|
||||
<img
|
||||
alt="Attachment"
|
||||
class="mx_ImageView_image "
|
||||
draggable="true"
|
||||
src="https://server/_matrix/media/v3/download/server/svg-thumbnail"
|
||||
style="transform: translateX(-512px)
|
||||
translateY(NaNpx)
|
||||
scale(0)
|
||||
rotate(0deg); cursor: zoom-out;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<MImageBody/> should render MFileBody for svg with no thumbnail 1`] = `
|
||||
<DocumentFragment>
|
||||
<span
|
||||
class="mx_MFileBody"
|
||||
>
|
||||
<div
|
||||
class="mx_AccessibleButton mx_MediaBody mx_MFileBody_info"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<span
|
||||
class="mx_MFileBody_info_icon"
|
||||
/>
|
||||
<span
|
||||
aria-labelledby="_r_0_"
|
||||
tabindex="0"
|
||||
>
|
||||
<span
|
||||
class="mx_MFileBody_info_filename"
|
||||
>
|
||||
Attachment
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</span>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`<MImageBody/> should show a thumbnail while image is being downloaded 1`] = `
|
||||
<div>
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user