/* * Copyright 2026 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 { type MouseEvent, type RefObject } from "react"; import { logger } from "matrix-js-sdk/src/logger"; import { MsgType, type MatrixEvent } from "matrix-js-sdk/src/matrix"; import { type MediaEventContent } from "matrix-js-sdk/src/types"; import { BaseViewModel, FileBodyViewState, FileBodyViewInfoIcon, type FileBodyViewSnapshot, type FileBodyViewModel as FileBodyViewModelInterface, } from "@element-hq/web-shared-components"; import Modal from "../../Modal"; import { _t } from "../../languageHandler"; import { mediaFromContent } from "../../customisations/Media"; import { downloadLabelForFile, presentableTextForFile } from "../../utils/FileUtils"; import { FileDownloader } from "../../utils/FileDownloader"; import { type MediaEventHelper } from "../../utils/MediaEventHelper"; import { TimelineRenderingType } from "../../contexts/RoomContext"; import ErrorDialog from "../../components/views/dialogs/ErrorDialog"; export interface FileBodyViewModelProps { mxEvent: MatrixEvent; mediaEventHelper?: MediaEventHelper; forExport?: boolean; showFileInfo?: boolean; timelineRenderingType: TimelineRenderingType; refIFrame: RefObject; refLink: RefObject; } // Cached copy of the download.svg asset for the sandboxed iframe. const downloadIconCache = { url: "" }; async function cacheDownloadIcon(): Promise { if (downloadIconCache.url) return downloadIconCache.url; // eslint-disable-next-line @typescript-eslint/no-require-imports const svg = await fetch(require("@vector-im/compound-design-tokens/icons/download.svg").default).then((r) => r.text(), ); downloadIconCache.url = "data:image/svg+xml;base64," + window.btoa(svg); return downloadIconCache.url; } // Cache the asset immediately // noinspection JSIgnoredPromiseFromCall cacheDownloadIcon(); // User supplied content can contain scripts, we have to be careful that // we don't accidentally run those script within the same origin as the // client. Otherwise those scripts written by remote users can read // the access token and end-to-end keys that are in local storage. // // For attachments downloaded directly from the homeserver we can use // Content-Security-Policy headers to disable script execution. // // But attachments with end-to-end encryption are more difficult to handle. // We need to decrypt the attachment on the client and then display it. // To display the attachment we need to turn the decrypted bytes into a URL. // // There are two ways to turn bytes into URLs, data URL and blob URLs. // Data URLs aren't suitable for downloading a file because Chrome has a // 2MB limit on the size of URLs that can be viewed in the browser or // downloaded. This limit does not seem to apply when the url is used as // the source attribute of an image tag. // // Blob URLs are generated using window.URL.createObjectURL and unfortunately // for our purposes they inherit the origin of the page that created them. // This means that any scripts that run when the URL is viewed will be able // to access local storage. // // The easiest solution is to host the code that generates the blob URL on // a different domain to the client. // Another possibility is to generate the blob URL within a sandboxed iframe. // The downside of using a second domain is that it complicates hosting, // the downside of using a sandboxed iframe is that the browers are overly // restrictive in what you are allowed to do with the generated URL. /** * Get the current CSS style for a DOMElement. * @param {HTMLElement} element The element to get the current style of. * @return {string} The CSS style encoded as a string. */ function computedStyle(element: HTMLElement | null): string { if (!element) { return ""; } const style = window.getComputedStyle(element, null); let cssText = style.cssText; // noinspection EqualityComparisonWithCoercionJS if (cssText === "") { // Firefox doesn't implement ".cssText" for computed styles. // https://bugzilla.mozilla.org/show_bug.cgi?id=137687 for (const rule of style) { cssText += rule + ":"; cssText += style.getPropertyValue(rule) + ";"; } } return cssText; } export class FileBodyViewModel extends BaseViewModel implements FileBodyViewModelInterface { private readonly refIFrame: RefObject; private readonly refLink: RefObject; private decryptedBlob?: Blob; private userDidClick = false; private readonly fileDownloader: FileDownloader; public constructor(props: FileBodyViewModelProps) { super(props, FileBodyViewModel.computeSnapshot(props)); this.refIFrame = props.refIFrame; this.refLink = props.refLink; this.fileDownloader = new FileDownloader(() => this.refIFrame.current); } private static getInfoIcon(content: MediaEventContent): FileBodyViewInfoIcon { if (content.msgtype === MsgType.Audio) { return FileBodyViewInfoIcon.AUDIO; } else if (content.msgtype === MsgType.Video) { return FileBodyViewInfoIcon.VIDEO; } return FileBodyViewInfoIcon.ATTACHMENT; } private static computeSnapshot(props: FileBodyViewModelProps, decryptedBlob?: Blob): FileBodyViewSnapshot { const content = props.mxEvent.getContent(); const media = mediaFromContent(content); const isEncrypted = props.mediaEventHelper?.media.isEncrypted === true; //Whether or not to show the default placeholder for the file. Defaults to true. const showFileInfo = props.showFileInfo ?? true; const showDownload = !showFileInfo && props.timelineRenderingType !== TimelineRenderingType.Room && props.timelineRenderingType !== TimelineRenderingType.Search && props.timelineRenderingType !== TimelineRenderingType.Pinned && props.timelineRenderingType !== TimelineRenderingType.Thread; const fileInfoLabel = showFileInfo ? presentableTextForFile(content, _t("common|attachment"), true, true) : undefined; const fileInfoTooltip = showFileInfo ? presentableTextForFile(content, _t("common|attachment"), true) : undefined; const fileInfoIcon = showFileInfo ? FileBodyViewModel.getInfoIcon(content) : undefined; const downloadLabel = showDownload ? downloadLabelForFile(content, true) : undefined; const downloadTitle = showDownload ? presentableTextForFile(content, _t("common|attachment"), true, true) : undefined; if (props.forExport) { return { state: FileBodyViewState.EXPORT, showInfo: showFileInfo, infoLabel: fileInfoLabel, infoTooltip: fileInfoTooltip, infoIcon: fileInfoIcon, infoHref: content.file?.url || content.url, }; } if (isEncrypted) { const state = decryptedBlob ? FileBodyViewState.ENCRYPTED : FileBodyViewState.DECRYPTION_PENDING; return { state, showInfo: showFileInfo, infoLabel: fileInfoLabel, infoTooltip: fileInfoTooltip, infoIcon: fileInfoIcon, showDownload, downloadLabel, downloadTitle: downloadTitle, }; } if (media.srcHttp) { return { state: FileBodyViewState.UNENCRYPTED, showInfo: showFileInfo, infoLabel: fileInfoLabel, infoTooltip: fileInfoTooltip, infoIcon: fileInfoIcon, showDownload, downloadLabel, downloadTitle: downloadTitle, downloadHref: media.srcHttp, }; } return { state: FileBodyViewState.INVALID, showInfo: showFileInfo, infoLabel: fileInfoLabel, infoTooltip: fileInfoTooltip, infoIcon: fileInfoIcon, infoHref: content.file?.url || content.url, }; } private get content(): MediaEventContent { return this.props.mxEvent.getContent(); } private get fileName(): string { return this.props.mediaEventHelper?.fileName || _t("common|attachment"); } private get linkText(): string { return downloadLabelForFile(this.content, true); } private downloadFile(fileName: string, text: string): void { if (!this.decryptedBlob) return; this.fileDownloader.download({ blob: this.decryptedBlob, name: fileName, autoDownload: this.userDidClick, opts: { imgSrc: downloadIconCache.url, imgStyle: null, style: computedStyle(this.refLink.current), textContent: text, }, }); } private decryptFile = async (): Promise => { if (this.decryptedBlob || !this.props.mediaEventHelper) { return; } try { this.userDidClick = true; this.decryptedBlob = await this.props.mediaEventHelper.sourceBlob.value; this.snapshot.set(FileBodyViewModel.computeSnapshot(this.props, this.decryptedBlob)); } catch (err) { logger.warn("Unable to decrypt attachment: ", err); Modal.createDialog(ErrorDialog, { title: _t("common|error"), description: _t("timeline|m.file|error_decrypting"), }); } }; public onInfoClick = async (): Promise => { if (this.props.forExport || !(this.props.showFileInfo ?? true) || !this.props.mediaEventHelper) { return; } if (this.props.mediaEventHelper.media.isEncrypted) { await this.decryptFile(); this.downloadFile(this.fileName, this.linkText); return; } this.fileDownloader.download({ blob: await this.props.mediaEventHelper.sourceBlob.value, name: this.fileName, }); }; public onDownloadClick = (): Promise => this.decryptFile(); public onDownloadLinkClick = (event: MouseEvent): void => { event.preventDefault(); event.stopPropagation(); if (!this.props.mediaEventHelper) return; const fileType = this.content.info?.mimetype ?? "application/octet-stream"; logger.log(`Downloading ${fileType} as blob (unencrypted)`); this.props.mediaEventHelper.sourceBlob.value.then((blob) => { const blobUrl = URL.createObjectURL(blob); const tempAnchor = document.createElement("a"); tempAnchor.download = this.fileName; tempAnchor.href = blobUrl; document.body.appendChild(tempAnchor); tempAnchor.click(); tempAnchor.remove(); }); }; public onDownloadIframeLoad = (): void => { this.downloadFile(this.fileName, this.linkText); }; public setProps(newProps: Partial): void { const oldEvent = this.props.mxEvent; this.props = { ...this.props, ...newProps }; if (this.props.mxEvent !== oldEvent) { this.decryptedBlob = undefined; this.userDidClick = false; } this.snapshot.set(FileBodyViewModel.computeSnapshot(this.props, this.decryptedBlob)); } }