Work on attachments

This commit is contained in:
N-Pex 2025-06-09 09:44:37 +02:00
parent ec79a33eab
commit 842c87dc96
28 changed files with 2714 additions and 798 deletions

View file

@ -1,7 +1,7 @@
import axios from "axios";
import * as ContentHelpers from "matrix-js-sdk/lib/content-helpers";
import imageResize from "image-resize";
import { AutoDiscovery } from "matrix-js-sdk";
import { AutoDiscovery, Method } from "matrix-js-sdk";
import User from "../models/user";
import prettyBytes from "pretty-bytes";
import Hammer from "hammerjs";
@ -12,12 +12,8 @@ import aesjs from "aes-js";
import localizedFormat from "dayjs/plugin/localizedFormat";
import duration from "dayjs/plugin/duration";
import i18n from "./lang";
import {
toRaw,
isRef,
isReactive,
isProxy,
} from 'vue';
import { toRaw, isRef, isReactive, isProxy } from "vue";
import { UploadPromise } from "../models/attachment";
export const STATE_EVENT_ROOM_DELETION_NOTICE = "im.keanu.room_deletion_notice";
export const STATE_EVENT_ROOM_DELETED = "im.keanu.room_deleted";
@ -29,6 +25,9 @@ export const ROOM_TYPE_CHANNEL = "im.keanu.room_type_channel";
export const STATE_EVENT_ROOM_TYPE = "im.keanu.room_type";
const THUMBNAIL_MAX_WIDTH = 160;
const THUMBNAIL_MAX_HEIGHT = 160;
// Install extended localized format
dayjs.extend(localizedFormat);
dayjs.extend(duration);
@ -43,32 +42,6 @@ var _browserCanRecordAudioF = function () {
};
var _browserCanRecordAudio = _browserCanRecordAudioF();
class UploadPromise {
aborted = false;
onAbort = undefined;
constructor(wrappedPromise) {
this.wrappedPromise = wrappedPromise;
}
abort() {
this.aborted = true;
if (this.onAbort) {
this.onAbort();
}
}
then(resolve, reject) {
this.wrappedPromise = this.wrappedPromise.then(resolve, reject);
return this;
}
catch(handler) {
this.wrappedPromise = this.wrappedPromise.catch(handler);
return this;
}
}
class Util {
threadMessageType() {
return Thread.hasServerSideSupport ? "m.thread" : "io.element.thread";
@ -90,6 +63,7 @@ class Util {
}
getAttachment(matrixClient, useAuthedMedia, event, progressCallback, asBlob = false, abortController = undefined) {
console.error("GET ATTACHMENT FOR EVENT", event.getId());
return new Promise((resolve, reject) => {
const content = event.getContent();
var url = null;
@ -164,94 +138,54 @@ class Util {
});
}
getThumbnail(matrixClient, useAuthedMedia, event, config, ignoredw, ignoredh) {
return new Promise((resolve, reject) => {
const content = event.getContent();
var url = null;
var mime = "image/png";
var file = null;
let decrypt = true;
if (content.url != null) {
url = matrixClient.mxcUrlToHttp(
content.url,
undefined,
undefined,
undefined,
undefined,
undefined,
useAuthedMedia
);
decrypt = false;
if (content.info) {
mime = content.info.mimetype;
}
} else if (content && content.info && content.info.thumbnail_file && content.info.thumbnail_file.url) {
file = content.info.thumbnail_file;
// var width = w;
// var height = h;
// if (content.info.w < w || content.info.h < h) {
// width = content.info.w;
// height = content.info.h;
// }
// url = matrixClient.mxcUrlToHttp(
// file.url,
// width, height,
// "scale",
// true
// );
url = matrixClient.mxcUrlToHttp(
file.url,
undefined,
undefined,
undefined,
undefined,
undefined,
useAuthedMedia
);
mime = file.mimetype;
} else if (
content.file &&
content.file.url &&
this.getFileSize(event) > 0 &&
this.getFileSize(event) < config.maxSizeAutoDownloads
) {
// No thumb, use real url
file = content.file;
url = matrixClient.mxcUrlToHttp(
file.url,
undefined,
undefined,
undefined,
undefined,
undefined,
useAuthedMedia
);
mime = file.mimetype;
async getThumbnail(matrixClient, useAuthedMedia, event, config, ignoredw, ignoredh) {
console.error("GET THUMB FOR EVENT", event.getId());
const content = event.getContent();
var url = null;
var mime = "image/png";
var file = null;
let decrypt = true;
if (!!content.info && !!content.info.thumbnail_url) {
url = matrixClient.mxcUrlToHttp(content.info.thumbnail_url, undefined, undefined, undefined, undefined, undefined, useAuthedMedia);
decrypt = false;
if (content.info.thumbnail_info) {
mime = content.info.thumbnail_info.mimetype;
}
if (url == null) {
reject("No url found!");
return;
} else if (content.url != null) {
url = matrixClient.mxcUrlToHttp(content.url, undefined, undefined, undefined, undefined, undefined, useAuthedMedia);
decrypt = false;
if (content.info) {
mime = content.info.mimetype;
}
} else if (content && content.info && content.info.thumbnail_file && content.info.thumbnail_file.url) {
file = content.info.thumbnail_file;
url = matrixClient.mxcUrlToHttp(file.url, undefined, undefined, undefined, undefined, undefined, useAuthedMedia);
mime = file.mimetype;
} else if (
content.file &&
content.file.url &&
this.getFileSize(event) > 0 &&
this.getFileSize(event) < config.maxSizeAutoDownloads
) {
// No thumb, use real url
file = content.file;
url = matrixClient.mxcUrlToHttp(file.url, undefined, undefined, undefined, undefined, undefined, useAuthedMedia);
mime = file.mimetype;
}
axios
if (url == null) {
throw new Error("No url found!");
}
const response = await axios
.get(url, useAuthedMedia ? {
responseType: "arraybuffer",
headers: {
Authorization: `Bearer ${matrixClient.getAccessToken()}`,
},
} : { responseType: "arraybuffer" })
.then((response) => {
return decrypt ? this.decryptIfNeeded(file, response) : Promise.resolve({ buffer: response.data });
})
.then((bytes) => {
resolve(URL.createObjectURL(new Blob([bytes.buffer], { type: mime })));
})
.catch((err) => {
console.log("Download error: ", err);
reject(err);
});
});
} : { responseType: "arraybuffer" });
const bytes = decrypt ? await this.decryptIfNeeded(file, response) : { buffer: response.data };
return URL.createObjectURL(new Blob([bytes.buffer], { type: mime }));
}
b64toBuffer(val) {
@ -430,7 +364,37 @@ class Util {
});
}
sendFile(matrixClient, roomId, file, onUploadProgress, threadRoot) {
async encryptFileAndGenerateInfo(data, mime) {
let key = Buffer.from(crypto.getRandomValues(new Uint8Array(256 / 8)));
let iv = Buffer.concat([Buffer.from(crypto.getRandomValues(new Uint8Array(8))), Buffer.alloc(8)]); // Initialization vector.
// Encrypt
let aesCtr = new aesjs.ModeOfOperation.ctr(key, new aesjs.Counter(iv));
let encryptedBytes = aesCtr.encrypt(data);
// Calculate sha256
let hash = await crypto.subtle.digest("SHA-256", encryptedBytes);
console.error("HASH GENERATED", Buffer.from(hash));
const jwk = {
kty: "oct",
key_ops: ["encrypt", "decrypt"],
alg: "A256CTR",
k: key.toString("base64").replaceAll(/\//g, "_").replaceAll(/\+/g, "-"),
ext: true,
};
const encryptedFile = {
mimetype: mime,
key: jwk,
iv: Buffer.from(iv).toString("base64").replace(/=/g, ""),
hashes: { sha256: Buffer.from(hash).toString("base64").replace(/=/g, "") },
v: "v2",
};
return [encryptedBytes, encryptedFile];
}
sendFile(matrixClient, roomId, file, onUploadProgress, threadRoot, dimensions) {
const uploadPromise = new UploadPromise(undefined);
uploadPromise.wrappedPromise = new Promise((resolve, reject) => {
var reader = new FileReader();
@ -439,131 +403,140 @@ class Util {
reject("Aborted");
return;
}
const fileContents = e.target.result;
var data = new Uint8Array(fileContents);
try {
const fileContents = e.target.result;
const info = {
mimetype: file.type,
size: file.size,
};
var data = new Uint8Array(fileContents);
let thumbnailData = undefined;
let thumbnailInfo = undefined;
// If audio, send duration in ms as well
if (file.duration) {
info.duration = file.duration;
}
var description = file.name;
var msgtype = "m.file";
if (file.type.startsWith("image/")) {
msgtype = "m.image";
} else if (file.type.startsWith("audio/")) {
msgtype = "m.audio";
} else if (file.type.startsWith("video/")) {
msgtype = "m.video";
}
const opts = {
type: file.type,
name: description,
progressHandler: onUploadProgress,
onlyContentUri: false,
};
var messageContent = {
body: description,
info: info,
msgtype: msgtype,
};
// If thread root (an eventId) is set, add that here
if (threadRoot) {
messageContent["m.relates_to"] = {
rel_type: this.threadMessageType(),
event_id: threadRoot,
const info = {
mimetype: file.type,
size: file.size,
};
}
// Set filename for files
if (msgtype == "m.file") {
messageContent.filename = file.name;
}
// If audio, send duration in ms as well
if (file.duration) {
info.duration = file.duration;
}
if (!matrixClient.isRoomEncrypted(roomId)) {
// Not encrypted.
const promise = matrixClient.uploadContent(data, opts);
uploadPromise.onAbort = () => {
matrixClient.cancelUpload(promise);
};
promise
.then((response) => {
messageContent.url = response.content_uri;
return msgtype == "m.audio" ? this.generateWaveform(fileContents, messageContent) : true;
})
.then(() => {
return this.sendMessage(matrixClient, roomId, "m.room.message", messageContent);
})
.then((result) => {
resolve(result);
})
.catch((err) => {
reject(err);
});
return; // Don't fall through
}
var description = file.name;
var msgtype = "m.file";
if (file.type.startsWith("image/")) {
msgtype = "m.image";
let key = Buffer.from(crypto.getRandomValues(new Uint8Array(256 / 8)));
let iv = Buffer.concat([Buffer.from(crypto.getRandomValues(new Uint8Array(8))), Buffer.alloc(8)]); // Initialization vector.
// Encrypt
var aesCtr = new aesjs.ModeOfOperation.ctr(key, new aesjs.Counter(iv));
var encryptedBytes = aesCtr.encrypt(data);
data = encryptedBytes;
// Calculate sha256
let hash = await crypto.subtle.digest("SHA-256", data);
const jwk = {
kty: "oct",
key_ops: ["encrypt", "decrypt"],
alg: "A256CTR",
k: key.toString("base64").replaceAll(/\//g, "_").replaceAll(/\+/g, "-"),
ext: true,
};
const encryptedFile = {
mimetype: file.type,
key: jwk,
iv: Buffer.from(iv).toString("base64").replace(/=/g, ""),
hashes: { sha256: Buffer.from(hash).toString("base64").replace(/=/g, "") },
v: "v2",
};
messageContent.file = encryptedFile;
// Encrypted data sent as octet-stream!
opts.type = "application/octet-stream";
const promise = matrixClient.uploadContent(data, opts);
uploadPromise.onAbort = () => {
matrixClient.cancelUpload(promise);
};
promise
.then((response) => {
if (response.error) {
return reject(response.error);
// Generate thumbnail?
if (dimensions) {
const w = dimensions.width;
const h = dimensions.height;
if (w > THUMBNAIL_MAX_WIDTH || h > THUMBNAIL_MAX_HEIGHT) {
var aspect = w / h;
var newWidth = parseInt((w > h ? THUMBNAIL_MAX_WIDTH : THUMBNAIL_MAX_HEIGHT * aspect).toFixed());
var newHeight = parseInt((w > h ? THUMBNAIL_MAX_WIDTH / aspect : THUMBNAIL_MAX_HEIGHT).toFixed());
const scaled = await imageResize(file, {
format: "webp",
width: newWidth,
height: newHeight,
outputType: "blob",
}).catch(() => {return Promise.resolve(undefined)});
if (scaled && file.size > scaled.size) {
thumbnailData = new Uint8Array(await scaled.arrayBuffer());
thumbnailInfo = {
mimetype: scaled.type,
size: scaled.size,
h: newHeight,
w: newWidth,
};
}
}
}
} else if (file.type.startsWith("audio/")) {
msgtype = "m.audio";
} else if (file.type.startsWith("video/")) {
msgtype = "m.video";
}
var messageContent = {
body: description,
info: info,
msgtype: msgtype,
};
// If thread root (an eventId) is set, add that here
if (threadRoot) {
messageContent["m.relates_to"] = {
rel_type: this.threadMessageType(),
event_id: threadRoot,
};
}
// Set filename for files
if (msgtype == "m.file") {
messageContent.filename = file.name;
}
const useEncryption = matrixClient.isRoomEncrypted(roomId);
const dataUploadOpts = {
type: useEncryption ? "application/octet-stream" : file.type,
name: description,
progressHandler: onUploadProgress,
onlyContentUri: false,
};
if (useEncryption) {
const [encryptedBytes, encryptedFile] = await this.encryptFileAndGenerateInfo(data, file.type);
messageContent.file = encryptedFile;
data = encryptedBytes;
}
if (thumbnailData) {
messageContent.thumbnail_info = thumbnailInfo;
if (useEncryption) {
console.error("Encrypt thumb thumb");
const [encryptedBytes, encryptedFile] = await this.encryptFileAndGenerateInfo(thumbnailData, file.type);
messageContent.info.thumbnail_file = encryptedFile;
thumbnailData = encryptedBytes;
}
const thumbnailUploadOpts = {
type: useEncryption ? "application/octet-stream" : file.type,
name: "thumb:" + description,
progressHandler: onUploadProgress,
onlyContentUri: false,
};
const thumbUploadPromise = matrixClient.uploadContent(thumbnailData, thumbnailUploadOpts);
uploadPromise.onAbort = () => {
matrixClient.cancelUpload(thumbUploadPromise);
};
const thumbnailResponse = await thumbUploadPromise;
if (useEncryption) {
messageContent.info.thumbnail_file.url = thumbnailResponse.content_uri;
} else {
messageContent.info.thumbnail_url = thumbnailResponse.content_uri;
}
}
const dataUploadPromise = matrixClient.uploadContent(data, dataUploadOpts);
uploadPromise.onAbort = () => {
matrixClient.cancelUpload(dataUploadPromise);
};
const response = await dataUploadPromise;
if (useEncryption) {
messageContent.file.url = response.content_uri;
return msgtype == "m.audio" ? this.generateWaveform(fileContents, messageContent) : true;
})
.then(() => {
return this.sendMessage(matrixClient, roomId, "m.room.message", messageContent);
})
.then((result) => {
resolve(result);
})
.catch((err) => {
reject(err);
});
} else {
messageContent.url = response.content_uri;
}
// Generate audio waveforms
if (msgtype == "m.audio") {
this.generateWaveform(fileContents, messageContent);
}
const result = await this.sendMessage(matrixClient, roomId, "m.room.message", messageContent);
resolve(result);
} catch (error) {
reject(error);
}
};
reader.onerror = (err) => {
reject(err);
@ -1248,5 +1221,5 @@ class Util {
};
return objectIterator(sourceObj);
}
};
}
export default new Util();