Skip to content

Commit

Permalink
Implement MSC4039 download_file action (#99)
Browse files Browse the repository at this point in the history
* Implement download_file action

Signed-off-by: Michael Weimann <[email protected]>

* Tweak imports

Signed-off-by: Michael Weimann <[email protected]>

* address PR feedback

Signed-off-by: Kim Brose <[email protected]>

---------

Signed-off-by: Michael Weimann <[email protected]>
Signed-off-by: Kim Brose <[email protected]>
Co-authored-by: Kim Brose <[email protected]>
  • Loading branch information
weeman1337 and HarHarLinks authored Aug 30, 2024
1 parent 3c9543c commit feb02ee
Show file tree
Hide file tree
Showing 9 changed files with 247 additions and 2 deletions.
28 changes: 28 additions & 0 deletions src/ClientWidgetApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,10 @@ import {
IUploadFileActionFromWidgetActionRequest,
IUploadFileActionFromWidgetResponseData,
} from "./interfaces/UploadFileAction";
import {
IDownloadFileActionFromWidgetActionRequest,
IDownloadFileActionFromWidgetResponseData,
} from "./interfaces/DownloadFileAction";

/**
* API handler for the client side of widgets. This raises events
Expand Down Expand Up @@ -824,6 +828,28 @@ export class ClientWidgetApi extends EventEmitter {
}
}

private async handleDownloadFile(request: IDownloadFileActionFromWidgetActionRequest): Promise<void> {
if (!this.hasCapability(MatrixCapabilities.MSC4039DownloadFile)) {
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
error: { message: "Missing capability" },
});
}

try {
const result = await this.driver.downloadFile(request.data.content_uri);

return this.transport.reply<IDownloadFileActionFromWidgetResponseData>(
request,
{ file: result.file },
);
} catch (e) {
console.error("error while downloading a file", e);
this.transport.reply<IWidgetApiErrorResponseData>(request, {
error: { message: "Unexpected error while downloading a file" },
});
}
}

private handleMessage(ev: CustomEvent<IWidgetApiRequest>) {
if (this.isStopped) return;
const actionEv = new CustomEvent(`action:${ev.detail.action}`, {
Expand Down Expand Up @@ -863,6 +889,8 @@ export class ClientWidgetApi extends EventEmitter {
return this.handleGetMediaConfig(<IGetMediaConfigActionFromWidgetActionRequest>ev.detail);
case WidgetApiFromWidgetAction.MSC4039UploadFileAction:
return this.handleUploadFile(<IUploadFileActionFromWidgetActionRequest>ev.detail);
case WidgetApiFromWidgetAction.MSC4039DownloadFileAction:
return this.handleDownloadFile(<IDownloadFileActionFromWidgetActionRequest>ev.detail);
case WidgetApiFromWidgetAction.MSC4157UpdateDelayedEvent:
return this.handleUpdateDelayedEvent(<IUpdateDelayedEventFromWidgetActionRequest>ev.detail);

Expand Down
25 changes: 25 additions & 0 deletions src/WidgetApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@ import {
IUploadFileActionFromWidgetRequestData,
IUploadFileActionFromWidgetResponseData,
} from "./interfaces/UploadFileAction";
import {
IDownloadFileActionFromWidgetRequestData,
IDownloadFileActionFromWidgetResponseData,
} from "./interfaces/DownloadFileAction";
import {
IUpdateDelayedEventFromWidgetRequestData,
IUpdateDelayedEventFromWidgetResponseData,
Expand Down Expand Up @@ -750,6 +754,27 @@ export class WidgetApi extends EventEmitter {
>(WidgetApiFromWidgetAction.MSC4039UploadFileAction, data);
}

/**
* Download a file from the media repository on the homeserver.
* @param contentUri - MXC URI of the file to download.
* @returns Resolves to the contents of the file.
*/
public async downloadFile(contentUri: string): Promise<IDownloadFileActionFromWidgetResponseData> {
const versions = await this.getClientVersions();
if (!versions.includes(UnstableApiVersion.MSC4039)) {
throw new Error("The download_file action is not supported by the client.")
}

const data: IDownloadFileActionFromWidgetRequestData = {
content_uri: contentUri,
};

return this.transport.send<
IDownloadFileActionFromWidgetRequestData,
IDownloadFileActionFromWidgetResponseData
>(WidgetApiFromWidgetAction.MSC4039DownloadFileAction, data);
}

/**
* Starts the communication channel. This should be done early to ensure
* that messages are not missed. Communication can only be stopped by the client.
Expand Down
11 changes: 11 additions & 0 deletions src/driver/WidgetDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,4 +347,15 @@ export abstract class WidgetDriver {
): Promise<{ contentUri: string }> {
throw new Error("Upload file is not implemented");
}

/**
* Download a file from the media repository on the homeserver.
* @param contentUri - MXC URI of the file to download.
* @returns Resolves to the contents of the file.
*/
public downloadFile(
contentUri: string,
): Promise<{ file: XMLHttpRequestBodyInit }> {
throw new Error("Download file is not implemented");
}
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export * from "./interfaces/ReadRelationsAction";
export * from "./interfaces/GetMediaConfigAction";
export * from "./interfaces/UpdateDelayedEventAction";
export * from "./interfaces/UploadFileAction";
export * from "./interfaces/DownloadFileAction";

// Complex models
export * from "./models/WidgetEventCapability";
Expand Down
4 changes: 4 additions & 0 deletions src/interfaces/Capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ export enum MatrixCapabilities {
/**
* @deprecated It is not recommended to rely on this existing - it can be removed without notice.
*/
MSC4039DownloadFile = "org.matrix.msc4039.download_file",
/**
* @deprecated It is not recommended to rely on this existing - it can be removed without notice.
*/
MSC4157SendDelayedEvent = "org.matrix.msc4157.send.delayed_event",
/**
* @deprecated It is not recommended to rely on this existing - it can be removed without notice.
Expand Down
40 changes: 40 additions & 0 deletions src/interfaces/DownloadFileAction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*
* Copyright 2024 Nordeck IT + Consulting GmbH
*
* 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 { IWidgetApiRequest, IWidgetApiRequestData } from "./IWidgetApiRequest";
import { IWidgetApiResponseData } from "./IWidgetApiResponse";
import { WidgetApiFromWidgetAction } from "./WidgetApiAction";

export interface IDownloadFileActionFromWidgetRequestData
extends IWidgetApiRequestData {
content_uri: string; // eslint-disable-line camelcase
}

export interface IDownloadFileActionFromWidgetActionRequest
extends IWidgetApiRequest {
action: WidgetApiFromWidgetAction.MSC4039DownloadFileAction;
data: IDownloadFileActionFromWidgetRequestData;
}

export interface IDownloadFileActionFromWidgetResponseData
extends IWidgetApiResponseData {
file: XMLHttpRequestBodyInit;
}

export interface IDownloadFileActionFromWidgetActionResponse
extends IDownloadFileActionFromWidgetActionRequest {
response: IDownloadFileActionFromWidgetResponseData;
}
5 changes: 5 additions & 0 deletions src/interfaces/WidgetApiAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@ export enum WidgetApiFromWidgetAction {
*/
MSC4039UploadFileAction = "org.matrix.msc4039.upload_file",

/**
* @deprecated It is not recommended to rely on this existing - it can be removed without notice.
*/
MSC4039DownloadFileAction = "org.matrix.msc4039.download_file",

/**
* @deprecated It is not recommended to rely on this existing - it can be removed without notice.
*/
Expand Down
88 changes: 86 additions & 2 deletions test/ClientWidgetApi-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,14 @@ import { WidgetApiDirection } from '../src/interfaces/WidgetApiDirection';
import { Widget } from '../src/models/Widget';
import { PostmessageTransport } from '../src/transport/PostmessageTransport';
import {
IDownloadFileActionFromWidgetActionRequest,
IReadEventFromWidgetActionRequest,
ISendEventFromWidgetActionRequest,
IUpdateDelayedEventFromWidgetActionRequest,
IUploadFileActionFromWidgetActionRequest,
UpdateDelayedEventAction,
} from '../src';
import { IGetMediaConfigActionFromWidgetActionRequest } from '../src/interfaces/GetMediaConfigAction';
import { IUploadFileActionFromWidgetActionRequest } from '../src/interfaces/UploadFileAction';

jest.mock('../src/transport/PostmessageTransport')

Expand Down Expand Up @@ -91,6 +92,7 @@ describe('ClientWidgetApi', () => {
searchUserDirectory: jest.fn(),
getMediaConfig: jest.fn(),
uploadFile: jest.fn(),
downloadFile: jest.fn(),
} as Partial<WidgetDriver> as jest.Mocked<WidgetDriver>;

clientWidgetApi = new ClientWidgetApi(
Expand Down Expand Up @@ -1083,7 +1085,7 @@ describe('ClientWidgetApi', () => {
});
});

describe('org.matrix.msc4039.upload_file action', () => {
describe('MSC4039', () => {
it('should present as supported api version', () => {
const event: ISupportedVersionsActionRequest = {
api: WidgetApiDirection.FromWidget,
Expand All @@ -1101,7 +1103,9 @@ describe('ClientWidgetApi', () => {
]),
});
});
});

describe('org.matrix.msc4039.upload_file action', () => {
it('should handle and process the request', async () => {
driver.uploadFile.mockResolvedValue({
contentUri: 'mxc://...',
Expand Down Expand Up @@ -1180,4 +1184,84 @@ describe('ClientWidgetApi', () => {
});
});
});

describe('org.matrix.msc4039.download_file action', () => {
it('should handle and process the request', async () => {
driver.downloadFile.mockResolvedValue({
file: 'test contents',
});

const event: IDownloadFileActionFromWidgetActionRequest = {
api: WidgetApiDirection.FromWidget,
widgetId: 'test',
requestId: '0',
action: WidgetApiFromWidgetAction.MSC4039DownloadFileAction,
data: {
content_uri: 'mxc://example.com/test_file',
},
};

await loadIframe([
'org.matrix.msc4039.download_file',
]);

emitEvent(new CustomEvent('', { detail: event }));

await waitFor(() => {
expect(transport.reply).toHaveBeenCalledWith(event, {
file: 'test contents',
});
});

expect(driver.downloadFile).toHaveBeenCalledWith( 'mxc://example.com/test_file');
});

it('should reject requests when the capability was not requested', async () => {
const event: IDownloadFileActionFromWidgetActionRequest = {
api: WidgetApiDirection.FromWidget,
widgetId: 'test',
requestId: '0',
action: WidgetApiFromWidgetAction.MSC4039DownloadFileAction,
data: {
content_uri: 'mxc://example.com/test_file',
},
};

emitEvent(new CustomEvent('', { detail: event }));

expect(transport.reply).toBeCalledWith(event, {
error: { message: 'Missing capability' },
});

expect(driver.uploadFile).not.toBeCalled();
});

it('should reject requests when the driver throws an exception', async () => {
driver.getMediaConfig.mockRejectedValue(
new Error("M_LIMIT_EXCEEDED: Too many requests"),
);

const event: IDownloadFileActionFromWidgetActionRequest = {
api: WidgetApiDirection.FromWidget,
widgetId: 'test',
requestId: '0',
action: WidgetApiFromWidgetAction.MSC4039DownloadFileAction,
data: {
content_uri: 'mxc://example.com/test_file',
},
};

await loadIframe([
'org.matrix.msc4039.download_file',
]);

emitEvent(new CustomEvent('', { detail: event }));

await waitFor(() => {
expect(transport.reply).toBeCalledWith(event, {
error: { message: 'Unexpected error while downloading a file' },
});
});
});
});
});
47 changes: 47 additions & 0 deletions test/WidgetApi-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { IReadRelationsFromWidgetResponseData } from '../src/interfaces/ReadRela
import { ISendEventFromWidgetResponseData } from '../src/interfaces/SendEventAction';
import { ISupportedVersionsActionResponseData } from '../src/interfaces/SupportedVersionsAction';
import { IUploadFileActionFromWidgetResponseData } from '../src/interfaces/UploadFileAction';
import { IDownloadFileActionFromWidgetResponseData } from '../src/interfaces/DownloadFileAction';
import { IUserDirectorySearchFromWidgetResponseData } from '../src/interfaces/UserDirectorySearchAction';
import { WidgetApiFromWidgetAction } from '../src/interfaces/WidgetApiAction';
import { PostmessageTransport } from '../src/transport/PostmessageTransport';
Expand Down Expand Up @@ -380,4 +381,50 @@ describe('WidgetApi', () => {
);
});
});

describe('downloadFile', () => {
it('should forward the request to the ClientWidgetApi', async () => {
jest.mocked(PostmessageTransport.prototype.send).mockResolvedValueOnce(
{ supported_versions: [UnstableApiVersion.MSC4039] } as ISupportedVersionsActionResponseData,
);
jest.mocked(PostmessageTransport.prototype.send).mockResolvedValue(
{ file: 'test contents' } as IDownloadFileActionFromWidgetResponseData,
);

await expect(widgetApi.downloadFile("mxc://example.com/test_file")).resolves.toEqual({
file: 'test contents',
});

expect(PostmessageTransport.prototype.send).toHaveBeenCalledWith(
WidgetApiFromWidgetAction.MSC4039DownloadFileAction,
{ content_uri: "mxc://example.com/test_file" },
);
});

it('should reject the request if the api is not supported', async () => {
jest.mocked(PostmessageTransport.prototype.send).mockResolvedValueOnce(
{ supported_versions: [] } as ISupportedVersionsActionResponseData,
);

await expect(widgetApi.downloadFile("mxc://example.com/test_file")).rejects.toThrow(
"The download_file action is not supported by the client.",
);

expect(PostmessageTransport.prototype.send)
.not.toHaveBeenCalledWith(WidgetApiFromWidgetAction.MSC4039GetMediaConfigAction, expect.anything());
});

it('should handle an error', async () => {
jest.mocked(PostmessageTransport.prototype.send).mockResolvedValueOnce(
{ supported_versions: [UnstableApiVersion.MSC4039] } as ISupportedVersionsActionResponseData,
);
jest.mocked(PostmessageTransport.prototype.send).mockRejectedValue(
new Error('An error occurred'),
);

await expect(widgetApi.downloadFile("mxc://example.com/test_file")).rejects.toThrow(
'An error occurred',
);
});
});
});

0 comments on commit feb02ee

Please sign in to comment.