/* Copyright 2024 New Vector Ltd. Copyright 2015-2021 The Matrix.org Foundation C.I.C. SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ import React, { AllHTMLAttributes, createRef } from "react"; import { logger } from "matrix-js-sdk/src/logger"; import { MediaEventContent } from "matrix-js-sdk/src/types"; import { Button } from "@vector-im/compound-web"; import { DownloadIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import { _t } from "../../../languageHandler"; import Modal from "../../../Modal"; import AccessibleButton from "../elements/AccessibleButton"; import { mediaFromContent } from "../../../customisations/Media"; import ErrorDialog from "../dialogs/ErrorDialog"; import { downloadLabelForFile, presentableTextForFile } from "../../../utils/FileUtils"; import { IBodyProps } from "./IBodyProps"; import { FileDownloader } from "../../../utils/FileDownloader"; import TextWithTooltip from "../elements/TextWithTooltip"; import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext"; export let DOWNLOAD_ICON_URL: string; // cached copy of the download.svg asset for the sandboxed iframe later on async function cacheDownloadIcon(): Promise { if (DOWNLOAD_ICON_URL) return; // cached already // 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(), ); DOWNLOAD_ICON_URL = "data:image/svg+xml;base64," + window.btoa(svg); } // 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. */ export 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; } interface IProps extends IBodyProps { /* whether or not to show the default placeholder for the file. Defaults to true. */ showGenericPlaceholder: boolean; } interface IState { decryptedBlob?: Blob; } export default class MFileBody extends React.Component { public static contextType = RoomContext; public declare context: React.ContextType; public state: IState = {}; public static defaultProps = { showGenericPlaceholder: true, }; private iframe: React.RefObject = createRef(); private dummyLink: React.RefObject = createRef(); private userDidClick = false; private fileDownloader: FileDownloader = new FileDownloader(() => this.iframe.current); private getContentUrl(): string | null { if (this.props.forExport) return null; const media = mediaFromContent(this.props.mxEvent.getContent()); return media.srcHttp; } private get content(): MediaEventContent { return this.props.mxEvent.getContent(); } private get fileName(): string { return this.content.body && this.content.body.length > 0 ? this.content.body : _t("common|attachment"); } private get linkText(): string { return downloadLabelForFile(this.content, true); } private downloadFile(fileName: string, text: string): void { if (!this.state.decryptedBlob) return; this.fileDownloader.download({ blob: this.state.decryptedBlob, name: fileName, autoDownload: this.userDidClick, opts: { imgSrc: DOWNLOAD_ICON_URL, imgStyle: null, style: computedStyle(this.dummyLink.current), textContent: text, }, }); } public componentDidUpdate(prevProps: IProps, prevState: IState): void { if (this.props.onHeightChanged && !prevState.decryptedBlob && this.state.decryptedBlob) { this.props.onHeightChanged(); } } private decryptFile = async (): Promise => { if (this.state.decryptedBlob) { return; } try { this.userDidClick = true; this.setState({ decryptedBlob: await this.props.mediaEventHelper!.sourceBlob.value, }); } catch (err) { logger.warn("Unable to decrypt attachment: ", err); Modal.createDialog(ErrorDialog, { title: _t("common|error"), description: _t("timeline|m.file|error_decrypting"), }); } }; private onPlaceholderClick = async (): Promise => { const mediaHelper = this.props.mediaEventHelper; if (mediaHelper?.media.isEncrypted) { await this.decryptFile(); this.downloadFile(this.fileName, this.linkText); } else { // As a button we're missing the `download` attribute for styling reasons, so // download with the file downloader. this.fileDownloader.download({ blob: await mediaHelper!.sourceBlob.value, name: this.fileName, }); } }; public render(): React.ReactNode { const isEncrypted = this.props.mediaEventHelper?.media.isEncrypted; const contentUrl = this.getContentUrl(); const contentFileSize = this.content.info ? this.content.info.size : null; const fileType = this.content.info?.mimetype ?? "application/octet-stream"; let showDownloadLink = !this.props.showGenericPlaceholder || (this.context.timelineRenderingType !== TimelineRenderingType.Room && this.context.timelineRenderingType !== TimelineRenderingType.Search && this.context.timelineRenderingType !== TimelineRenderingType.Pinned); let placeholder: React.ReactNode = null; if (this.props.showGenericPlaceholder) { placeholder = ( {presentableTextForFile(this.content, _t("common|attachment"), true, true)} ); showDownloadLink = false; } if (this.props.forExport) { const content = this.props.mxEvent.getContent(); // During export, the content url will point to the MSC, which will later point to a local url return ( {placeholder} ); } if (this.context.timelineRenderingType === TimelineRenderingType.Thread) { showDownloadLink = false; } if (isEncrypted) { if (!this.state.decryptedBlob) { // Need to decrypt the attachment // Wait for the user to click on the link before downloading // and decrypting the attachment. // This button should actually Download because usercontent/ will try to click itself // but it is not guaranteed between various browsers' settings. return ( {placeholder} {showDownloadLink && (
)}
); } const url = "usercontent/"; // XXX: this path should probably be passed from the skin // If the attachment is encrypted then put the link inside an iframe. return ( {placeholder} {showDownloadLink && (
{/* * Add dummy copy of the button * We'll use it to learn how the download button * would have been styled if it was rendered inline. */} {/* this violates multiple eslint rules so ignore it completely */}
{/* TODO: Move iframe (and dummy link) into FileDownloader. We currently have it set up this way because of styles applied to the iframe itself which cannot be easily handled/overridden by the FileDownloader. In future, the download link may disappear entirely at which point it could also be suitable to just remove this bit of code. */}