import { MatrixClient, MatrixEvent } from "matrix-js-sdk"; import { KeanuEventExtension } from "./eventAttachment"; import axios, { AxiosRequestConfig, AxiosResponse } from "axios"; import { Counter, ModeOfOperation } from "aes-js"; import { Attachment } from "./attachment"; import proofmode from "../plugins/proofmode"; import imageSize from "image-size"; import imageResize from "image-resize"; import { reactive } from "vue"; type CacheEntry = { attachment?: string; thumbnail?: string; attachmentPromise?: Promise; thumbnailPromise?: Promise; attachmentProgress?: ((progress: number) => void)[]; thumbnailProgress?: ((progress: number) => void)[]; }; export class AttachmentManager { matrixClient: MatrixClient; useAuthedMedia: boolean; maxSizeUploads: number; maxSizeAutoDownloads: number; cache: Map; constructor(matrixClient: MatrixClient, useAuthedMedia: boolean, maxSizeAutoDownloads: number) { this.matrixClient = matrixClient; this.useAuthedMedia = useAuthedMedia; this.maxSizeUploads = 0; this.maxSizeAutoDownloads = maxSizeAutoDownloads; this.cache = new Map(); // Get max upload size this.matrixClient.getMediaConfig(useAuthedMedia).then((config) => { this.maxSizeUploads = config["m.upload.size"] ?? 0; }).catch(() => {}); } public createAttachment(file: File): Attachment { let a: Attachment = { status: "loading", file: file, useScaled: false, }; const ra = reactive(a); this.prepareUpload(ra); return ra; } private async prepareUpload(attachment: Attachment): Promise { const file = attachment.file; if (file.type.startsWith("image/")) { attachment.proof = await proofmode.proofCheckFile(file); var reader = new FileReader(); await new Promise((resolve) => { reader.onload = (evt) => { attachment.src = (evt.target?.result as string) ?? undefined; if (attachment.src) { try { const buffer = Uint8Array.from(window.atob(attachment.src.replace(/^data[^,]+,/, "")), (v) => v.charCodeAt(0) ); attachment.dimensions = imageSize(buffer); // Need to resize? const w = attachment.dimensions.width; const h = attachment.dimensions.height; if (w > 640 || h > 640) { var aspect = w / h; var newWidth = parseInt((w > h ? 640 : 640 * aspect).toFixed()); var newHeight = parseInt((w > h ? 640 / aspect : 640).toFixed()); imageResize(attachment.src, { format: "webp", width: newWidth, height: newHeight, outputType: "blob", }) .then((img) => { attachment.scaledFile = new File([img as BlobPart], file.name, { type: "image/webp", lastModified: Date.now(), }); attachment.scaledDimensions = { width: newWidth, height: newHeight, }; // Use scaled version if the image does not contain C2PA attachment.useScaled = attachment.scaledFile !== undefined && (attachment.proof === undefined || attachment.proof.integrity === undefined || attachment.proof.integrity.c2pa === undefined) }) .catch((err) => { console.error("Resize failed:", err); }); } } catch (error) { console.error("Failed to get image dimensions: " + error); } } resolve(true); }; reader.readAsDataURL(file); }); } attachment.status = "loaded"; return attachment; } public async loadEventAttachment( event: MatrixEvent & KeanuEventExtension, progress?: (percent: number) => void, outputObject?: { src: string; thumbnailSrc: string } ): Promise { console.error("GET ATTACHMENT FOR EVENT", event.getId()); const entry = this.cache.get(event.getId()) ?? {}; if (entry.attachment) { if (outputObject) { outputObject.src = entry.attachment; } return entry.attachment; } if (!entry.attachmentPromise) { entry.attachmentPromise = this._loadEventAttachmentOrThumbnail(event, false, progress) .then((attachment) => { entry.attachment = attachment; return attachment; }) .catch((err) => { entry.attachmentPromise = undefined; throw err; }); this.cache.set(event.getId(), entry); } entry.attachmentProgress = (entry.attachmentProgress ?? []).concat(); return entry.attachmentPromise.then((attachment) => { console.error("GOT ATTACHMENT", attachment); if (outputObject) { outputObject.src = attachment; } return attachment; }); } public async loadEventThumbnail( event: MatrixEvent & KeanuEventExtension, progress?: (percent: number) => void, outputObject?: { src: string; thumbnailSrc: string } ): Promise { console.error("GET THUMB FOR EVENT", event.getId()); const entry = this.cache.get(event.getId()) ?? {}; if (entry.thumbnail) { if (outputObject) { outputObject.thumbnailSrc = entry.thumbnail; } return entry.thumbnail; } if (!entry.thumbnailPromise) { entry.thumbnailPromise = this._loadEventAttachmentOrThumbnail(event, true, progress) .then((thummbnail) => { entry.thumbnail = thummbnail; return thummbnail; }) .catch((err) => { entry.thumbnailPromise = undefined; throw err; }); this.cache.set(event.getId(), entry); } return entry.thumbnailPromise.then((thumbnail) => { console.error("GOT THUMB", thumbnail); if (outputObject) { outputObject.thumbnailSrc = thumbnail; } return thumbnail; }); } private async _loadEventAttachmentOrThumbnail( event: MatrixEvent & KeanuEventExtension, thumbnail: boolean, progress?: (percent: number) => void ): Promise { await this.matrixClient.decryptEventIfNeeded(event); const content = event.getContent(); var url = null; var mime = "image/png"; var file = null; let decrypt = true; if (thumbnail && !!content.info && !!content.info.thumbnail_url) { url = this.matrixClient.mxcUrlToHttp( content.info.thumbnail_url, undefined, undefined, undefined, undefined, undefined, this.useAuthedMedia ); decrypt = false; if (content.info.thumbnail_info) { mime = content.info.thumbnail_info.mimetype; } } else if (content.url != null) { url = this.matrixClient.mxcUrlToHttp( content.url, undefined, undefined, undefined, undefined, undefined, this.useAuthedMedia ); decrypt = false; if (content.info) { mime = content.info.mimetype; } } else if (thumbnail && content && content.info && content.info.thumbnail_file && content.info.thumbnail_file.url) { file = content.info.thumbnail_file; url = this.matrixClient.mxcUrlToHttp( file.url, undefined, undefined, undefined, undefined, undefined, this.useAuthedMedia ); mime = file.mimetype; } else if ( content.file && content.file.url && event.getContent()?.info?.size > 0 && event.getContent()?.info?.size < this.maxSizeAutoDownloads ) { // No thumb, use real url file = content.file; url = this.matrixClient.mxcUrlToHttp( file.url, undefined, undefined, undefined, undefined, undefined, this.useAuthedMedia ); mime = file.mimetype; } if (url == null) { throw new Error("No url found!"); } let options: AxiosRequestConfig = { responseType: "arraybuffer", onDownloadProgress: (progressEvent) => { if (progress) { progress(Math.floor((progressEvent.progress ?? 0) * 100)); } }, }; if (this.useAuthedMedia) { options.headers = { Authorization: `Bearer ${this.matrixClient.getAccessToken()}`, }; } const response = await axios.get(url, options); const bytes = decrypt ? await this.decryptData(file, response) : { buffer: response.data }; return URL.createObjectURL(new Blob([bytes.buffer], { type: mime })); } releaseEvent(event: MatrixEvent & KeanuEventExtension): void { console.error("Release event", event.getId()); const entry = this.cache.get(event.getId()); if (entry) { // TODO - abortable promises this.cache.delete(event.getId()); if (entry.attachment) { URL.revokeObjectURL(entry.attachment); } if (entry.thumbnail) { URL.revokeObjectURL(entry.thumbnail); } } } private b64toBuffer(val: any) { const baseValue = val.replaceAll("-", "+").replaceAll("_", "/"); return Buffer.from(baseValue, "base64"); } private decryptData(file: any, response: AxiosResponse): Promise { return new Promise((resolve, reject) => { const key = this.b64toBuffer(file.key.k); const iv = this.b64toBuffer(file.iv); const originalHash = this.b64toBuffer(file.hashes.sha256); var aesCtr = new ModeOfOperation.ctr(key, new Counter(iv)); const data = new Uint8Array(response.data); crypto.subtle .digest("SHA-256", data) .then((hash) => { // Calculate sha256 and compare hashes if (Buffer.compare(Buffer.from(hash), originalHash) != 0) { reject("Hashes don't match!"); return; } var decryptedBytes = aesCtr.decrypt(data); resolve(decryptedBytes); }) .catch((err) => { reject("Failed to calculate hash value"); }); }); } }