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:
Michael Telatynski
2025-12-01 16:00:09 +00:00
committed by GitHub
parent afa186cdf4
commit 45ab536737
10 changed files with 389 additions and 22 deletions

View File

@@ -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.

View File

@@ -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}
/>

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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)),
)
);
};

View File

@@ -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;