Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for authenticated media #1177

Merged
merged 2 commits into from
Aug 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 54 additions & 23 deletions src/matrix/Client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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;
Expand All @@ -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)
);
}
}

Expand Down
170 changes: 143 additions & 27 deletions src/matrix/net/MediaRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<BlobHandle> {
async downloadEncryptedFile(
fileEntry: EncryptedFile,
cache: boolean = false
): Promise<BlobHandle> {
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<BlobHandle> {
async downloadPlaintextFile(
mxcUrl: string,
mimetype: string,
cache: boolean = false
): Promise<BlobHandle> {
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<BlobHandle> {
async downloadAttachment(
content: Attachment,
cache: boolean = false
): Promise<BlobHandle> {
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/
);
},
};
}
8 changes: 6 additions & 2 deletions src/platform/web/Platform.js
Original file line number Diff line number Diff line change
Expand Up @@ -146,8 +146,13 @@ export class Platform {
this.onlineStatus = new OnlineStatus();
this.timeFormatter = new TimeFormatter();
this._serviceWorkerHandler = null;
this.sessionInfoStorage = new SessionInfoStorage(
"hydrogen_sessions_v1"
);
if (assetPaths.serviceWorker && "serviceWorker" in navigator) {
this._serviceWorkerHandler = new ServiceWorkerHandler();
this._serviceWorkerHandler = new ServiceWorkerHandler(
this.sessionInfoStorage
);
this._serviceWorkerHandler.registerAndStart(assetPaths.serviceWorker);
}
this.notificationService = undefined;
Expand All @@ -156,7 +161,6 @@ export class Platform {
this.crypto = new Crypto(cryptoExtras);
}
this.storageFactory = new StorageFactory(this._serviceWorkerHandler);
this.sessionInfoStorage = new SessionInfoStorage("hydrogen_sessions_v1");
this.estimateStorageUsage = estimateStorageUsage;
if (typeof fetch === "function") {
this.request = createFetchRequest(this.clock.createTimeout, this._serviceWorkerHandler);
Expand Down
Loading
Loading