forked from mystiq/hydrogen-web
Merge pull request #175 from vector-im/bwindels/decrypt-images
Decrypt images
This commit is contained in:
commit
6d13709fcc
19 changed files with 372 additions and 65 deletions
|
@ -95,6 +95,9 @@ export class TilesCollection extends BaseObservableList {
|
||||||
|
|
||||||
onUnsubscribeLast() {
|
onUnsubscribeLast() {
|
||||||
this._entrySubscription = this._entrySubscription();
|
this._entrySubscription = this._entrySubscription();
|
||||||
|
for(let i = 0; i < this._tiles.length; i+= 1) {
|
||||||
|
this._tiles[i].dispose();
|
||||||
|
}
|
||||||
this._tiles = null;
|
this._tiles = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -147,7 +150,8 @@ export class TilesCollection extends BaseObservableList {
|
||||||
if (action.shouldReplace) {
|
if (action.shouldReplace) {
|
||||||
const newTile = this._tileCreator(entry);
|
const newTile = this._tileCreator(entry);
|
||||||
if (newTile) {
|
if (newTile) {
|
||||||
this._replaceTile(tileIdx, tile, newTile);
|
this._replaceTile(tileIdx, tile, newTile, action.updateParams);
|
||||||
|
newTile.setUpdateEmit(this._emitSpontanousUpdate);
|
||||||
} else {
|
} else {
|
||||||
this._removeTile(tileIdx, tile);
|
this._removeTile(tileIdx, tile);
|
||||||
}
|
}
|
||||||
|
@ -172,7 +176,7 @@ export class TilesCollection extends BaseObservableList {
|
||||||
// merge with neighbours? ... hard to imagine use case for this ...
|
// merge with neighbours? ... hard to imagine use case for this ...
|
||||||
}
|
}
|
||||||
|
|
||||||
_replaceTile(tileIdx, existingTile, newTile) {
|
_replaceTile(tileIdx, existingTile, newTile, updateParams) {
|
||||||
existingTile.dispose();
|
existingTile.dispose();
|
||||||
const prevTile = this._getTileAtIdx(tileIdx - 1);
|
const prevTile = this._getTileAtIdx(tileIdx - 1);
|
||||||
const nextTile = this._getTileAtIdx(tileIdx + 1);
|
const nextTile = this._getTileAtIdx(tileIdx + 1);
|
||||||
|
@ -181,7 +185,7 @@ export class TilesCollection extends BaseObservableList {
|
||||||
newTile.updatePreviousSibling(prevTile);
|
newTile.updatePreviousSibling(prevTile);
|
||||||
newTile.updateNextSibling(nextTile);
|
newTile.updateNextSibling(nextTile);
|
||||||
nextTile?.updatePreviousSibling(newTile);
|
nextTile?.updatePreviousSibling(newTile);
|
||||||
this.emitUpdate(tileIdx, newTile, null);
|
this.emitUpdate(tileIdx, newTile, updateParams);
|
||||||
}
|
}
|
||||||
|
|
||||||
_removeTile(tileIdx, tile) {
|
_removeTile(tileIdx, tile) {
|
||||||
|
|
|
@ -50,7 +50,7 @@ export class UpdateAction {
|
||||||
return new UpdateAction(false, false, false, null);
|
return new UpdateAction(false, false, false, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Replace() {
|
static Replace(params) {
|
||||||
return new UpdateAction(false, false, true, null);
|
return new UpdateAction(false, false, true, params);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,7 +22,8 @@ export class EncryptedEventTile extends MessageTile {
|
||||||
const parentResult = super.updateEntry(entry, params);
|
const parentResult = super.updateEntry(entry, params);
|
||||||
// event got decrypted, recreate the tile and replace this one with it
|
// event got decrypted, recreate the tile and replace this one with it
|
||||||
if (entry.eventType !== "m.room.encrypted") {
|
if (entry.eventType !== "m.room.encrypted") {
|
||||||
return UpdateAction.Replace();
|
// the "shape" parameter trigger tile recreation in TimelineList
|
||||||
|
return UpdateAction.Replace("shape");
|
||||||
} else {
|
} else {
|
||||||
return parentResult;
|
return parentResult;
|
||||||
}
|
}
|
||||||
|
@ -38,7 +39,7 @@ export class EncryptedEventTile extends MessageTile {
|
||||||
if (code === "MEGOLM_NO_SESSION") {
|
if (code === "MEGOLM_NO_SESSION") {
|
||||||
return this.i18n`The sender hasn't sent us the key for this message yet.`;
|
return this.i18n`The sender hasn't sent us the key for this message yet.`;
|
||||||
} else {
|
} else {
|
||||||
return decryptionError?.message || this.i18n`"Could not decrypt message because of unknown reason."`;
|
return decryptionError?.message || this.i18n`Could not decrypt message because of unknown reason.`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
/*
|
/*
|
||||||
Copyright 2020 Bruno Windels <bruno@windels.cloud>
|
Copyright 2020 Bruno Windels <bruno@windels.cloud>
|
||||||
|
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
|
@ -20,20 +21,60 @@ const MAX_HEIGHT = 300;
|
||||||
const MAX_WIDTH = 400;
|
const MAX_WIDTH = 400;
|
||||||
|
|
||||||
export class ImageTile extends MessageTile {
|
export class ImageTile extends MessageTile {
|
||||||
|
constructor(options) {
|
||||||
|
super(options);
|
||||||
|
this._decryptedThumbail = null;
|
||||||
|
this._decryptedImage = null;
|
||||||
|
this._error = null;
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async _loadEncryptedFile(file) {
|
||||||
|
const buffer = await this._mediaRepository.downloadEncryptedFile(file);
|
||||||
|
if (this.isDisposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return this.track(this.platform.createBufferURL(buffer, file.mimetype));
|
||||||
|
}
|
||||||
|
|
||||||
|
async load() {
|
||||||
|
try {
|
||||||
|
const thumbnailFile = this._getContent().info?.thumbnail_file;
|
||||||
|
const file = this._getContent().file;
|
||||||
|
if (thumbnailFile) {
|
||||||
|
this._decryptedThumbail = await this._loadEncryptedFile(thumbnailFile);
|
||||||
|
this.emitChange("thumbnailUrl");
|
||||||
|
} else if (file) {
|
||||||
|
this._decryptedImage = await this._loadEncryptedFile(file);
|
||||||
|
this.emitChange("thumbnailUrl");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this._error = err;
|
||||||
|
this.emitChange("error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
get thumbnailUrl() {
|
get thumbnailUrl() {
|
||||||
|
if (this._decryptedThumbail) {
|
||||||
|
return this._decryptedThumbail.url;
|
||||||
|
} else if (this._decryptedImage) {
|
||||||
|
return this._decryptedImage.url;
|
||||||
|
}
|
||||||
const mxcUrl = this._getContent()?.url;
|
const mxcUrl = this._getContent()?.url;
|
||||||
if (typeof mxcUrl === "string") {
|
if (typeof mxcUrl === "string") {
|
||||||
return this._mediaRepository.mxcUrlThumbnail(mxcUrl, this.thumbnailWidth, this.thumbnailHeight, "scale");
|
return this._mediaRepository.mxcUrlThumbnail(mxcUrl, this.thumbnailWidth, this.thumbnailHeight, "scale");
|
||||||
}
|
}
|
||||||
return null;
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
get url() {
|
async loadImageUrl() {
|
||||||
const mxcUrl = this._getContent()?.url;
|
if (!this._decryptedImage) {
|
||||||
if (typeof mxcUrl === "string") {
|
const file = this._getContent().file;
|
||||||
return this._mediaRepository.mxcUrl(mxcUrl);
|
if (file) {
|
||||||
|
this._decryptedImage = await this._loadEncryptedFile(file);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return this._decryptedImage?.url || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
_scaleFactor() {
|
_scaleFactor() {
|
||||||
|
@ -59,6 +100,13 @@ export class ImageTile extends MessageTile {
|
||||||
return this._getContent().body;
|
return this._getContent().body;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get error() {
|
||||||
|
if (this._error) {
|
||||||
|
return `Could not decrypt image: ${this._error.message}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
get shape() {
|
get shape() {
|
||||||
return "image";
|
return "image";
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,9 +18,9 @@ import {UpdateAction} from "../UpdateAction.js";
|
||||||
import {ViewModel} from "../../../../ViewModel.js";
|
import {ViewModel} from "../../../../ViewModel.js";
|
||||||
|
|
||||||
export class SimpleTile extends ViewModel {
|
export class SimpleTile extends ViewModel {
|
||||||
constructor({entry}) {
|
constructor(options) {
|
||||||
super();
|
super(options);
|
||||||
this._entry = entry;
|
this._entry = options.entry;
|
||||||
}
|
}
|
||||||
// view model props for all subclasses
|
// view model props for all subclasses
|
||||||
// hmmm, could also do instanceof ... ?
|
// hmmm, could also do instanceof ... ?
|
||||||
|
|
|
@ -165,14 +165,19 @@ export class SessionContainer {
|
||||||
}
|
}
|
||||||
this._requestScheduler = new RequestScheduler({hsApi, clock});
|
this._requestScheduler = new RequestScheduler({hsApi, clock});
|
||||||
this._requestScheduler.start();
|
this._requestScheduler.start();
|
||||||
|
const mediaRepository = new MediaRepository({
|
||||||
|
homeServer: sessionInfo.homeServer,
|
||||||
|
crypto: this._platform.crypto,
|
||||||
|
request: this._platform.request,
|
||||||
|
});
|
||||||
this._session = new Session({
|
this._session = new Session({
|
||||||
storage: this._storage,
|
storage: this._storage,
|
||||||
sessionInfo: filteredSessionInfo,
|
sessionInfo: filteredSessionInfo,
|
||||||
hsApi: this._requestScheduler.hsApi,
|
hsApi: this._requestScheduler.hsApi,
|
||||||
olm,
|
olm,
|
||||||
olmWorker,
|
olmWorker,
|
||||||
|
mediaRepository,
|
||||||
platform: this._platform,
|
platform: this._platform,
|
||||||
mediaRepository: new MediaRepository(sessionInfo.homeServer)
|
|
||||||
});
|
});
|
||||||
await this._session.load();
|
await this._session.load();
|
||||||
if (isNewLogin) {
|
if (isNewLogin) {
|
||||||
|
|
58
src/matrix/e2ee/attachment.js
Normal file
58
src/matrix/e2ee/attachment.js
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
/*
|
||||||
|
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import base64 from "../../../lib/base64-arraybuffer/index.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt an attachment.
|
||||||
|
* @param {ArrayBuffer} ciphertextBuffer The encrypted attachment data buffer.
|
||||||
|
* @param {Object} info The information needed to decrypt the attachment.
|
||||||
|
* @param {Object} info.key AES-CTR JWK key object.
|
||||||
|
* @param {string} info.iv Base64 encoded 16 byte AES-CTR IV.
|
||||||
|
* @param {string} info.hashes.sha256 Base64 encoded SHA-256 hash of the ciphertext.
|
||||||
|
* @return {Promise} A promise that resolves with an ArrayBuffer when the attachment is decrypted.
|
||||||
|
*/
|
||||||
|
export async function decryptAttachment(crypto, ciphertextBuffer, info) {
|
||||||
|
if (info === undefined || info.key === undefined || info.iv === undefined
|
||||||
|
|| info.hashes === undefined || info.hashes.sha256 === undefined) {
|
||||||
|
throw new Error("Invalid info. Missing info.key, info.iv or info.hashes.sha256 key");
|
||||||
|
}
|
||||||
|
|
||||||
|
var ivArray = base64.decode(info.iv);
|
||||||
|
// re-encode to not deal with padded vs unpadded
|
||||||
|
var expectedSha256base64 = base64.encode(base64.decode(info.hashes.sha256));
|
||||||
|
// Check the sha256 hash
|
||||||
|
const digestResult = await crypto.digest("SHA-256", ciphertextBuffer);
|
||||||
|
if (base64.encode(new Uint8Array(digestResult)) != expectedSha256base64) {
|
||||||
|
throw new Error("Mismatched SHA-256 digest");
|
||||||
|
}
|
||||||
|
var counterLength;
|
||||||
|
if (info.v == "v1" || info.v == "v2") {
|
||||||
|
// Version 1 and 2 use a 64 bit counter.
|
||||||
|
counterLength = 64;
|
||||||
|
} else {
|
||||||
|
// Version 0 uses a 128 bit counter.
|
||||||
|
counterLength = 128;
|
||||||
|
}
|
||||||
|
|
||||||
|
const decryptedBuffer = await crypto.aes.decryptCTR({
|
||||||
|
jwkKey: info.key,
|
||||||
|
iv: ivArray,
|
||||||
|
data: ciphertextBuffer,
|
||||||
|
counterLength
|
||||||
|
});
|
||||||
|
return decryptedBuffer;
|
||||||
|
}
|
|
@ -75,7 +75,8 @@ export class HomeServerApi {
|
||||||
method,
|
method,
|
||||||
headers,
|
headers,
|
||||||
body: bodyString,
|
body: bodyString,
|
||||||
timeout: options?.timeout
|
timeout: options?.timeout,
|
||||||
|
format: "json"
|
||||||
});
|
});
|
||||||
|
|
||||||
const wrapper = new RequestWrapper(method, url, requestResult);
|
const wrapper = new RequestWrapper(method, url, requestResult);
|
||||||
|
|
|
@ -15,17 +15,20 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {encodeQueryParams} from "./common.js";
|
import {encodeQueryParams} from "./common.js";
|
||||||
|
import {decryptAttachment} from "../e2ee/attachment.js";
|
||||||
|
|
||||||
export class MediaRepository {
|
export class MediaRepository {
|
||||||
constructor(homeserver) {
|
constructor({homeServer, crypto, request}) {
|
||||||
this._homeserver = homeserver;
|
this._homeServer = homeServer;
|
||||||
|
this._crypto = crypto;
|
||||||
|
this._request = request;
|
||||||
}
|
}
|
||||||
|
|
||||||
mxcUrlThumbnail(url, width, height, method) {
|
mxcUrlThumbnail(url, width, height, method) {
|
||||||
const parts = this._parseMxcUrl(url);
|
const parts = this._parseMxcUrl(url);
|
||||||
if (parts) {
|
if (parts) {
|
||||||
const [serverName, mediaId] = parts;
|
const [serverName, mediaId] = parts;
|
||||||
const httpUrl = `${this._homeserver}/_matrix/media/r0/thumbnail/${encodeURIComponent(serverName)}/${encodeURIComponent(mediaId)}`;
|
const httpUrl = `${this._homeServer}/_matrix/media/r0/thumbnail/${encodeURIComponent(serverName)}/${encodeURIComponent(mediaId)}`;
|
||||||
return httpUrl + "?" + encodeQueryParams({width, height, method});
|
return httpUrl + "?" + encodeQueryParams({width, height, method});
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
@ -35,7 +38,7 @@ export class MediaRepository {
|
||||||
const parts = this._parseMxcUrl(url);
|
const parts = this._parseMxcUrl(url);
|
||||||
if (parts) {
|
if (parts) {
|
||||||
const [serverName, mediaId] = parts;
|
const [serverName, mediaId] = parts;
|
||||||
return `${this._homeserver}/_matrix/media/r0/download/${encodeURIComponent(serverName)}/${encodeURIComponent(mediaId)}`;
|
return `${this._homeServer}/_matrix/media/r0/download/${encodeURIComponent(serverName)}/${encodeURIComponent(mediaId)}`;
|
||||||
} else {
|
} else {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@ -49,4 +52,11 @@ export class MediaRepository {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async downloadEncryptedFile(fileEntry) {
|
||||||
|
const url = this.mxcUrl(fileEntry.url);
|
||||||
|
const {body: encryptedBuffer} = await this._request(url, {method: "GET", format: "buffer", cache: true}).response();
|
||||||
|
const decryptedBuffer = await decryptAttachment(this._crypto, encryptedBuffer, fileEntry);
|
||||||
|
return decryptedBuffer;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -64,8 +64,11 @@ export class SecretStorage {
|
||||||
throw new Error("Bad MAC");
|
throw new Error("Bad MAC");
|
||||||
}
|
}
|
||||||
|
|
||||||
const plaintextBytes = await this._crypto.aes.decrypt(
|
const plaintextBytes = await this._crypto.aes.decryptCTR({
|
||||||
aesKey, base64.decode(encryptedData.iv), ciphertextBytes);
|
key: aesKey,
|
||||||
|
iv: base64.decode(encryptedData.iv),
|
||||||
|
data: ciphertextBytes
|
||||||
|
});
|
||||||
|
|
||||||
return textDecoder.decode(plaintextBytes);
|
return textDecoder.decode(plaintextBytes);
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,6 +27,7 @@ import {OnlineStatus} from "./dom/OnlineStatus.js";
|
||||||
import {Crypto} from "./dom/Crypto.js";
|
import {Crypto} from "./dom/Crypto.js";
|
||||||
import {estimateStorageUsage} from "./dom/StorageEstimate.js";
|
import {estimateStorageUsage} from "./dom/StorageEstimate.js";
|
||||||
import {WorkerPool} from "./dom/WorkerPool.js";
|
import {WorkerPool} from "./dom/WorkerPool.js";
|
||||||
|
import {BufferURL} from "./dom/BufferURL.js";
|
||||||
|
|
||||||
function addScript(src) {
|
function addScript(src) {
|
||||||
return new Promise(function (resolve, reject) {
|
return new Promise(function (resolve, reject) {
|
||||||
|
@ -127,4 +128,8 @@ export class Platform {
|
||||||
setNavigation(navigation) {
|
setNavigation(navigation) {
|
||||||
this._serviceWorkerHandler?.setNavigation(navigation);
|
this._serviceWorkerHandler?.setNavigation(navigation);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createBufferURL(buffer, mimetype) {
|
||||||
|
return new BufferURL(buffer, mimetype);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
86
src/platform/web/dom/BufferURL.js
Normal file
86
src/platform/web/dom/BufferURL.js
Normal file
|
@ -0,0 +1,86 @@
|
||||||
|
/*
|
||||||
|
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
// WARNING: We have to be very careful about what mime-types we allow into blobs.
|
||||||
|
//
|
||||||
|
// This means that the content is rendered using the origin of the script which
|
||||||
|
// called createObjectURL(), and so if the content contains any scripting then it
|
||||||
|
// will pose a XSS vulnerability when the browser renders it. This is particularly
|
||||||
|
// bad if the user right-clicks the URI and pastes it into a new window or tab,
|
||||||
|
// as the blob will then execute with access to Element's full JS environment(!)
|
||||||
|
//
|
||||||
|
// See https://github.com/matrix-org/matrix-react-sdk/pull/1820#issuecomment-385210647
|
||||||
|
// for details.
|
||||||
|
//
|
||||||
|
// We mitigate this by only allowing mime-types into blobs which we know don't
|
||||||
|
// contain any scripting, and instantiate all others as application/octet-stream
|
||||||
|
// regardless of what mime-type the event claimed. Even if the payload itself
|
||||||
|
// is some malicious HTML, the fact we instantiate it with a media mimetype or
|
||||||
|
// application/octet-stream means the browser doesn't try to render it as such.
|
||||||
|
//
|
||||||
|
// One interesting edge case is image/svg+xml, which empirically *is* rendered
|
||||||
|
// correctly if the blob is set to the src attribute of an img tag (for thumbnails)
|
||||||
|
// *even if the mimetype is application/octet-stream*. However, empirically JS
|
||||||
|
// in the SVG isn't executed in this scenario, so we seem to be okay.
|
||||||
|
//
|
||||||
|
// Tested on Chrome 65 and Firefox 60
|
||||||
|
//
|
||||||
|
// The list below is taken mainly from
|
||||||
|
// https://developer.mozilla.org/en-US/docs/Web/HTML/Supported_media_formats
|
||||||
|
// N.B. Matrix doesn't currently specify which mimetypes are valid in given
|
||||||
|
// events, so we pick the ones which HTML5 browsers should be able to display
|
||||||
|
//
|
||||||
|
// For the record, mime-types which must NEVER enter this list below include:
|
||||||
|
// text/html, text/xhtml, image/svg, image/svg+xml, image/pdf, and similar.
|
||||||
|
|
||||||
|
const ALLOWED_BLOB_MIMETYPES = {
|
||||||
|
'image/jpeg': true,
|
||||||
|
'image/gif': true,
|
||||||
|
'image/png': true,
|
||||||
|
|
||||||
|
'video/mp4': true,
|
||||||
|
'video/webm': true,
|
||||||
|
'video/ogg': true,
|
||||||
|
|
||||||
|
'audio/mp4': true,
|
||||||
|
'audio/webm': true,
|
||||||
|
'audio/aac': true,
|
||||||
|
'audio/mpeg': true,
|
||||||
|
'audio/ogg': true,
|
||||||
|
'audio/wave': true,
|
||||||
|
'audio/wav': true,
|
||||||
|
'audio/x-wav': true,
|
||||||
|
'audio/x-pn-wav': true,
|
||||||
|
'audio/flac': true,
|
||||||
|
'audio/x-flac': true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export class BufferURL {
|
||||||
|
constructor(buffer, mimetype) {
|
||||||
|
mimetype = mimetype ? mimetype.split(";")[0].trim() : '';
|
||||||
|
if (!ALLOWED_BLOB_MIMETYPES[mimetype]) {
|
||||||
|
mimetype = 'application/octet-stream';
|
||||||
|
}
|
||||||
|
const blob = new Blob([buffer], {type: mimetype});
|
||||||
|
this.url = URL.createObjectURL(blob);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
URL.revokeObjectURL(this.url);
|
||||||
|
this.url = null;
|
||||||
|
}
|
||||||
|
}
|
|
@ -159,21 +159,25 @@ class AESCrypto {
|
||||||
/**
|
/**
|
||||||
* [decrypt description]
|
* [decrypt description]
|
||||||
* @param {BufferSource} key [description]
|
* @param {BufferSource} key [description]
|
||||||
|
* @param {Object} jwkKey [description]
|
||||||
* @param {BufferSource} iv [description]
|
* @param {BufferSource} iv [description]
|
||||||
* @param {BufferSource} ciphertext [description]
|
* @param {BufferSource} data [description]
|
||||||
|
* @param {Number} counterLength the size of the counter, in bits
|
||||||
* @return {BufferSource} [description]
|
* @return {BufferSource} [description]
|
||||||
*/
|
*/
|
||||||
async decrypt(key, iv, ciphertext) {
|
async decryptCTR({key, jwkKey, iv, data, counterLength = 64}) {
|
||||||
const opts = {
|
const opts = {
|
||||||
name: "AES-CTR",
|
name: "AES-CTR",
|
||||||
counter: iv,
|
counter: iv,
|
||||||
length: 64,
|
length: counterLength,
|
||||||
};
|
};
|
||||||
let aesKey;
|
let aesKey;
|
||||||
try {
|
try {
|
||||||
|
const selectedKey = key || jwkKey;
|
||||||
|
const format = jwkKey ? "jwk" : "raw";
|
||||||
aesKey = await subtleCryptoResult(this._subtleCrypto.importKey(
|
aesKey = await subtleCryptoResult(this._subtleCrypto.importKey(
|
||||||
'raw',
|
format,
|
||||||
key,
|
selectedKey,
|
||||||
opts,
|
opts,
|
||||||
false,
|
false,
|
||||||
['decrypt'],
|
['decrypt'],
|
||||||
|
@ -186,7 +190,7 @@ class AESCrypto {
|
||||||
// see https://developer.mozilla.org/en-US/docs/Web/API/AesCtrParams
|
// see https://developer.mozilla.org/en-US/docs/Web/API/AesCtrParams
|
||||||
opts,
|
opts,
|
||||||
aesKey,
|
aesKey,
|
||||||
ciphertext,
|
data,
|
||||||
), "decrypt");
|
), "decrypt");
|
||||||
return new Uint8Array(plaintext);
|
return new Uint8Array(plaintext);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
@ -196,6 +200,8 @@ class AESCrypto {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
import base64 from "../../../../lib/base64-arraybuffer/index.js";
|
||||||
|
|
||||||
class AESLegacyCrypto {
|
class AESLegacyCrypto {
|
||||||
constructor(aesjs) {
|
constructor(aesjs) {
|
||||||
this._aesjs = aesjs;
|
this._aesjs = aesjs;
|
||||||
|
@ -205,12 +211,31 @@ class AESLegacyCrypto {
|
||||||
* @param {BufferSource} key [description]
|
* @param {BufferSource} key [description]
|
||||||
* @param {BufferSource} iv [description]
|
* @param {BufferSource} iv [description]
|
||||||
* @param {BufferSource} ciphertext [description]
|
* @param {BufferSource} ciphertext [description]
|
||||||
|
* @param {Number} counterLength the size of the counter, in bits
|
||||||
* @return {BufferSource} [description]
|
* @return {BufferSource} [description]
|
||||||
*/
|
*/
|
||||||
async decrypt(key, iv, ciphertext) {
|
async decryptCTR({key, jwkKey, iv, data, counterLength = 64}) {
|
||||||
|
if (counterLength !== 64) {
|
||||||
|
throw new Error(`Unsupported counter length: ${counterLength}`);
|
||||||
|
}
|
||||||
|
if (jwkKey) {
|
||||||
|
if (jwkKey.alg !== "A256CTR") {
|
||||||
|
throw new Error(`Unknown algorithm: ${jwkKey.alg}`);
|
||||||
|
}
|
||||||
|
if (!jwkKey.key_ops.includes("decrypt")) {
|
||||||
|
throw new Error(`decrypt missing from key_ops`);
|
||||||
|
}
|
||||||
|
if (jwkKey.kty !== "oct") {
|
||||||
|
throw new Error(`Invalid key type, "oct" expected: ${jwkKey.kty}`);
|
||||||
|
}
|
||||||
|
// convert base64-url to normal base64
|
||||||
|
const base64UrlKey = jwkKey.k;
|
||||||
|
const base64Key = base64UrlKey.replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
key = base64.decode(base64Key);
|
||||||
|
}
|
||||||
const aesjs = this._aesjs;
|
const aesjs = this._aesjs;
|
||||||
var aesCtr = new aesjs.ModeOfOperation.ctr(new Uint8Array(key), new aesjs.Counter(new Uint8Array(iv)));
|
var aesCtr = new aesjs.ModeOfOperation.ctr(new Uint8Array(key), new aesjs.Counter(new Uint8Array(iv)));
|
||||||
return aesCtr.decrypt(new Uint8Array(ciphertext));
|
return aesCtr.decrypt(new Uint8Array(data));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -51,14 +51,17 @@ class RequestResult {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createFetchRequest(createTimeout) {
|
export function createFetchRequest(createTimeout) {
|
||||||
return function fetchRequest(url, options) {
|
return function fetchRequest(url, {method, headers, body, timeout, format, cache = false}) {
|
||||||
const controller = typeof AbortController === "function" ? new AbortController() : null;
|
const controller = typeof AbortController === "function" ? new AbortController() : null;
|
||||||
|
let options = {method, body};
|
||||||
if (controller) {
|
if (controller) {
|
||||||
options = Object.assign(options, {
|
options = Object.assign(options, {
|
||||||
signal: controller.signal
|
signal: controller.signal
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
url = addCacheBuster(url);
|
if (!cache) {
|
||||||
|
url = addCacheBuster(url);
|
||||||
|
}
|
||||||
options = Object.assign(options, {
|
options = Object.assign(options, {
|
||||||
mode: "cors",
|
mode: "cors",
|
||||||
credentials: "omit",
|
credentials: "omit",
|
||||||
|
@ -76,18 +79,22 @@ export function createFetchRequest(createTimeout) {
|
||||||
// cache: "no-store",
|
// cache: "no-store",
|
||||||
cache: "default",
|
cache: "default",
|
||||||
});
|
});
|
||||||
if (options.headers) {
|
if (headers) {
|
||||||
const headers = new Headers();
|
const fetchHeaders = new Headers();
|
||||||
for(const [name, value] of options.headers.entries()) {
|
for(const [name, value] of headers.entries()) {
|
||||||
headers.append(name, value);
|
fetchHeaders.append(name, value);
|
||||||
}
|
}
|
||||||
options.headers = headers;
|
options.headers = fetchHeaders;
|
||||||
}
|
}
|
||||||
const promise = fetch(url, options).then(async response => {
|
const promise = fetch(url, options).then(async response => {
|
||||||
const {status} = response;
|
const {status} = response;
|
||||||
let body;
|
let body;
|
||||||
try {
|
try {
|
||||||
body = await response.json();
|
if (format === "json") {
|
||||||
|
body = await response.json();
|
||||||
|
} else if (format === "buffer") {
|
||||||
|
body = await response.arrayBuffer();
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// some error pages return html instead of json, ignore error
|
// some error pages return html instead of json, ignore error
|
||||||
if (!(err.name === "SyntaxError" && status >= 400)) {
|
if (!(err.name === "SyntaxError" && status >= 400)) {
|
||||||
|
@ -105,14 +112,14 @@ export function createFetchRequest(createTimeout) {
|
||||||
//
|
//
|
||||||
// One could check navigator.onLine to rule out the first
|
// One could check navigator.onLine to rule out the first
|
||||||
// but the 2 latter ones are indistinguishable from javascript.
|
// but the 2 latter ones are indistinguishable from javascript.
|
||||||
throw new ConnectionError(`${options.method} ${url}: ${err.message}`);
|
throw new ConnectionError(`${method} ${url}: ${err.message}`);
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
});
|
});
|
||||||
const result = new RequestResult(promise, controller);
|
const result = new RequestResult(promise, controller);
|
||||||
|
|
||||||
if (options.timeout) {
|
if (timeout) {
|
||||||
result.promise = abortOnTimeout(createTimeout, options.timeout, result, result.promise);
|
result.promise = abortOnTimeout(createTimeout, timeout, result, result.promise);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|
|
@ -35,19 +35,24 @@ class RequestResult {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function send(url, options) {
|
function send(url, {method, headers, timeout, body, format}) {
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
xhr.open(options.method, url);
|
xhr.open(method, url);
|
||||||
if (options.headers) {
|
|
||||||
for(const [name, value] of options.headers.entries()) {
|
if (format === "buffer") {
|
||||||
|
// important to call this after calling open
|
||||||
|
xhr.responseType = "arraybuffer";
|
||||||
|
}
|
||||||
|
if (headers) {
|
||||||
|
for(const [name, value] of headers.entries()) {
|
||||||
xhr.setRequestHeader(name, value);
|
xhr.setRequestHeader(name, value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (options.timeout) {
|
if (timeout) {
|
||||||
xhr.timeout = options.timeout;
|
xhr.timeout = timeout;
|
||||||
}
|
}
|
||||||
|
|
||||||
xhr.send(options.body || null);
|
xhr.send(body || null);
|
||||||
|
|
||||||
return xhr;
|
return xhr;
|
||||||
}
|
}
|
||||||
|
@ -62,12 +67,17 @@ function xhrAsPromise(xhr, method, url) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function xhrRequest(url, options) {
|
export function xhrRequest(url, options) {
|
||||||
url = addCacheBuster(url);
|
const {cache, format} = options;
|
||||||
|
if (!cache) {
|
||||||
|
url = addCacheBuster(url);
|
||||||
|
}
|
||||||
const xhr = send(url, options);
|
const xhr = send(url, options);
|
||||||
const promise = xhrAsPromise(xhr, options.method, url).then(xhr => {
|
const promise = xhrAsPromise(xhr, options.method, url).then(xhr => {
|
||||||
const {status} = xhr;
|
const {status} = xhr;
|
||||||
let body = null;
|
let body = null;
|
||||||
if (xhr.getResponseHeader("Content-Type") === "application/json") {
|
if (format === "buffer") {
|
||||||
|
body = xhr.response;
|
||||||
|
} else if (xhr.getResponseHeader("Content-Type") === "application/json") {
|
||||||
body = JSON.parse(xhr.responseText);
|
body = JSON.parse(xhr.responseText);
|
||||||
}
|
}
|
||||||
return {status, body};
|
return {status, body};
|
||||||
|
|
|
@ -505,6 +505,11 @@ ul.Timeline > li.messageStatus .message-container > p {
|
||||||
--avatar-size: 25px;
|
--avatar-size: 25px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-container img.picture {
|
||||||
|
margin-top: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.TextMessageView.continuation .message-container {
|
.TextMessageView.continuation .message-container {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
|
@ -608,7 +613,7 @@ ul.Timeline > li.messageStatus .message-container > p {
|
||||||
flex: 0 0 200px;
|
flex: 0 0 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.Settings .error {
|
.error {
|
||||||
color: red;
|
color: red;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
|
@ -144,6 +144,19 @@ export class ListView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
recreateItem(index, value) {
|
||||||
|
if (this._childInstances) {
|
||||||
|
const child = this._childCreator(value);
|
||||||
|
if (!child) {
|
||||||
|
this.onRemove(index, value);
|
||||||
|
} else {
|
||||||
|
const [oldChild] = this._childInstances.splice(index, 1, child);
|
||||||
|
this._root.replaceChild(child.mount(this._mountArgs), oldChild.root());
|
||||||
|
oldChild.unmount();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onBeforeListChanged() {}
|
onBeforeListChanged() {}
|
||||||
onListChanged() {}
|
onListChanged() {}
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,6 +20,17 @@ import {TextMessageView} from "./timeline/TextMessageView.js";
|
||||||
import {ImageView} from "./timeline/ImageView.js";
|
import {ImageView} from "./timeline/ImageView.js";
|
||||||
import {AnnouncementView} from "./timeline/AnnouncementView.js";
|
import {AnnouncementView} from "./timeline/AnnouncementView.js";
|
||||||
|
|
||||||
|
function viewClassForEntry(entry) {
|
||||||
|
switch (entry.shape) {
|
||||||
|
case "gap": return GapView;
|
||||||
|
case "announcement": return AnnouncementView;
|
||||||
|
case "message":
|
||||||
|
case "message-status":
|
||||||
|
return TextMessageView;
|
||||||
|
case "image": return ImageView;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class TimelineList extends ListView {
|
export class TimelineList extends ListView {
|
||||||
constructor(viewModel) {
|
constructor(viewModel) {
|
||||||
const options = {
|
const options = {
|
||||||
|
@ -27,13 +38,9 @@ export class TimelineList extends ListView {
|
||||||
list: viewModel.tiles,
|
list: viewModel.tiles,
|
||||||
}
|
}
|
||||||
super(options, entry => {
|
super(options, entry => {
|
||||||
switch (entry.shape) {
|
const View = viewClassForEntry(entry);
|
||||||
case "gap": return new GapView(entry);
|
if (View) {
|
||||||
case "announcement": return new AnnouncementView(entry);
|
return new View(entry);
|
||||||
case "message":
|
|
||||||
case "message-status":
|
|
||||||
return new TextMessageView(entry);
|
|
||||||
case "image": return new ImageView(entry);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
this._atBottom = false;
|
this._atBottom = false;
|
||||||
|
@ -127,4 +134,21 @@ export class TimelineList extends ListView {
|
||||||
root.scrollTop = root.scrollHeight;
|
root.scrollTop = root.scrollHeight;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onUpdate(index, value, param) {
|
||||||
|
if (param === "shape") {
|
||||||
|
if (this._childInstances) {
|
||||||
|
const ExpectedClass = viewClassForEntry(value);
|
||||||
|
const child = this._childInstances[index];
|
||||||
|
if (!ExpectedClass || !(child instanceof ExpectedClass)) {
|
||||||
|
// shape was updated, so we need to recreate the tile view,
|
||||||
|
// the shape parameter is set in EncryptedEventTile.updateEntry
|
||||||
|
// (and perhaps elsewhere by the time you read this)
|
||||||
|
super.recreateItem(index, value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
super.onUpdate(index, value, param);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,17 +23,19 @@ export class ImageView extends TemplateView {
|
||||||
const heightRatioPercent = (vm.thumbnailHeight / vm.thumbnailWidth) * 100;
|
const heightRatioPercent = (vm.thumbnailHeight / vm.thumbnailWidth) * 100;
|
||||||
const image = t.img({
|
const image = t.img({
|
||||||
className: "picture",
|
className: "picture",
|
||||||
src: vm.thumbnailUrl,
|
src: vm => vm.thumbnailUrl,
|
||||||
width: vm.thumbnailWidth,
|
width: vm.thumbnailWidth,
|
||||||
height: vm.thumbnailHeight,
|
height: vm.thumbnailHeight,
|
||||||
loading: "lazy",
|
loading: "lazy",
|
||||||
alt: vm.label,
|
alt: vm => vm.label,
|
||||||
|
title: vm => vm.label,
|
||||||
});
|
});
|
||||||
const linkContainer = t.a({
|
const linkContainer = t.a({
|
||||||
href: vm.url,
|
|
||||||
target: "_blank",
|
|
||||||
style: `padding-top: ${heightRatioPercent}%; width: ${vm.thumbnailWidth}px;`
|
style: `padding-top: ${heightRatioPercent}%; width: ${vm.thumbnailWidth}px;`
|
||||||
}, image);
|
}, [
|
||||||
|
image,
|
||||||
|
t.if(vm => vm.error, t.createTemplate((t, vm) => t.p({className: "error"}, vm.error)))
|
||||||
|
]);
|
||||||
|
|
||||||
return renderMessage(t, vm,
|
return renderMessage(t, vm,
|
||||||
[t.div(linkContainer), t.p(t.time(vm.date + " " + vm.time))]
|
[t.div(linkContainer), t.p(t.time(vm.date + " " + vm.time))]
|
||||||
|
|
Loading…
Reference in a new issue