Skip to content

Commit

Permalink
Add support for some content scanner endpoints. (#45)
Browse files Browse the repository at this point in the history
* Add support for some content scanner endpoints.

* fix tests

* fix empty media id being allowed
  • Loading branch information
Half-Shot authored Jul 11, 2024
1 parent 816d1a3 commit 798f2d3
Show file tree
Hide file tree
Showing 6 changed files with 136 additions and 18 deletions.
43 changes: 28 additions & 15 deletions src/MatrixClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { PowerLevelBounds } from "./models/PowerLevelBounds";
import { EventKind } from "./models/events/EventKind";
import { IdentityClient } from "./identity/IdentityClient";
import { OpenIDConnectToken } from "./models/OpenIDConnect";
import { doHttpRequest } from "./http";
import { doHttpRequest, DoHttpRequestOpts } from "./http";
import { Space, SpaceCreateOptions } from "./models/Spaces";
import { PowerLevelAction } from "./models/PowerLevelAction";
import { CryptoClient } from "./e2ee/CryptoClient";
Expand All @@ -46,6 +46,8 @@ import { RoomCreateOptions } from "./models/CreateRoom";
import { PresenceState } from './models/events/PresenceEvent';
import { IKeyBackupInfo, IKeyBackupInfoRetrieved, IKeyBackupInfoUnsigned, IKeyBackupInfoUpdate, IKeyBackupVersion, KeyBackupVersion } from "./models/KeyBackup";
import { MatrixError } from "./models/MatrixError";
import { MXCUrl } from "./models/MXCUrl";
import { MatrixContentScannerClient } from "./MatrixContentScannerClient";

const SYNC_BACKOFF_MIN_MS = 5000;
const SYNC_BACKOFF_MAX_MS = 15000;
Expand Down Expand Up @@ -79,6 +81,13 @@ export class MatrixClient extends EventEmitter {
*/
public readonly crypto: CryptoClient;

/**
* The Content Scanner API instance for this client. This is set if `opts.enableContentScanner`
* is true. The `downloadContent` and `crypto.decryptMedia` methods automatically go via
* the content scanner when this is set.
*/
public readonly contentScannerInstance?: MatrixContentScannerClient;

/**
* The DM manager instance for this client.
*/
Expand All @@ -94,7 +103,7 @@ export class MatrixClient extends EventEmitter {
private filterId = 0;
private stopSyncing = false;
private metricsInstance: Metrics = new Metrics();
private unstableApisInstance = new UnstableApis(this);
private readonly unstableApisInstance = new UnstableApis(this);
private cachedVersions: ServerVersions;
private versionsLastFetched = 0;

Expand All @@ -118,6 +127,7 @@ export class MatrixClient extends EventEmitter {
public readonly accessToken: string,
private storage: IStorageProvider = null,
public readonly cryptoStore: ICryptoStorageProvider = null,
opts: { enableContentScanner?: boolean } = {},
) {
super();

Expand Down Expand Up @@ -149,6 +159,10 @@ export class MatrixClient extends EventEmitter {
if (!this.storage) this.storage = new MemoryStorageProvider();

this.dms = new DMs(this);

if (opts.enableContentScanner) {
this.contentScannerInstance = new MatrixContentScannerClient(this);
}
}

/**
Expand Down Expand Up @@ -1587,11 +1601,8 @@ export class MatrixClient extends EventEmitter {
* @returns {string} The HTTP URL for the content.
*/
public mxcToHttp(mxc: string): string {
if (!mxc.startsWith("mxc://")) throw new Error("Not a MXC URI");
const parts = mxc.substring("mxc://".length).split('/');
const originHomeserver = parts[0];
const mediaId = parts.slice(1, parts.length).join('/');
return `${this.homeserverUrl}/_matrix/media/v3/download/${encodeURIComponent(originHomeserver)}/${encodeURIComponent(mediaId)}`;
const { domain, mediaId } = MXCUrl.parse(mxc);
return `${this.homeserverUrl}/_matrix/media/v3/download/${encodeURIComponent(domain)}/${encodeURIComponent(mediaId)}`;
}

/**
Expand Down Expand Up @@ -1633,13 +1644,11 @@ export class MatrixClient extends EventEmitter {
* @returns {Promise<{data: Buffer, contentType: string}>} Resolves to the downloaded content.
*/
public async downloadContent(mxcUrl: string, allowRemote = true): Promise<{ data: Buffer, contentType: string }> {
if (!mxcUrl.toLowerCase().startsWith("mxc://")) {
throw Error("'mxcUrl' does not begin with mxc://");
if (this.contentScannerInstance) {
return this.contentScannerInstance.downloadContent(mxcUrl, allowRemote);
}
const urlParts = mxcUrl.substr("mxc://".length).split("/");
const domain = encodeURIComponent(urlParts[0]);
const mediaId = encodeURIComponent(urlParts[1].split("/")[0]);
const path = `/_matrix/media/v3/download/${domain}/${mediaId}`;
const { domain, mediaId } = MXCUrl.parse(mxcUrl);
const path = `/_matrix/media/v3/download/${encodeURIComponent(domain)}/${encodeURIComponent(mediaId)}`;
const res = await this.doRequest("GET", path, { allow_remote: allowRemote }, null, null, true, null, true);
return {
data: res.body,
Expand Down Expand Up @@ -2095,7 +2104,8 @@ export class MatrixClient extends EventEmitter {
* @returns {Promise<any>} Resolves to the response (body), rejected if a non-2xx status code was returned.
*/
@timedMatrixClientFunctionCall()
public doRequest(method, endpoint, qs = null, body = null, timeout = 60000, raw = false, contentType = "application/json", noEncoding = false): Promise<any> {
public doRequest(method, endpoint, qs = null, body = null, timeout = 60000, raw = false,
contentType = "application/json", noEncoding = false, opts?: DoHttpRequestOpts): Promise<any> {
if (this.impersonatedUserId) {
if (!qs) qs = { "user_id": this.impersonatedUserId };
else qs["user_id"] = this.impersonatedUserId;
Expand All @@ -2108,7 +2118,10 @@ export class MatrixClient extends EventEmitter {
if (this.accessToken) {
headers["Authorization"] = `Bearer ${this.accessToken}`;
}
return doHttpRequest(this.homeserverUrl, method, endpoint, qs, body, headers, timeout, raw, contentType, noEncoding);
return doHttpRequest(
this.homeserverUrl, method, endpoint, qs, body, headers,
timeout, raw, contentType, noEncoding, opts,
);
}
}

Expand Down
66 changes: 66 additions & 0 deletions src/MatrixContentScannerClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { EncryptedFile, MatrixClient } from ".";
import { MXCUrl } from "./models/MXCUrl";

export interface ContentScannerResult {
info: string;
clean: boolean;
}
export interface ContentScannerErrorResult {
info: string;
reason: string;
}

export class MatrixContentScannerError extends Error {
constructor(public readonly body: ContentScannerErrorResult) {
super(`Encountered error scanning content (${body.reason}): ${body.info}`);
}
}

const errorHandler = (_response, errBody) => {
return typeof (errBody) === "object" && 'reason' in errBody ?
new MatrixContentScannerError(errBody as ContentScannerErrorResult) : undefined;
};

/**
* API client for https://github.com/element-hq/matrix-content-scanner-python.
*/
export class MatrixContentScannerClient {
constructor(public readonly client: MatrixClient) {

}

public async scanContent(mxcUrl: string): Promise<ContentScannerResult> {
const { domain, mediaId } = MXCUrl.parse(mxcUrl);
const path = `/_matrix/media_proxy/unstable/scan/${domain}/${mediaId}`;
const res = await this.client.doRequest("GET", path, null, null, null, false, null, false, { errorHandler });
return res;
}

public async scanContentEncrypted(file: EncryptedFile): Promise<ContentScannerResult> {
// Sanity check.
MXCUrl.parse(file.url);
const path = `/_matrix/media_proxy/unstable/scan_encrypted`;
const res = await this.client.doRequest("POST", path, null, { file }, null, false, null, false, { errorHandler });
return res;
}

public async downloadContent(mxcUrl: string, allowRemote = true): ReturnType<MatrixClient["downloadContent"]> {
const { domain, mediaId } = MXCUrl.parse(mxcUrl);
const path = `/_matrix/media_proxy/unstable/download/${encodeURIComponent(domain)}/${encodeURIComponent(mediaId)}`;
const res = await this.client.doRequest("GET", path, null, null, null, true, null, true, { errorHandler });
return {
data: res.body,
contentType: res.headers["content-type"],
};
}

public async downloadEncryptedContent(file: EncryptedFile): Promise<Buffer> {
// Sanity check.
MXCUrl.parse(file.url);
const path = `/_matrix/media_proxy/unstable/download_encrypted`;
const res = await this.client.doRequest("POST", path, undefined, {
file,
}, null, true, null, true, { errorHandler });
return res.data;
}
}
4 changes: 3 additions & 1 deletion src/e2ee/CryptoClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,9 @@ export class CryptoClient {
*/
@requiresReady()
public async decryptMedia(file: EncryptedFile): Promise<Buffer> {
const contents = (await this.client.downloadContent(file.url)).data;
const contents = this.client.contentScannerInstance ?
await this.client.contentScannerInstance.downloadEncryptedContent(file) :
(await this.client.downloadContent(file.url)).data;
const encrypted = new EncryptedAttachment(
contents,
JSON.stringify(file),
Expand Down
17 changes: 15 additions & 2 deletions src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@ import { MatrixError } from "./models/MatrixError";

let lastRequestId = 0;

const defaultErrorHandler = (response, errBody) => {
return typeof (errBody) === "object" && 'errcode' in errBody ?
new MatrixError(errBody, response.statusCode, response.headers) : undefined;
};

export interface DoHttpRequestOpts {
errorHandler?: (response, body) => Error|undefined;
}

/**
* Performs a web request to a server.
* @category Unit testing
Expand All @@ -30,6 +39,9 @@ export async function doHttpRequest(
raw = false,
contentType = "application/json",
noEncoding = false,
opts: DoHttpRequestOpts = {
errorHandler: defaultErrorHandler,
},
): Promise<any> {
if (!endpoint.startsWith('/')) {
endpoint = '/' + endpoint;
Expand Down Expand Up @@ -104,10 +116,11 @@ export async function doHttpRequest(

// Check for errors.
const errBody = response.body || resBody;
if (typeof (errBody) === "object" && 'errcode' in errBody) {
const handledError = opts.errorHandler(response, errBody);
if (handledError) {
const redactedBody = respIsBuffer ? '<Buffer>' : redactObjectForLogging(errBody);
LogService.error("MatrixHttpClient", "(REQ-" + requestId + ")", redactedBody);
throw new MatrixError(errBody, response.statusCode, response.headers);
throw handledError;
}

// Don't log the body unless we're in debug mode. They can be large.
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export * from "./models/PowerLevelAction";
export * from "./models/ServerVersions";
export * from "./models/MatrixError";
export * from "./models/CreateRoom";
export * from "./models/MXCUrl";

// Unstable models
export * from "./models/unstable/MediaInfo";
Expand Down Expand Up @@ -112,6 +113,7 @@ export * from "./request";
export * from "./PantalaimonClient";
export * from "./SynchronousMatrixClient";
export * from "./SynapseAdminApis";
export * from "./MatrixContentScannerClient";
export * from "./simple-validation";
export * from "./b64";
export * from "./http";
Expand Down
22 changes: 22 additions & 0 deletions src/models/MXCUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
export class MXCUrl {
static parse(mxcUrl: string): MXCUrl {
if (!mxcUrl?.toLowerCase()?.startsWith("mxc://")) {
throw Error("Not a MXC URI");
}
const [domain, ...mediaIdParts] = mxcUrl.slice("mxc://".length).split("/");
if (!domain) {
throw Error("missing domain component");
}
const mediaId = mediaIdParts?.join('/') ?? undefined;
if (!mediaId) {
throw Error("missing mediaId component");
}
return new MXCUrl(domain, mediaId);
}

constructor(public domain: string, public mediaId: string) { }

public toString() {
return `mxc://${this.domain}/${this.mediaId}`;
}
}

0 comments on commit 798f2d3

Please sign in to comment.