keanu-weblite/src/models/attachmentManager.ts

326 lines
10 KiB
TypeScript
Raw Normal View History

2025-06-09 09:44:37 +02:00
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<string>;
thumbnailPromise?: Promise<string>;
attachmentProgress?: ((progress: number) => void)[];
thumbnailProgress?: ((progress: number) => void)[];
};
export class AttachmentManager {
matrixClient: MatrixClient;
useAuthedMedia: boolean;
maxSizeUploads: number;
maxSizeAutoDownloads: number;
cache: Map<string | undefined, CacheEntry>;
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<Attachment> {
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<string> {
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<string> {
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<string> {
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<Uint8Array> {
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");
});
});
}
}