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 MCS API #131

Closed
wants to merge 3 commits into from
Closed
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
3 changes: 2 additions & 1 deletion customisations.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@
"src/components/views/dialogs/CreateRoomDialog.tsx": "src/components/views/dialogs/TchapCreateRoomDialog.tsx",
"src/components/views/elements/TchapRoomTypeSelector.tsx": "src/components/views/elements/TchapRoomTypeSelector.tsx",
"src/components/views/dialogs/ServerPickerDialog.tsx": "src/components/views/dialogs/TchapServerPickerDialog.tsx",
"src/customisations/ComponentVisibility.ts": "src/customisations/TchapComponentVisibility.ts"
"src/customisations/ComponentVisibility.ts": "src/customisations/TchapComponentVisibility.ts",
"src/customisations/Media.ts": "src/customisations/ContentScanningMedia.ts"
}
130 changes: 130 additions & 0 deletions src/content-scanner/ContentScanner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/*
weeman1337 marked this conversation as resolved.
Show resolved Hide resolved
* Copyright 2022 New Vector Ltd
*
* 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 { getHttpUriForMxc } from "matrix-js-sdk/src/content-repo";
import { IEncryptedFile } from "matrix-react-sdk/src/customisations/models/IMediaEventContent";
import { PkEncryption } from "@matrix-org/olm";
import { ResizeMethod } from "matrix-js-sdk/src/@types/partials";
import { MatrixClientPeg } from "matrix-react-sdk/src/MatrixClientPeg";

export enum ScanErrorReason {
RequestFailed = "MCS_MEDIA_REQUEST_FAILED",
DecryptFailed = "MCS_MEDIA_FAILED_TO_DECRYPT",
NotClean = "MCS_MEDIA_NOT_CLEAN",
BadDecryption = "MCS_BAD_DECRYPTION",
Malformed = "MCS_MALFORMED_JSON",
}

export interface ScanError {
info: string;
reason: ScanErrorReason;
}

export interface ScanResult {
clean: boolean;
scanned: boolean;
info: string;
}

/**
* Content scanner implementation that interacts with a Matrix Content Scanner.
* @see https://github.com/matrix-org/matrix-content-scanner
*/
export class ContentScanner {
private static internalInstance: ContentScanner;

private mcsKey: PkEncryption = new global.Olm.PkEncryption();
private hasKey = false;
private pendingScans = new Map<string, Promise<boolean>>();

constructor(private scannerUrl: string) {
}

public urlForMxc(mxc: string, width?: number, height?: number, method?: ResizeMethod): string {
const matrixUrl = getHttpUriForMxc(this.scannerUrl, mxc, width, height, method);
return matrixUrl.replace(/media\/r0/, "media_proxy/unstable");
}

public async download(mxc: string, file?: IEncryptedFile): Promise<Response> {
if (!file) {
return fetch(this.urlForMxc(mxc));
}

if (!this.hasKey) {
const k = await fetch(this.scannerUrl + "/_matrix/media_proxy/unstable/public_key").then(r => r.json());
this.mcsKey.set_recipient_key(k['public_key']);
this.hasKey = true;
}

return fetch(this.scannerUrl + "/_matrix/media_proxy/unstable/download_encrypted", {
method: "POST",
body: JSON.stringify({
encrypted_body: this.mcsKey.encrypt(JSON.stringify({ file })),
}),
headers: {
"Content-Type": "application/json",
},
});
}

public async scan(mxc: string, file?: IEncryptedFile): Promise<boolean> {
// XXX: we're assuming that encryption won't be a differentiating factor and that the MXC URIs
// will be different.
if (this.pendingScans.has(mxc)) {
return this.pendingScans.get(mxc);
}

// eslint-disable-next-line no-async-promise-executor
const promise = new Promise<boolean>(async resolve => {
let response: Response;

if (file) {
if (!this.hasKey) {
const k = await fetch(this.scannerUrl + "/_matrix/media_proxy/unstable/public_key")
.then(r => r.json());
this.mcsKey.set_recipient_key(k["public_key"]);
this.hasKey = true;
}

response = await fetch(this.scannerUrl + "/_matrix/media_proxy/unstable/scan_encrypted", {
method: "POST",
body: JSON.stringify({
encrypted_body: this.mcsKey.encrypt(JSON.stringify({ file })),
}),
headers: {
"Content-Type": "application/json",
},
});
} else {
const url = this.scannerUrl + `/_matrix/media_proxy/unstable/scan/${mxc.substring('mxc://'.length)}`;
response = await fetch(url);
}

const responseJson: ScanResult = await response.json();
resolve(responseJson.clean);
});
this.pendingScans.set(mxc, promise);
return promise;
}

public static get instance(): ContentScanner {
if (!ContentScanner.internalInstance) {
ContentScanner.internalInstance = new ContentScanner(MatrixClientPeg.get().getHomeserverUrl());
}

return ContentScanner.internalInstance;
}
}
164 changes: 164 additions & 0 deletions src/customisations/ContentScanningMedia.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
/*
* Copyright 2022 New Vector Ltd
*
* 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 {
IMediaEventContent,
IPreparedMedia,
prepEventContentAsMedia,
} from "matrix-react-sdk/src/customisations/models/IMediaEventContent";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { ResizeMethod } from "matrix-js-sdk/src/@types/partials";

import { ContentScanner } from "./../content-scanner/ContentScanner";

/**
* A media object is a representation of a "source media" and an optional
* "thumbnail media", derived from event contents or external sources.
*
* Routes all media through a content scanner.
*/
export class Media {
constructor(public readonly prepared: IPreparedMedia) {
}

/**
* True if the media appears to be encrypted. Actual file contents may vary.
*/
public get isEncrypted(): boolean {
return !!this.prepared.file;
}

/**
* The MXC URI of the source media.
*/
public get srcMxc(): string {
return this.prepared.mxc;
}

/**
* The MXC URI of the thumbnail media, if a thumbnail is recorded. Null/undefined
* otherwise.
*/
public get thumbnailMxc(): string | undefined | null {
return this.prepared.thumbnail?.mxc;
}

/**
* Whether or not a thumbnail is recorded for this media.
*/
public get hasThumbnail(): boolean {
return !!this.thumbnailMxc;
}

/**
* The HTTP URL for the source media.
*/
public get srcHttp(): string {
return ContentScanner.instance.urlForMxc(this.srcMxc);
}

/**
* The HTTP URL for the thumbnail media (without any specified width, height, etc). Null/undefined
* if no thumbnail media recorded.
*/
public get thumbnailHttp(): string | undefined | null {
if (!this.hasThumbnail) return null;
return ContentScanner.instance.urlForMxc(this.thumbnailMxc);
}

/**
* Gets the HTTP URL for the thumbnail media with the requested characteristics, if a thumbnail
* is recorded for this media. Returns null/undefined otherwise.
* @param {number} width The desired width of the thumbnail.
* @param {number} height The desired height of the thumbnail.
* @param {"scale"|"crop"} mode The desired thumbnailing mode. Defaults to scale.
* @returns {string} The HTTP URL which points to the thumbnail.
*/
public getThumbnailHttp(width: number, height: number, mode: ResizeMethod = "scale"): string | null | undefined {
if (!this.hasThumbnail) return null;
return ContentScanner.instance.urlForMxc(this.thumbnailMxc, width, height, mode);
}

/**
* Gets the HTTP URL for a thumbnail of the source media with the requested characteristics.
* @param {number} width The desired width of the thumbnail.
* @param {number} height The desired height of the thumbnail.
* @param {"scale"|"crop"} mode The desired thumbnailing mode. Defaults to scale.
* @returns {string} The HTTP URL which points to the thumbnail.
*/
public getThumbnailOfSourceHttp(width: number, height: number, mode: ResizeMethod = "scale"): string {
return ContentScanner.instance.urlForMxc(this.srcMxc, width, height, mode);
}

/**
* Creates a square thumbnail of the media. If the media has a thumbnail recorded, that MXC will
* be used, otherwise the source media will be used.
* @param {number} dim The desired width and height.
* @returns {string} An HTTP URL for the thumbnail.
*/
public getSquareThumbnailHttp(dim: number): string {
if (this.hasThumbnail) {
return this.getThumbnailHttp(dim, dim, 'crop');
}
return this.getThumbnailOfSourceHttp(dim, dim, 'crop');
}

/**
* Downloads the source media.
* @returns {Promise<Response>} Resolves to the server's response for chaining.
*/
public downloadSource(): Promise<Response> {
return ContentScanner.instance.download(this.srcMxc, this.prepared.file);
}

/**
* Hardened Element Web specific. Scans the source media with the content scanner.
* @returns {Promise<boolean>} Resolves to true if the media is safe.
*/
public scanSource(): Promise<boolean> {
return ContentScanner.instance.scan(this.srcMxc, this.prepared.file);
}

/**
* Hardened Element Web specific. Scans the thumbnail media with the content scanner.
* If there is no thumbnail media, this returns true.
* @returns {Promise<boolean>} Resolves to true if the media is safe.
*/
public async scanThumbnail(): Promise<boolean> {
if (!this.hasThumbnail) return true;
return ContentScanner.instance.scan(this.thumbnailMxc, this.prepared.thumbnail.file);
}
}

/**
* Creates a media object from event content.
* @param {IMediaEventContent} content The event content.
* @param {MatrixClient} client? Optional client to use.
* @returns {Media} The media object.
*/
export function mediaFromContent(content: IMediaEventContent, client?: MatrixClient): Media {
return new Media(prepEventContentAsMedia(content));
}

/**
* Creates a media object from an MXC URI.
* @param {string} mxc The MXC URI.
* @param {MatrixClient} client? Optional client to use.
* @returns {Media} The media object.
*/
export function mediaFromMxc(mxc: string, client?: MatrixClient): Media {
return mediaFromContent({ url: mxc }, client);
}