Skip to content

Commit feb02ee

Browse files
Implement MSC4039 download_file action (#99)
* 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]>
1 parent 3c9543c commit feb02ee

File tree

9 files changed

+247
-2
lines changed

9 files changed

+247
-2
lines changed

src/ClientWidgetApi.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,10 @@ import {
9898
IUploadFileActionFromWidgetActionRequest,
9999
IUploadFileActionFromWidgetResponseData,
100100
} from "./interfaces/UploadFileAction";
101+
import {
102+
IDownloadFileActionFromWidgetActionRequest,
103+
IDownloadFileActionFromWidgetResponseData,
104+
} from "./interfaces/DownloadFileAction";
101105

102106
/**
103107
* API handler for the client side of widgets. This raises events
@@ -824,6 +828,28 @@ export class ClientWidgetApi extends EventEmitter {
824828
}
825829
}
826830

831+
private async handleDownloadFile(request: IDownloadFileActionFromWidgetActionRequest): Promise<void> {
832+
if (!this.hasCapability(MatrixCapabilities.MSC4039DownloadFile)) {
833+
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
834+
error: { message: "Missing capability" },
835+
});
836+
}
837+
838+
try {
839+
const result = await this.driver.downloadFile(request.data.content_uri);
840+
841+
return this.transport.reply<IDownloadFileActionFromWidgetResponseData>(
842+
request,
843+
{ file: result.file },
844+
);
845+
} catch (e) {
846+
console.error("error while downloading a file", e);
847+
this.transport.reply<IWidgetApiErrorResponseData>(request, {
848+
error: { message: "Unexpected error while downloading a file" },
849+
});
850+
}
851+
}
852+
827853
private handleMessage(ev: CustomEvent<IWidgetApiRequest>) {
828854
if (this.isStopped) return;
829855
const actionEv = new CustomEvent(`action:${ev.detail.action}`, {
@@ -863,6 +889,8 @@ export class ClientWidgetApi extends EventEmitter {
863889
return this.handleGetMediaConfig(<IGetMediaConfigActionFromWidgetActionRequest>ev.detail);
864890
case WidgetApiFromWidgetAction.MSC4039UploadFileAction:
865891
return this.handleUploadFile(<IUploadFileActionFromWidgetActionRequest>ev.detail);
892+
case WidgetApiFromWidgetAction.MSC4039DownloadFileAction:
893+
return this.handleDownloadFile(<IDownloadFileActionFromWidgetActionRequest>ev.detail);
866894
case WidgetApiFromWidgetAction.MSC4157UpdateDelayedEvent:
867895
return this.handleUpdateDelayedEvent(<IUpdateDelayedEventFromWidgetActionRequest>ev.detail);
868896

src/WidgetApi.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,10 @@ import {
8585
IUploadFileActionFromWidgetRequestData,
8686
IUploadFileActionFromWidgetResponseData,
8787
} from "./interfaces/UploadFileAction";
88+
import {
89+
IDownloadFileActionFromWidgetRequestData,
90+
IDownloadFileActionFromWidgetResponseData,
91+
} from "./interfaces/DownloadFileAction";
8892
import {
8993
IUpdateDelayedEventFromWidgetRequestData,
9094
IUpdateDelayedEventFromWidgetResponseData,
@@ -750,6 +754,27 @@ export class WidgetApi extends EventEmitter {
750754
>(WidgetApiFromWidgetAction.MSC4039UploadFileAction, data);
751755
}
752756

757+
/**
758+
* Download a file from the media repository on the homeserver.
759+
* @param contentUri - MXC URI of the file to download.
760+
* @returns Resolves to the contents of the file.
761+
*/
762+
public async downloadFile(contentUri: string): Promise<IDownloadFileActionFromWidgetResponseData> {
763+
const versions = await this.getClientVersions();
764+
if (!versions.includes(UnstableApiVersion.MSC4039)) {
765+
throw new Error("The download_file action is not supported by the client.")
766+
}
767+
768+
const data: IDownloadFileActionFromWidgetRequestData = {
769+
content_uri: contentUri,
770+
};
771+
772+
return this.transport.send<
773+
IDownloadFileActionFromWidgetRequestData,
774+
IDownloadFileActionFromWidgetResponseData
775+
>(WidgetApiFromWidgetAction.MSC4039DownloadFileAction, data);
776+
}
777+
753778
/**
754779
* Starts the communication channel. This should be done early to ensure
755780
* that messages are not missed. Communication can only be stopped by the client.

src/driver/WidgetDriver.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,4 +347,15 @@ export abstract class WidgetDriver {
347347
): Promise<{ contentUri: string }> {
348348
throw new Error("Upload file is not implemented");
349349
}
350+
351+
/**
352+
* Download a file from the media repository on the homeserver.
353+
* @param contentUri - MXC URI of the file to download.
354+
* @returns Resolves to the contents of the file.
355+
*/
356+
public downloadFile(
357+
contentUri: string,
358+
): Promise<{ file: XMLHttpRequestBodyInit }> {
359+
throw new Error("Download file is not implemented");
360+
}
350361
}

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ export * from "./interfaces/ReadRelationsAction";
6161
export * from "./interfaces/GetMediaConfigAction";
6262
export * from "./interfaces/UpdateDelayedEventAction";
6363
export * from "./interfaces/UploadFileAction";
64+
export * from "./interfaces/DownloadFileAction";
6465

6566
// Complex models
6667
export * from "./models/WidgetEventCapability";

src/interfaces/Capabilities.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ export enum MatrixCapabilities {
4141
/**
4242
* @deprecated It is not recommended to rely on this existing - it can be removed without notice.
4343
*/
44+
MSC4039DownloadFile = "org.matrix.msc4039.download_file",
45+
/**
46+
* @deprecated It is not recommended to rely on this existing - it can be removed without notice.
47+
*/
4448
MSC4157SendDelayedEvent = "org.matrix.msc4157.send.delayed_event",
4549
/**
4650
* @deprecated It is not recommended to rely on this existing - it can be removed without notice.
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/*
2+
* Copyright 2024 Nordeck IT + Consulting GmbH
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { IWidgetApiRequest, IWidgetApiRequestData } from "./IWidgetApiRequest";
18+
import { IWidgetApiResponseData } from "./IWidgetApiResponse";
19+
import { WidgetApiFromWidgetAction } from "./WidgetApiAction";
20+
21+
export interface IDownloadFileActionFromWidgetRequestData
22+
extends IWidgetApiRequestData {
23+
content_uri: string; // eslint-disable-line camelcase
24+
}
25+
26+
export interface IDownloadFileActionFromWidgetActionRequest
27+
extends IWidgetApiRequest {
28+
action: WidgetApiFromWidgetAction.MSC4039DownloadFileAction;
29+
data: IDownloadFileActionFromWidgetRequestData;
30+
}
31+
32+
export interface IDownloadFileActionFromWidgetResponseData
33+
extends IWidgetApiResponseData {
34+
file: XMLHttpRequestBodyInit;
35+
}
36+
37+
export interface IDownloadFileActionFromWidgetActionResponse
38+
extends IDownloadFileActionFromWidgetActionRequest {
39+
response: IDownloadFileActionFromWidgetResponseData;
40+
}

src/interfaces/WidgetApiAction.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,11 @@ export enum WidgetApiFromWidgetAction {
8080
*/
8181
MSC4039UploadFileAction = "org.matrix.msc4039.upload_file",
8282

83+
/**
84+
* @deprecated It is not recommended to rely on this existing - it can be removed without notice.
85+
*/
86+
MSC4039DownloadFileAction = "org.matrix.msc4039.download_file",
87+
8388
/**
8489
* @deprecated It is not recommended to rely on this existing - it can be removed without notice.
8590
*/

test/ClientWidgetApi-test.ts

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,14 @@ import { WidgetApiDirection } from '../src/interfaces/WidgetApiDirection';
2929
import { Widget } from '../src/models/Widget';
3030
import { PostmessageTransport } from '../src/transport/PostmessageTransport';
3131
import {
32+
IDownloadFileActionFromWidgetActionRequest,
3233
IReadEventFromWidgetActionRequest,
3334
ISendEventFromWidgetActionRequest,
3435
IUpdateDelayedEventFromWidgetActionRequest,
36+
IUploadFileActionFromWidgetActionRequest,
3537
UpdateDelayedEventAction,
3638
} from '../src';
3739
import { IGetMediaConfigActionFromWidgetActionRequest } from '../src/interfaces/GetMediaConfigAction';
38-
import { IUploadFileActionFromWidgetActionRequest } from '../src/interfaces/UploadFileAction';
3940

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

@@ -91,6 +92,7 @@ describe('ClientWidgetApi', () => {
9192
searchUserDirectory: jest.fn(),
9293
getMediaConfig: jest.fn(),
9394
uploadFile: jest.fn(),
95+
downloadFile: jest.fn(),
9496
} as Partial<WidgetDriver> as jest.Mocked<WidgetDriver>;
9597

9698
clientWidgetApi = new ClientWidgetApi(
@@ -1083,7 +1085,7 @@ describe('ClientWidgetApi', () => {
10831085
});
10841086
});
10851087

1086-
describe('org.matrix.msc4039.upload_file action', () => {
1088+
describe('MSC4039', () => {
10871089
it('should present as supported api version', () => {
10881090
const event: ISupportedVersionsActionRequest = {
10891091
api: WidgetApiDirection.FromWidget,
@@ -1101,7 +1103,9 @@ describe('ClientWidgetApi', () => {
11011103
]),
11021104
});
11031105
});
1106+
});
11041107

1108+
describe('org.matrix.msc4039.upload_file action', () => {
11051109
it('should handle and process the request', async () => {
11061110
driver.uploadFile.mockResolvedValue({
11071111
contentUri: 'mxc://...',
@@ -1180,4 +1184,84 @@ describe('ClientWidgetApi', () => {
11801184
});
11811185
});
11821186
});
1187+
1188+
describe('org.matrix.msc4039.download_file action', () => {
1189+
it('should handle and process the request', async () => {
1190+
driver.downloadFile.mockResolvedValue({
1191+
file: 'test contents',
1192+
});
1193+
1194+
const event: IDownloadFileActionFromWidgetActionRequest = {
1195+
api: WidgetApiDirection.FromWidget,
1196+
widgetId: 'test',
1197+
requestId: '0',
1198+
action: WidgetApiFromWidgetAction.MSC4039DownloadFileAction,
1199+
data: {
1200+
content_uri: 'mxc://example.com/test_file',
1201+
},
1202+
};
1203+
1204+
await loadIframe([
1205+
'org.matrix.msc4039.download_file',
1206+
]);
1207+
1208+
emitEvent(new CustomEvent('', { detail: event }));
1209+
1210+
await waitFor(() => {
1211+
expect(transport.reply).toHaveBeenCalledWith(event, {
1212+
file: 'test contents',
1213+
});
1214+
});
1215+
1216+
expect(driver.downloadFile).toHaveBeenCalledWith( 'mxc://example.com/test_file');
1217+
});
1218+
1219+
it('should reject requests when the capability was not requested', async () => {
1220+
const event: IDownloadFileActionFromWidgetActionRequest = {
1221+
api: WidgetApiDirection.FromWidget,
1222+
widgetId: 'test',
1223+
requestId: '0',
1224+
action: WidgetApiFromWidgetAction.MSC4039DownloadFileAction,
1225+
data: {
1226+
content_uri: 'mxc://example.com/test_file',
1227+
},
1228+
};
1229+
1230+
emitEvent(new CustomEvent('', { detail: event }));
1231+
1232+
expect(transport.reply).toBeCalledWith(event, {
1233+
error: { message: 'Missing capability' },
1234+
});
1235+
1236+
expect(driver.uploadFile).not.toBeCalled();
1237+
});
1238+
1239+
it('should reject requests when the driver throws an exception', async () => {
1240+
driver.getMediaConfig.mockRejectedValue(
1241+
new Error("M_LIMIT_EXCEEDED: Too many requests"),
1242+
);
1243+
1244+
const event: IDownloadFileActionFromWidgetActionRequest = {
1245+
api: WidgetApiDirection.FromWidget,
1246+
widgetId: 'test',
1247+
requestId: '0',
1248+
action: WidgetApiFromWidgetAction.MSC4039DownloadFileAction,
1249+
data: {
1250+
content_uri: 'mxc://example.com/test_file',
1251+
},
1252+
};
1253+
1254+
await loadIframe([
1255+
'org.matrix.msc4039.download_file',
1256+
]);
1257+
1258+
emitEvent(new CustomEvent('', { detail: event }));
1259+
1260+
await waitFor(() => {
1261+
expect(transport.reply).toBeCalledWith(event, {
1262+
error: { message: 'Unexpected error while downloading a file' },
1263+
});
1264+
});
1265+
});
1266+
});
11831267
});

test/WidgetApi-test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { IReadRelationsFromWidgetResponseData } from '../src/interfaces/ReadRela
2020
import { ISendEventFromWidgetResponseData } from '../src/interfaces/SendEventAction';
2121
import { ISupportedVersionsActionResponseData } from '../src/interfaces/SupportedVersionsAction';
2222
import { IUploadFileActionFromWidgetResponseData } from '../src/interfaces/UploadFileAction';
23+
import { IDownloadFileActionFromWidgetResponseData } from '../src/interfaces/DownloadFileAction';
2324
import { IUserDirectorySearchFromWidgetResponseData } from '../src/interfaces/UserDirectorySearchAction';
2425
import { WidgetApiFromWidgetAction } from '../src/interfaces/WidgetApiAction';
2526
import { PostmessageTransport } from '../src/transport/PostmessageTransport';
@@ -380,4 +381,50 @@ describe('WidgetApi', () => {
380381
);
381382
});
382383
});
384+
385+
describe('downloadFile', () => {
386+
it('should forward the request to the ClientWidgetApi', async () => {
387+
jest.mocked(PostmessageTransport.prototype.send).mockResolvedValueOnce(
388+
{ supported_versions: [UnstableApiVersion.MSC4039] } as ISupportedVersionsActionResponseData,
389+
);
390+
jest.mocked(PostmessageTransport.prototype.send).mockResolvedValue(
391+
{ file: 'test contents' } as IDownloadFileActionFromWidgetResponseData,
392+
);
393+
394+
await expect(widgetApi.downloadFile("mxc://example.com/test_file")).resolves.toEqual({
395+
file: 'test contents',
396+
});
397+
398+
expect(PostmessageTransport.prototype.send).toHaveBeenCalledWith(
399+
WidgetApiFromWidgetAction.MSC4039DownloadFileAction,
400+
{ content_uri: "mxc://example.com/test_file" },
401+
);
402+
});
403+
404+
it('should reject the request if the api is not supported', async () => {
405+
jest.mocked(PostmessageTransport.prototype.send).mockResolvedValueOnce(
406+
{ supported_versions: [] } as ISupportedVersionsActionResponseData,
407+
);
408+
409+
await expect(widgetApi.downloadFile("mxc://example.com/test_file")).rejects.toThrow(
410+
"The download_file action is not supported by the client.",
411+
);
412+
413+
expect(PostmessageTransport.prototype.send)
414+
.not.toHaveBeenCalledWith(WidgetApiFromWidgetAction.MSC4039GetMediaConfigAction, expect.anything());
415+
});
416+
417+
it('should handle an error', async () => {
418+
jest.mocked(PostmessageTransport.prototype.send).mockResolvedValueOnce(
419+
{ supported_versions: [UnstableApiVersion.MSC4039] } as ISupportedVersionsActionResponseData,
420+
);
421+
jest.mocked(PostmessageTransport.prototype.send).mockRejectedValue(
422+
new Error('An error occurred'),
423+
);
424+
425+
await expect(widgetApi.downloadFile("mxc://example.com/test_file")).rejects.toThrow(
426+
'An error occurred',
427+
);
428+
});
429+
});
383430
});

0 commit comments

Comments
 (0)