From c67f8c3582dc32842dbb07e31527fd6c1dcb46e9 Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Mon, 19 Aug 2024 18:17:08 +0530 Subject: [PATCH] Use authenticated media endpoints when possible --- src/matrix/Client.js | 77 ++++++++++---- src/matrix/net/MediaRepository.ts | 170 +++++++++++++++++++++++++----- 2 files changed, 197 insertions(+), 50 deletions(-) diff --git a/src/matrix/Client.js b/src/matrix/Client.js index 7423b43112..49f24d691f 100644 --- a/src/matrix/Client.js +++ b/src/matrix/Client.js @@ -252,7 +252,7 @@ export class Client { this._reconnector = new Reconnector({ onlineStatus: this._platform.onlineStatus, retryDelay: new ExponentialRetryDelay(clock.createTimeout), - createMeasure: clock.createMeasure + createMeasure: clock.createMeasure, }); const hsApi = new HomeServerApi({ homeserver: sessionInfo.homeServer, @@ -261,7 +261,10 @@ export class Client { reconnector: this._reconnector, }); this._sessionId = sessionInfo.id; - this._storage = await this._platform.storageFactory.create(sessionInfo.id, log); + this._storage = await this._platform.storageFactory.create( + sessionInfo.id, + log + ); // no need to pass access token to session const filteredSessionInfo = { id: sessionInfo.id, @@ -275,11 +278,16 @@ export class Client { if (this._workerPromise) { olmWorker = await this._workerPromise; } - this._requestScheduler = new RequestScheduler({hsApi, clock}); + this._requestScheduler = new RequestScheduler({ hsApi, clock }); this._requestScheduler.start(); + + const lastVersionsResponse = await hsApi + .versions({ timeout: 10000, log }) + .response(); const mediaRepository = new MediaRepository({ homeserver: sessionInfo.homeServer, platform: this._platform, + serverVersions: lastVersionsResponse.versions, }); this._session = new Session({ storage: this._storage, @@ -289,32 +297,54 @@ export class Client { olmWorker, mediaRepository, platform: this._platform, - features: this._features + features: this._features, }); await this._session.load(log); if (dehydratedDevice) { - await log.wrap("dehydrateIdentity", log => this._session.dehydrateIdentity(dehydratedDevice, log)); - await this._session.setupDehydratedDevice(dehydratedDevice.key, log); + await log.wrap("dehydrateIdentity", (log) => + this._session.dehydrateIdentity(dehydratedDevice, log) + ); + await this._session.setupDehydratedDevice( + dehydratedDevice.key, + log + ); } else if (!this._session.hasIdentity) { this._status.set(LoadStatus.SessionSetup); - await log.wrap("createIdentity", log => this._session.createIdentity(log)); + await log.wrap("createIdentity", (log) => + this._session.createIdentity(log) + ); } - this._sync = new Sync({hsApi: this._requestScheduler.hsApi, storage: this._storage, session: this._session, logger: this._platform.logger}); - // notify sync and session when back online - this._reconnectSubscription = this._reconnector.connectionStatus.subscribe(state => { - if (state === ConnectionStatus.Online) { - this._platform.logger.runDetached("reconnect", async log => { - // needs to happen before sync and session or it would abort all requests - this._requestScheduler.start(); - this._sync.start(); - this._sessionStartedByReconnector = true; - const d = dehydratedDevice; - dehydratedDevice = undefined; - await log.wrap("session start", log => this._session.start(this._reconnector.lastVersionsResponse, d, log)); - }); - } + this._sync = new Sync({ + hsApi: this._requestScheduler.hsApi, + storage: this._storage, + session: this._session, + logger: this._platform.logger, }); + // notify sync and session when back online + this._reconnectSubscription = + this._reconnector.connectionStatus.subscribe((state) => { + if (state === ConnectionStatus.Online) { + this._platform.logger.runDetached( + "reconnect", + async (log) => { + // needs to happen before sync and session or it would abort all requests + this._requestScheduler.start(); + this._sync.start(); + this._sessionStartedByReconnector = true; + const d = dehydratedDevice; + dehydratedDevice = undefined; + await log.wrap("session start", (log) => + this._session.start( + this._reconnector.lastVersionsResponse, + d, + log + ) + ); + } + ); + } + }); await log.wrap("wait first sync", () => this._waitForFirstSync()); if (this._isDisposed) { return; @@ -326,14 +356,15 @@ export class Client { // started to session, so check first // to prevent an extra /versions request if (!this._sessionStartedByReconnector) { - const lastVersionsResponse = await hsApi.versions({timeout: 10000, log}).response(); if (this._isDisposed) { return; } const d = dehydratedDevice; dehydratedDevice = undefined; // log as ref as we don't want to await it - await log.wrap("session start", log => this._session.start(lastVersionsResponse, d, log)); + await log.wrap("session start", (log) => + this._session.start(lastVersionsResponse, d, log) + ); } } diff --git a/src/matrix/net/MediaRepository.ts b/src/matrix/net/MediaRepository.ts index e95ed60cbc..1a818d3e81 100644 --- a/src/matrix/net/MediaRepository.ts +++ b/src/matrix/net/MediaRepository.ts @@ -14,67 +14,183 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {encodeQueryParams} from "./common"; -import {decryptAttachment} from "../e2ee/attachment.js"; -import {Platform} from "../../platform/web/Platform.js"; -import {BlobHandle} from "../../platform/web/dom/BlobHandle.js"; -import type {Attachment, EncryptedFile} from "./types/response"; +import { encodeQueryParams } from "./common"; +import { decryptAttachment } from "../e2ee/attachment.js"; +import { Platform } from "../../platform/web/Platform.js"; +import { BlobHandle } from "../../platform/web/dom/BlobHandle.js"; +import type { + Attachment, + EncryptedFile, + VersionResponse, +} from "./types/response"; + +type ServerVersions = VersionResponse["versions"]; + +type Params = { + homeserver: string; + platform: Platform; + serverVersions: ServerVersions; +}; export class MediaRepository { - private readonly _homeserver: string; - private readonly _platform: Platform; + private readonly homeserver: string; + private readonly platform: Platform; + // Depends on whether the server supports authenticated media + private mediaUrlPart: string; + + constructor(params: Params) { + this.homeserver = params.homeserver; + this.platform = params.platform; + this.generateMediaUrl(params.serverVersions); + } - constructor({homeserver, platform}: {homeserver:string, platform: Platform}) { - this._homeserver = homeserver; - this._platform = platform; + /** + * Calculate and store the correct media endpoint depending + * on whether the homeserver supports authenticated media (MSC3916) + * @see https://github.com/matrix-org/matrix-spec-proposals/pull/3916 + * @param serverVersions List of supported spec versions + */ + private generateMediaUrl(serverVersions: ServerVersions) { + const VERSION_WITH_AUTHENTICATION = "v1.11"; + if (serverVersions.includes(VERSION_WITH_AUTHENTICATION)) { + this.mediaUrlPart = "_matrix/client/v1/media"; + } else { + this.mediaUrlPart = "_matrix/media/v3"; + } } - mxcUrlThumbnail(url: string, width: number, height: number, method: "crop" | "scale"): string | undefined { - const parts = this._parseMxcUrl(url); + mxcUrlThumbnail( + url: string, + width: number, + height: number, + method: "crop" | "scale" + ): string | undefined { + const parts = this.parseMxcUrl(url); if (parts) { const [serverName, mediaId] = parts; - const httpUrl = `${this._homeserver}/_matrix/media/r0/thumbnail/${encodeURIComponent(serverName)}/${encodeURIComponent(mediaId)}`; - return httpUrl + "?" + encodeQueryParams({width: Math.round(width), height: Math.round(height), method}); + const httpUrl = `${this.homeserver}/${ + this.mediaUrlPart + }/thumbnail/${encodeURIComponent(serverName)}/${encodeURIComponent( + mediaId + )}`; + return ( + httpUrl + + "?" + + encodeQueryParams({ + width: Math.round(width), + height: Math.round(height), + method, + }) + ); } return undefined; } mxcUrl(url: string): string | undefined { - const parts = this._parseMxcUrl(url); + const parts = this.parseMxcUrl(url); if (parts) { const [serverName, mediaId] = parts; - return `${this._homeserver}/_matrix/media/r0/download/${encodeURIComponent(serverName)}/${encodeURIComponent(mediaId)}`; + return `${this.homeserver}/${ + this.mediaUrlPart + }/download/${encodeURIComponent(serverName)}/${encodeURIComponent( + mediaId + )}`; } return undefined; } - private _parseMxcUrl(url: string): string[] | undefined { + private parseMxcUrl(url: string): string[] | undefined { const prefix = "mxc://"; if (url.startsWith(prefix)) { - return url.substr(prefix.length).split("/", 2); + return url.slice(prefix.length).split("/", 2); } else { return undefined; } } - async downloadEncryptedFile(fileEntry: EncryptedFile, cache: boolean = false): Promise { + async downloadEncryptedFile( + fileEntry: EncryptedFile, + cache: boolean = false + ): Promise { 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, encryptedBuffer, fileEntry); - return this._platform.createBlob(decryptedBuffer, fileEntry.mimetype); + const { body: encryptedBuffer } = await this.platform + .request(url, { method: "GET", format: "buffer", cache }) + .response(); + const decryptedBuffer = await decryptAttachment( + this.platform, + encryptedBuffer, + fileEntry + ); + return this.platform.createBlob(decryptedBuffer, fileEntry.mimetype); } - async downloadPlaintextFile(mxcUrl: string, mimetype: string, cache: boolean = false): Promise { + async downloadPlaintextFile( + mxcUrl: string, + mimetype: string, + cache: boolean = false + ): Promise { const url = this.mxcUrl(mxcUrl); - const {body: buffer} = await this._platform.request(url, {method: "GET", format: "buffer", cache}).response(); - return this._platform.createBlob(buffer, mimetype); + const { body: buffer } = await this.platform + .request(url, { method: "GET", format: "buffer", cache }) + .response(); + return this.platform.createBlob(buffer, mimetype); } - async downloadAttachment(content: Attachment, cache: boolean = false): Promise { + async downloadAttachment( + content: Attachment, + cache: boolean = false + ): Promise { if (content.file) { return this.downloadEncryptedFile(content.file, cache); } else { - return this.downloadPlaintextFile(content.url!, content.info?.mimetype, cache); + return this.downloadPlaintextFile( + content.url!, + content.info?.mimetype, + cache + ); } } } + +export function tests() { + return { + "Uses correct endpoint when server supports authenticated media": ( + assert + ) => { + const homeserver = "matrix.org"; + const platform = {}; + // Is it enough to check if v1.11 is present? + // or do we check if maxVersion > v1.11 + const serverVersions = ["v1.1", "v1.11", "v1.10"]; + const mediaRepository = new MediaRepository({ + homeserver, + platform, + serverVersions, + }); + + const mxcUrl = "mxc://matrix.org/foobartest"; + assert.match( + mediaRepository.mxcUrl(mxcUrl), + /_matrix\/client\/v1\/media/ + ); + }, + + "Uses correct endpoint when server does not supports authenticated media": + (assert) => { + const homeserver = "matrix.org"; + const platform = {}; + const serverVersions = ["v1.1", "v1.11", "v1.10"]; + const mediaRepository = new MediaRepository({ + homeserver, + platform, + serverVersions, + }); + + const mxcUrl = "mxc://matrix.org/foobartest"; + assert.match( + mediaRepository.mxcUrl(mxcUrl), + /_matrix\/client\/v1\/media/ + ); + }, + }; +}