From d64636a9d7d52e15e83ce70b4a118b0f3c7df7b4 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Fri, 23 Aug 2024 15:27:34 +0200 Subject: [PATCH 1/3] Implement download_file action Signed-off-by: Michael Weimann --- src/ClientWidgetApi.ts | 28 +++++++++ src/WidgetApi.ts | 25 ++++++++ src/driver/WidgetDriver.ts | 11 ++++ src/index.ts | 1 + src/interfaces/Capabilities.ts | 4 ++ src/interfaces/DownloadFileAction.ts | 40 +++++++++++++ src/interfaces/WidgetApiAction.ts | 5 ++ test/ClientWidgetApi-test.ts | 86 +++++++++++++++++++++++++++- test/WidgetApi-test.ts | 47 +++++++++++++++ 9 files changed, 246 insertions(+), 1 deletion(-) create mode 100644 src/interfaces/DownloadFileAction.ts diff --git a/src/ClientWidgetApi.ts b/src/ClientWidgetApi.ts index 854f8c1..ea3e61c 100644 --- a/src/ClientWidgetApi.ts +++ b/src/ClientWidgetApi.ts @@ -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 @@ -824,6 +828,28 @@ export class ClientWidgetApi extends EventEmitter { } } + private async handleDownloadFile(request: IDownloadFileActionFromWidgetActionRequest) { + if (!this.hasCapability(MatrixCapabilities.MSC4039DownloadFile)) { + return this.transport.reply(request, { + error: { message: "Missing capability" }, + }); + } + + try { + const result = await this.driver.downloadFile(request.data.content_uri); + + return this.transport.reply( + request, + { file: result.file }, + ); + } catch (e) { + console.error("error while downloading a file", e); + this.transport.reply(request, { + error: { message: "Unexpected error while downloading a file" }, + }); + } + } + private handleMessage(ev: CustomEvent) { if (this.isStopped) return; const actionEv = new CustomEvent(`action:${ev.detail.action}`, { @@ -863,6 +889,8 @@ export class ClientWidgetApi extends EventEmitter { return this.handleGetMediaConfig(ev.detail); case WidgetApiFromWidgetAction.MSC4039UploadFileAction: return this.handleUploadFile(ev.detail); + case WidgetApiFromWidgetAction.MSC4039DownloadFileAction: + return this.handleDownloadFile(ev.detail); case WidgetApiFromWidgetAction.MSC4157UpdateDelayedEvent: return this.handleUpdateDelayedEvent(ev.detail); diff --git a/src/WidgetApi.ts b/src/WidgetApi.ts index c5fa2c1..dfd9c57 100644 --- a/src/WidgetApi.ts +++ b/src/WidgetApi.ts @@ -90,6 +90,10 @@ import { IUpdateDelayedEventFromWidgetResponseData, UpdateDelayedEventAction, } from "./interfaces/UpdateDelayedEventAction"; +import { + IDownloadFileActionFromWidgetRequestData, + IDownloadFileActionFromWidgetResponseData, +} from "./interfaces/DownloadFileAction"; /** * API handler for widgets. This raises events for each action @@ -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 of the file to download. + * @returns Resolves to the contents of the file. + */ + public async downloadFile(contentUri: string): Promise { + 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. diff --git a/src/driver/WidgetDriver.ts b/src/driver/WidgetDriver.ts index dd89ea9..cdb92a4 100644 --- a/src/driver/WidgetDriver.ts +++ b/src/driver/WidgetDriver.ts @@ -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 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"); + } } diff --git a/src/index.ts b/src/index.ts index c66009c..114781e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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"; diff --git a/src/interfaces/Capabilities.ts b/src/interfaces/Capabilities.ts index f0d3002..9baaae1 100644 --- a/src/interfaces/Capabilities.ts +++ b/src/interfaces/Capabilities.ts @@ -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. diff --git a/src/interfaces/DownloadFileAction.ts b/src/interfaces/DownloadFileAction.ts new file mode 100644 index 0000000..48ba6dd --- /dev/null +++ b/src/interfaces/DownloadFileAction.ts @@ -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; +} diff --git a/src/interfaces/WidgetApiAction.ts b/src/interfaces/WidgetApiAction.ts index 6af56eb..1aa7c17 100644 --- a/src/interfaces/WidgetApiAction.ts +++ b/src/interfaces/WidgetApiAction.ts @@ -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. */ diff --git a/test/ClientWidgetApi-test.ts b/test/ClientWidgetApi-test.ts index 5882c36..9ceefeb 100644 --- a/test/ClientWidgetApi-test.ts +++ b/test/ClientWidgetApi-test.ts @@ -29,6 +29,7 @@ import { WidgetApiDirection } from '../src/interfaces/WidgetApiDirection'; import { Widget } from '../src/models/Widget'; import { PostmessageTransport } from '../src/transport/PostmessageTransport'; import { + IDownloadFileActionFromWidgetActionRequest, IReadEventFromWidgetActionRequest, ISendEventFromWidgetActionRequest, IUpdateDelayedEventFromWidgetActionRequest, @@ -91,6 +92,7 @@ describe('ClientWidgetApi', () => { searchUserDirectory: jest.fn(), getMediaConfig: jest.fn(), uploadFile: jest.fn(), + downloadFile: jest.fn(), } as Partial as jest.Mocked; clientWidgetApi = new ClientWidgetApi( @@ -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, @@ -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://...', @@ -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' }, + }); + }); + }); + }); }); diff --git a/test/WidgetApi-test.ts b/test/WidgetApi-test.ts index e236606..fead968 100644 --- a/test/WidgetApi-test.ts +++ b/test/WidgetApi-test.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { IDownloadFileActionFromWidgetResponseData } from '../src'; import { UnstableApiVersion } from '../src/interfaces/ApiVersion'; import { IGetMediaConfigActionFromWidgetResponseData } from '../src/interfaces/GetMediaConfigAction'; import { IReadRelationsFromWidgetResponseData } from '../src/interfaces/ReadRelationsAction'; @@ -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', + ); + }); + }); }); From 7c06a33680cdef6873e5e70070db11e79ed39979 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Wed, 28 Aug 2024 10:51:46 +0200 Subject: [PATCH 2/3] Tweak imports Signed-off-by: Michael Weimann --- src/WidgetApi.ts | 8 ++++---- test/ClientWidgetApi-test.ts | 2 +- test/WidgetApi-test.ts | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/WidgetApi.ts b/src/WidgetApi.ts index dfd9c57..4fbbfba 100644 --- a/src/WidgetApi.ts +++ b/src/WidgetApi.ts @@ -85,15 +85,15 @@ import { IUploadFileActionFromWidgetRequestData, IUploadFileActionFromWidgetResponseData, } from "./interfaces/UploadFileAction"; +import { + IDownloadFileActionFromWidgetRequestData, + IDownloadFileActionFromWidgetResponseData, +} from "./interfaces/DownloadFileAction"; import { IUpdateDelayedEventFromWidgetRequestData, IUpdateDelayedEventFromWidgetResponseData, UpdateDelayedEventAction, } from "./interfaces/UpdateDelayedEventAction"; -import { - IDownloadFileActionFromWidgetRequestData, - IDownloadFileActionFromWidgetResponseData, -} from "./interfaces/DownloadFileAction"; /** * API handler for widgets. This raises events for each action diff --git a/test/ClientWidgetApi-test.ts b/test/ClientWidgetApi-test.ts index 9ceefeb..40b5b64 100644 --- a/test/ClientWidgetApi-test.ts +++ b/test/ClientWidgetApi-test.ts @@ -33,10 +33,10 @@ import { IReadEventFromWidgetActionRequest, ISendEventFromWidgetActionRequest, IUpdateDelayedEventFromWidgetActionRequest, + IUploadFileActionFromWidgetActionRequest, UpdateDelayedEventAction, } from '../src'; import { IGetMediaConfigActionFromWidgetActionRequest } from '../src/interfaces/GetMediaConfigAction'; -import { IUploadFileActionFromWidgetActionRequest } from '../src/interfaces/UploadFileAction'; jest.mock('../src/transport/PostmessageTransport') diff --git a/test/WidgetApi-test.ts b/test/WidgetApi-test.ts index fead968..4d17d6f 100644 --- a/test/WidgetApi-test.ts +++ b/test/WidgetApi-test.ts @@ -14,13 +14,13 @@ * limitations under the License. */ -import { IDownloadFileActionFromWidgetResponseData } from '../src'; import { UnstableApiVersion } from '../src/interfaces/ApiVersion'; import { IGetMediaConfigActionFromWidgetResponseData } from '../src/interfaces/GetMediaConfigAction'; import { IReadRelationsFromWidgetResponseData } from '../src/interfaces/ReadRelationsAction'; 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'; From be97fad653c6e29caacfc4aa728ebfffd702c0cb Mon Sep 17 00:00:00 2001 From: Kim Brose Date: Thu, 29 Aug 2024 19:03:51 +0200 Subject: [PATCH 3/3] address PR feedback Signed-off-by: Kim Brose --- src/ClientWidgetApi.ts | 2 +- src/WidgetApi.ts | 2 +- src/driver/WidgetDriver.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ClientWidgetApi.ts b/src/ClientWidgetApi.ts index ea3e61c..de88957 100644 --- a/src/ClientWidgetApi.ts +++ b/src/ClientWidgetApi.ts @@ -828,7 +828,7 @@ export class ClientWidgetApi extends EventEmitter { } } - private async handleDownloadFile(request: IDownloadFileActionFromWidgetActionRequest) { + private async handleDownloadFile(request: IDownloadFileActionFromWidgetActionRequest): Promise { if (!this.hasCapability(MatrixCapabilities.MSC4039DownloadFile)) { return this.transport.reply(request, { error: { message: "Missing capability" }, diff --git a/src/WidgetApi.ts b/src/WidgetApi.ts index 4fbbfba..eb567d0 100644 --- a/src/WidgetApi.ts +++ b/src/WidgetApi.ts @@ -756,7 +756,7 @@ export class WidgetApi extends EventEmitter { /** * Download a file from the media repository on the homeserver. - * @param contentUri - MXC of the file to download. + * @param contentUri - MXC URI of the file to download. * @returns Resolves to the contents of the file. */ public async downloadFile(contentUri: string): Promise { diff --git a/src/driver/WidgetDriver.ts b/src/driver/WidgetDriver.ts index cdb92a4..590d677 100644 --- a/src/driver/WidgetDriver.ts +++ b/src/driver/WidgetDriver.ts @@ -350,7 +350,7 @@ export abstract class WidgetDriver { /** * Download a file from the media repository on the homeserver. - * @param contentUri - MXC of the file to download. + * @param contentUri - MXC URI of the file to download. * @returns Resolves to the contents of the file. */ public downloadFile(