diff --git a/prototypes/ie11-textdecoder.html b/prototypes/ie11-textdecoder.html new file mode 100644 index 00000000..fbd02475 --- /dev/null +++ b/prototypes/ie11-textdecoder.html @@ -0,0 +1,23 @@ + +
+ + + + + + + diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index e2a092ae..c13dd1a3 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -164,6 +164,21 @@ export class RoomViewModel extends ViewModel { return false; } + async _sendFile() { + let file; + try { + file = await this.platform.openFile(); + } catch (err) { + return; + } + const attachment = this._room.uploadAttachment(file.blob, file.name); + const content = { + body: file.name, + msgtype: "m.file", + }; + await this._room.sendEvent("m.room.message", content, attachment); + } + get composerViewModel() { return this._composerVM; } @@ -189,6 +204,10 @@ class ComposerViewModel extends ViewModel { return success; } + sendAttachment() { + this._roomVM._sendFile(); + } + get canSend() { return !this._isEmpty; } diff --git a/src/domain/session/room/timeline/tiles/FileTile.js b/src/domain/session/room/timeline/tiles/FileTile.js index b2afd728..eab55136 100644 --- a/src/domain/session/room/timeline/tiles/FileTile.js +++ b/src/domain/session/room/timeline/tiles/FileTile.js @@ -23,37 +23,62 @@ export class FileTile extends MessageTile { super(options); this._error = null; this._downloading = false; + if (this._isUploading) { + // should really do this with an ObservableValue and waitFor to prevent leaks when the promise never resolves + this._entry.attachment.uploaded().finally(() => { + if (!this.isDisposed) { + this.emitChange("label"); + } + }); + } } async download() { - if (this._downloading) { + if (this._downloading || this._isUploading) { return; } const content = this._getContent(); const filename = content.body; this._downloading = true; this.emitChange("label"); - let bufferHandle; + let blob; try { - bufferHandle = await this._mediaRepository.downloadAttachment(content); - this.platform.offerSaveBufferHandle(bufferHandle, filename); + blob = await this._mediaRepository.downloadAttachment(content); + this.platform.saveFileAs(blob, filename); } catch (err) { this._error = err; } finally { - bufferHandle?.dispose(); + blob?.dispose(); this._downloading = false; } this.emitChange("label"); } + get size() { + if (this._isUploading) { + return this._entry.attachment.localPreview.size; + } else { + return this._getContent().info?.size; + } + } + + get _isUploading() { + return this._entry.attachment && !this._entry.attachment.isUploaded; + } + get label() { if (this._error) { return `Could not decrypt file: ${this._error.message}`; } + if (this._entry.attachment?.error) { + return `Failed to upload: ${this._entry.attachment.error.message}`; + } const content = this._getContent(); const filename = content.body; - const size = formatSize(content.info?.size); - if (this._downloading) { + const size = formatSize(this.size); + if (this._isUploading) { + return this.i18n`Uploading ${filename} (${size})…`; + } else if (this._downloading) { return this.i18n`Downloading ${filename} (${size})…`; } else { return this.i18n`Download ${filename} (${size})`; diff --git a/src/domain/session/room/timeline/tiles/ImageTile.js b/src/domain/session/room/timeline/tiles/ImageTile.js index 71b5b9d6..6d518b11 100644 --- a/src/domain/session/room/timeline/tiles/ImageTile.js +++ b/src/domain/session/room/timeline/tiles/ImageTile.js @@ -35,12 +35,12 @@ export class ImageTile extends MessageTile { } async _loadEncryptedFile(file) { - const bufferHandle = await this._mediaRepository.downloadEncryptedFile(file, true); + const blob = await this._mediaRepository.downloadEncryptedFile(file, true); if (this.isDisposed) { - bufferHandle.dispose(); + blob.dispose(); return; } - return this.track(bufferHandle); + return this.track(blob); } async load() { diff --git a/src/matrix/Session.js b/src/matrix/Session.js index 15487ec5..ace33fa8 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -362,7 +362,7 @@ export class Session { pendingEvents, user: this._user, createRoomEncryption: this._createRoomEncryption, - clock: this._platform.clock + platform: this._platform }); this._rooms.add(roomId, room); return room; diff --git a/src/matrix/e2ee/attachment.js b/src/matrix/e2ee/attachment.js index 408e04fe..105c16c5 100644 --- a/src/matrix/e2ee/attachment.js +++ b/src/matrix/e2ee/attachment.js @@ -56,3 +56,33 @@ export async function decryptAttachment(crypto, ciphertextBuffer, info) { }); return decryptedBuffer; } + +export async function encryptAttachment(platform, blob) { + const {crypto} = platform; + const iv = await crypto.aes.generateIV(); + const key = await crypto.aes.generateKey("jwk", 256); + const buffer = await blob.readAsBuffer(); + const ciphertext = await crypto.aes.encryptCTR({jwkKey: key, iv, data: buffer}); + const digest = await crypto.digest("SHA-256", ciphertext); + return { + blob: platform.createBlob(ciphertext, blob.mimeType), + info: { + v: "v2", + key, + iv: encodeUnpaddedBase64(iv), + hashes: { + sha256: encodeUnpaddedBase64(digest) + } + } + }; +} + +function encodeUnpaddedBase64(buffer) { + const str = base64.encode(buffer); + const paddingIdx = str.indexOf("="); + if (paddingIdx !== -1) { + return str.substr(0, paddingIdx); + } else { + return str; + } +} diff --git a/src/matrix/net/HomeServerApi.js b/src/matrix/net/HomeServerApi.js index e6c97433..bdf35363 100644 --- a/src/matrix/net/HomeServerApi.js +++ b/src/matrix/net/HomeServerApi.js @@ -55,6 +55,26 @@ class RequestWrapper { } } +function encodeBody(body) { + if (body.nativeBlob && body.mimeType) { + const blob = body; + return { + mimeType: blob.mimeType, + body: blob, // will be unwrapped in request fn + length: blob.size + }; + } else if (typeof body === "object") { + const json = JSON.stringify(body); + return { + mimeType: "application/json", + body: json, + length: body.length + }; + } else { + throw new Error("Unknown body type: " + body); + } +} + export class HomeServerApi { constructor({homeServer, accessToken, request, createTimeout, reconnector}) { // store these both in a closure somehow so it's harder to get at in case of XSS? @@ -73,22 +93,24 @@ export class HomeServerApi { _baseRequest(method, url, queryParams, body, options, accessToken) { const queryString = encodeQueryParams(queryParams); url = `${url}?${queryString}`; - let bodyString; + let encodedBody; const headers = new Map(); if (accessToken) { headers.set("Authorization", `Bearer ${accessToken}`); } headers.set("Accept", "application/json"); if (body) { - headers.set("Content-Type", "application/json"); - bodyString = JSON.stringify(body); + const encoded = encodeBody(body); + headers.set("Content-Type", encoded.mimeType); + headers.set("Content-Length", encoded.length); + encodedBody = encoded.body; } const requestResult = this._requestFn(url, { method, headers, - body: bodyString, + body: encodedBody, timeout: options?.timeout, - format: "json" + format: "json" // response format }); const wrapper = new RequestWrapper(method, url, requestResult); @@ -198,6 +220,10 @@ export class HomeServerApi { roomKeyForRoomAndSession(version, roomId, sessionId, options = null) { return this._get(`/room_keys/keys/${encodeURIComponent(roomId)}/${encodeURIComponent(sessionId)}`, {version}, null, options); } + + uploadAttachment(blob, filename, options = null) { + return this._authedRequest("POST", `${this._homeserver}/_matrix/media/r0/upload`, {filename}, blob, options); + } } export function tests() { diff --git a/src/matrix/net/MediaRepository.js b/src/matrix/net/MediaRepository.js index f04c387f..2e7ec438 100644 --- a/src/matrix/net/MediaRepository.js +++ b/src/matrix/net/MediaRepository.js @@ -56,13 +56,13 @@ export class MediaRepository { const url = this.mxcUrl(fileEntry.url); const {body: encryptedBuffer} = await this._platform.request(url, {method: "GET", format: "buffer", cache}).response(); const decryptedBuffer = await decryptAttachment(this._platform.crypto, encryptedBuffer, fileEntry); - return this._platform.createBufferHandle(decryptedBuffer, fileEntry.mimetype); + return this._platform.createBlob(decryptedBuffer, fileEntry.mimetype); } async downloadPlaintextFile(mxcUrl, mimetype, cache = false) { const url = this.mxcUrl(mxcUrl); const {body: buffer} = await this._platform.request(url, {method: "GET", format: "buffer", cache}).response(); - return this._platform.createBufferHandle(buffer, mimetype); + return this._platform.createBlob(buffer, mimetype); } async downloadAttachment(content, cache = false) { @@ -72,5 +72,4 @@ export class MediaRepository { return this.downloadPlaintextFile(content.url, content.info?.mimetype, cache); } } - } diff --git a/src/matrix/room/AttachmentUpload.js b/src/matrix/room/AttachmentUpload.js new file mode 100644 index 00000000..98d81894 --- /dev/null +++ b/src/matrix/room/AttachmentUpload.js @@ -0,0 +1,108 @@ +/* +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 {encryptAttachment} from "../e2ee/attachment.js"; + +export class AttachmentUpload { + constructor({filename, blob, hsApi, platform, isEncrypted}) { + this._filename = filename; + this._unencryptedBlob = blob; + this._isEncrypted = isEncrypted; + this._platform = platform; + this._hsApi = hsApi; + this._mxcUrl = null; + this._transferredBlob = null; + this._encryptionInfo = null; + this._uploadPromise = null; + this._uploadRequest = null; + this._aborted = false; + this._error = null; + } + + upload() { + if (!this._uploadPromise) { + this._uploadPromise = this._upload(); + } + return this._uploadPromise; + } + + async _upload() { + try { + let transferredBlob = this._unencryptedBlob; + if (this._isEncrypted) { + const {info, blob} = await encryptAttachment(this._platform, this._unencryptedBlob); + transferredBlob = blob; + this._encryptionInfo = info; + } + if (this._aborted) { + return; + } + this._uploadRequest = this._hsApi.uploadAttachment(transferredBlob, this._filename); + const {content_uri} = await this._uploadRequest.response(); + this._mxcUrl = content_uri; + this._transferredBlob = transferredBlob; + } catch (err) { + this._error = err; + throw err; + } + } + + get isUploaded() { + return !!this._transferredBlob; + } + + /** @public */ + abort() { + this._aborted = true; + this._uploadRequest?.abort(); + } + + /** @public */ + get localPreview() { + return this._unencryptedBlob; + } + + get error() { + return this._error; + } + + /** @package */ + uploaded() { + if (!this._uploadPromise) { + throw new Error("upload has not started yet"); + } + return this._uploadPromise; + } + + /** @package */ + applyToContent(content) { + if (!this._mxcUrl) { + throw new Error("upload has not finished"); + } + content.info = { + size: this._transferredBlob.size, + mimetype: this._unencryptedBlob.mimeType, + }; + if (this._isEncrypted) { + content.file = Object.assign(this._encryptionInfo, { + mimetype: this._unencryptedBlob.mimeType, + url: this._mxcUrl + }); + } else { + content.url = this._mxcUrl; + } + } +} diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index 8427239c..109c096e 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -30,11 +30,13 @@ import {EventEntry} from "./timeline/entries/EventEntry.js"; import {EventKey} from "./timeline/EventKey.js"; import {Direction} from "./timeline/Direction.js"; import {ObservedEventMap} from "./ObservedEventMap.js"; +import {AttachmentUpload} from "./AttachmentUpload.js"; import {DecryptionSource} from "../e2ee/common.js"; + const EVENT_ENCRYPTED_TYPE = "m.room.encrypted"; export class Room extends EventEmitter { - constructor({roomId, storage, hsApi, mediaRepository, emitCollectionChange, pendingEvents, user, createRoomEncryption, getSyncToken, clock}) { + constructor({roomId, storage, hsApi, mediaRepository, emitCollectionChange, pendingEvents, user, createRoomEncryption, getSyncToken, platform}) { super(); this._roomId = roomId; this._storage = storage; @@ -52,7 +54,7 @@ export class Room extends EventEmitter { this._createRoomEncryption = createRoomEncryption; this._roomEncryption = null; this._getSyncToken = getSyncToken; - this._clock = clock; + this._platform = platform; this._observedEvents = null; } @@ -350,10 +352,11 @@ export class Room extends EventEmitter { } /** @public */ - sendEvent(eventType, content) { - return this._sendQueue.enqueueEvent(eventType, content); + sendEvent(eventType, content, attachment) { + return this._sendQueue.enqueueEvent(eventType, content, attachment); } + /** @public */ async ensureMessageKeyIsShared() { return this._roomEncryption?.ensureMessageKeyIsShared(this._hsApi); } @@ -569,7 +572,7 @@ export class Room extends EventEmitter { } }, user: this._user, - clock: this._clock + clock: this._platform.clock }); if (this._roomEncryption) { this._timeline.enableEncryption(this._decryptEntries.bind(this, DecryptionSource.Timeline)); @@ -630,6 +633,13 @@ export class Room extends EventEmitter { } } + uploadAttachment(blob, filename) { + const attachment = new AttachmentUpload({blob, filename, + hsApi: this._hsApi, platform: this._platform, isEncrypted: this.isEncrypted}); + attachment.upload(); + return attachment; + } + dispose() { this._roomEncryption?.dispose(); this._timeline?.dispose(); diff --git a/src/matrix/room/sending/PendingEvent.js b/src/matrix/room/sending/PendingEvent.js index fb2d1a47..030e57b6 100644 --- a/src/matrix/room/sending/PendingEvent.js +++ b/src/matrix/room/sending/PendingEvent.js @@ -15,8 +15,9 @@ limitations under the License. */ export class PendingEvent { - constructor(data) { + constructor(data, attachment) { this._data = data; + this.attachment = attachment; } get roomId() { return this._data.roomId; } diff --git a/src/matrix/room/sending/SendQueue.js b/src/matrix/room/sending/SendQueue.js index eba5fcf3..76ff2c66 100644 --- a/src/matrix/room/sending/SendQueue.js +++ b/src/matrix/room/sending/SendQueue.js @@ -51,6 +51,17 @@ export class SendQueue { this._amountSent += 1; continue; } + if (pendingEvent.attachment) { + const {attachment} = pendingEvent; + try { + await attachment.uploaded(); + } catch (err) { + console.log("upload failed, skip sending message", pendingEvent); + this._amountSent += 1; + continue; + } + attachment.applyToContent(pendingEvent.content); + } if (pendingEvent.needsEncryption) { const {type, content} = await this._roomEncryption.encrypt( pendingEvent.eventType, pendingEvent.content, this._hsApi); @@ -116,8 +127,8 @@ export class SendQueue { } } - async enqueueEvent(eventType, content) { - const pendingEvent = await this._createAndStoreEvent(eventType, content); + async enqueueEvent(eventType, content, attachment) { + const pendingEvent = await this._createAndStoreEvent(eventType, content, attachment); this._pendingEvents.set(pendingEvent); console.log("added to _pendingEvents set", this._pendingEvents.length); if (!this._isSending && !this._offline) { @@ -150,7 +161,7 @@ export class SendQueue { await txn.complete(); } - async _createAndStoreEvent(eventType, content) { + async _createAndStoreEvent(eventType, content, attachment) { console.log("_createAndStoreEvent"); const txn = this._storage.readWriteTxn([this._storage.storeNames.pendingEvents]); let pendingEvent; @@ -167,7 +178,7 @@ export class SendQueue { content, txnId: makeTxnId(), needsEncryption: !!this._roomEncryption - }); + }, attachment); console.log("_createAndStoreEvent: adding to pendingEventsStore"); pendingEventsStore.add(pendingEvent.data); } catch (err) { diff --git a/src/matrix/room/timeline/entries/PendingEventEntry.js b/src/matrix/room/timeline/entries/PendingEventEntry.js index f9376eab..9f91c80f 100644 --- a/src/matrix/room/timeline/entries/PendingEventEntry.js +++ b/src/matrix/room/timeline/entries/PendingEventEntry.js @@ -64,6 +64,10 @@ export class PendingEventEntry extends BaseEntry { return this._pendingEvent.txnId; } + get attachment() { + return this._pendingEvent.attachment; + } + notifyUpdate() { } diff --git a/src/platform/web/Platform.js b/src/platform/web/Platform.js index 745122d9..6a981c64 100644 --- a/src/platform/web/Platform.js +++ b/src/platform/web/Platform.js @@ -27,7 +27,7 @@ import {OnlineStatus} from "./dom/OnlineStatus.js"; import {Crypto} from "./dom/Crypto.js"; import {estimateStorageUsage} from "./dom/StorageEstimate.js"; import {WorkerPool} from "./dom/WorkerPool.js"; -import {BufferHandle} from "./dom/BufferHandle.js"; +import {BlobHandle} from "./dom/BlobHandle.js"; import {downloadInIframe} from "./dom/download.js"; function addScript(src) { @@ -131,15 +131,41 @@ export class Platform { this._serviceWorkerHandler?.setNavigation(navigation); } - createBufferHandle(buffer, mimetype) { - return new BufferHandle(buffer, mimetype); + createBlob(buffer, mimetype) { + return BlobHandle.fromBuffer(buffer, mimetype); } - offerSaveBufferHandle(bufferHandle, filename) { + saveFileAs(blobHandle, filename) { if (navigator.msSaveBlob) { - navigator.msSaveBlob(bufferHandle.blob, filename); + navigator.msSaveBlob(blobHandle.nativeBlob, filename); } else { - downloadInIframe(this._container, this._paths.downloadSandbox, bufferHandle.blob, filename); + downloadInIframe(this._container, this._paths.downloadSandbox, blobHandle.nativeBlob, filename); } } + + openFile(mimeType = null) { + const input = document.createElement("input"); + input.setAttribute("type", "file"); + input.className = "hidden"; + if (mimeType) { + input.setAttribute("accept", mimeType); + } + const promise = new Promise((resolve, reject) => { + const checkFile = () => { + input.removeEventListener("change", checkFile, true); + const file = input.files[0]; + this._container.removeChild(input); + if (file) { + resolve({name: file.name, blob: BlobHandle.fromFile(file)}); + } else { + reject(new Error("No file selected")); + } + } + input.addEventListener("change", checkFile, true); + }); + // IE11 needs the input to be attached to the document + this._container.appendChild(input); + input.click(); + return promise; + } } diff --git a/src/platform/web/dom/BufferHandle.js b/src/platform/web/dom/BlobHandle.js similarity index 66% rename from src/platform/web/dom/BufferHandle.js rename to src/platform/web/dom/BlobHandle.js index 80bb40bb..00098de1 100644 --- a/src/platform/web/dom/BufferHandle.js +++ b/src/platform/web/dom/BlobHandle.js @@ -69,23 +69,73 @@ const ALLOWED_BLOB_MIMETYPES = { 'audio/x-flac': true, }; -export class BufferHandle { - constructor(buffer, mimetype) { +export class BlobHandle { + constructor(blob, buffer = null) { + this._blob = blob; + this._buffer = buffer; + this._url = null; + } + + static fromBuffer(buffer, mimetype) { mimetype = mimetype ? mimetype.split(";")[0].trim() : ''; if (!ALLOWED_BLOB_MIMETYPES[mimetype]) { mimetype = 'application/octet-stream'; } - this.blob = new Blob([buffer], {type: mimetype}); - this._url = null; + return new BlobHandle(new Blob([buffer], {type: mimetype}), buffer); + } + + static fromFile(file) { + // ok to not filter mimetypes as these are local files + return new BlobHandle(file); + } + + get nativeBlob() { + return this._blob; + } + + async readAsBuffer() { + if (this._buffer) { + return this._buffer; + } else { + const reader = new FileReader(); + const promise = new Promise((resolve, reject) => { + reader.addEventListener("load", evt => resolve(evt.target.result)); + reader.addEventListener("error", evt => reject(evt.target.error)); + }); + reader.readAsArrayBuffer(this._blob); + return promise; + } + } + + async readAsText() { + if (this._buffer) { + return this._buffer; + } else { + const reader = new FileReader(); + const promise = new Promise((resolve, reject) => { + reader.addEventListener("load", evt => resolve(evt.target.result)); + reader.addEventListener("error", evt => reject(evt.target.error)); + }); + reader.readAsText(this._blob, "utf-8"); + return promise; + } } get url() { if (!this._url) { - this._url = URL.createObjectURL(this.blob); + this._url = URL.createObjectURL(this._blob); } return this._url; } + get size() { + return this._blob.size; + } + + get mimeType() { + return this._blob.type; + } + dispose() { if (this._url) { URL.revokeObjectURL(this._url); diff --git a/src/platform/web/dom/Crypto.js b/src/platform/web/dom/Crypto.js index f80c1a40..2362ecda 100644 --- a/src/platform/web/dom/Crypto.js +++ b/src/platform/web/dom/Crypto.js @@ -153,8 +153,9 @@ class DeriveCrypto { } class AESCrypto { - constructor(subtleCrypto) { + constructor(subtleCrypto, crypto) { this._subtleCrypto = subtleCrypto; + this._crypto = crypto; } /** * [decrypt description] @@ -197,14 +198,116 @@ class AESCrypto { throw new Error(`Could not decrypt with AES-CTR: ${err.message}`); } } + + async encryptCTR({key, jwkKey, iv, data}) { + const opts = { + name: "AES-CTR", + counter: iv, + length: 64, + }; + let aesKey; + const selectedKey = key || jwkKey; + const format = jwkKey ? "jwk" : "raw"; + try { + aesKey = await subtleCryptoResult(this._subtleCrypto.importKey( + format, + selectedKey, + opts, + false, + ['encrypt'], + ), "importKey"); + } catch (err) { + throw new Error(`Could not import key for AES-CTR encryption: ${err.message}`); + } + try { + const ciphertext = await subtleCryptoResult(this._subtleCrypto.encrypt( + // see https://developer.mozilla.org/en-US/docs/Web/API/AesCtrParams + opts, + aesKey, + data, + ), "encrypt"); + return new Uint8Array(ciphertext); + } catch (err) { + throw new Error(`Could not encrypt with AES-CTR: ${err.message}`); + } + } + + /** + * Generate a CTR key + * @param {String} format "raw" or "jwk" + * @param {Number} length 128 or 256 + * @return {Promise