Work on attachments
This commit is contained in:
parent
ec79a33eab
commit
842c87dc96
28 changed files with 2714 additions and 798 deletions
51
src/models/attachment.ts
Normal file
51
src/models/attachment.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
export class UploadPromise<Type> {
|
||||
wrappedPromise: Promise<Type>;
|
||||
aborted: boolean = false;
|
||||
onAbort: (() => void) | undefined = undefined;
|
||||
|
||||
constructor(wrappedPromise: Promise<Type>) {
|
||||
this.wrappedPromise = wrappedPromise;
|
||||
}
|
||||
|
||||
abort() {
|
||||
this.aborted = true;
|
||||
if (this.onAbort) {
|
||||
this.onAbort();
|
||||
}
|
||||
}
|
||||
|
||||
then(resolve: any, reject: any) {
|
||||
this.wrappedPromise = this.wrappedPromise.then(resolve, reject);
|
||||
return this;
|
||||
}
|
||||
|
||||
catch(handler: any) {
|
||||
this.wrappedPromise = this.wrappedPromise.catch(handler);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
export type AttachmentSendStatus = "initial" | "sending" | "sent" | "canceled" | "failed";
|
||||
|
||||
export type AttachmentSendInfo = {
|
||||
status: AttachmentSendStatus;
|
||||
statusDate: number; //ms
|
||||
mediaEventId: string | undefined;
|
||||
progress: number;
|
||||
promise: UploadPromise<string> | undefined;
|
||||
randomRotation: number; // For UI effects
|
||||
randomTranslationX: number; // For UI effects
|
||||
randomTranslationY: number; // For UI effects
|
||||
};
|
||||
|
||||
export type Attachment = {
|
||||
status: "loading" | "loaded";
|
||||
file: File;
|
||||
dimensions?: { width: number; height: number };
|
||||
scaledFile?: File;
|
||||
scaledDimensions?: { width: number; height: number };
|
||||
useScaled: boolean;
|
||||
src?: string;
|
||||
proof?: any;
|
||||
sendInfo?: AttachmentSendInfo;
|
||||
};
|
||||
325
src/models/attachmentManager.ts
Normal file
325
src/models/attachmentManager.ts
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
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");
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
15
src/models/eventAttachment.ts
Normal file
15
src/models/eventAttachment.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { MatrixEvent } from "matrix-js-sdk";
|
||||
|
||||
export type KeanuEventExtension = {
|
||||
isMxThread?: boolean;
|
||||
isChannelMessage?: boolean;
|
||||
isPinned?: boolean;
|
||||
}
|
||||
|
||||
export type EventAttachment = {
|
||||
event: MatrixEvent & KeanuEventExtension;
|
||||
src?: string;
|
||||
thumbnail?: string;
|
||||
srcPromise?: Promise<string>;
|
||||
thumbnailPromise?: Promise<string>;
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue