diff --git a/src/utils/DecryptFile.ts b/src/utils/DecryptFile.ts index be984787a3..506c40441f 100644 --- a/src/utils/DecryptFile.ts +++ b/src/utils/DecryptFile.ts @@ -47,7 +47,8 @@ export class DecryptError extends Error { * @param {IMediaEventInfo} info The info parameter taken from the matrix event. * @returns {Promise} Resolves to a Blob of the file. */ -export async function decryptFile(file: IEncryptedFile, info?: IMediaEventInfo): Promise { +export async function decryptFile(file?: IEncryptedFile, info?: IMediaEventInfo): Promise { + // throws if file is falsy const media = mediaFromContent({ file }); let responseData: ArrayBuffer; @@ -64,7 +65,7 @@ export async function decryptFile(file: IEncryptedFile, info?: IMediaEventInfo): try { // Decrypt the array buffer using the information taken from the event content. - const dataArray = await encrypt.decryptAttachment(responseData, file); + const dataArray = await encrypt.decryptAttachment(responseData, file!); // Turn the array into a Blob and give it the correct MIME-type. // IMPORTANT: we must not allow scriptable mime-types into Blobs otherwise diff --git a/src/utils/exportUtils/Exporter.ts b/src/utils/exportUtils/Exporter.ts index 74e1e80f64..21bd72c217 100644 --- a/src/utils/exportUtils/Exporter.ts +++ b/src/utils/exportUtils/Exporter.ts @@ -51,7 +51,8 @@ export default abstract class Exporter { if ( exportOptions.maxSize < 1 * 1024 * 1024 || // Less than 1 MB exportOptions.maxSize > 8000 * 1024 * 1024 || // More than 8 GB - exportOptions.numberOfMessages > 10 ** 8 + (!!exportOptions.numberOfMessages && exportOptions.numberOfMessages > 10 ** 8) || + (exportType === ExportType.LastNMessages && !exportOptions.numberOfMessages) ) { throw new Error("Invalid export options"); } @@ -123,10 +124,11 @@ export default abstract class Exporter { } protected setEventMetadata(event: MatrixEvent): MatrixEvent { - const roomState = this.client.getRoom(this.room.roomId).currentState; - event.sender = roomState.getSentinelMember(event.getSender()); + const roomState = this.client.getRoom(this.room.roomId)?.currentState; + const sender = event.getSender(); + event.sender = (!!sender && roomState?.getSentinelMember(sender)) || null; if (event.getType() === "m.room.member") { - event.target = roomState.getSentinelMember(event.getStateKey()); + event.target = roomState?.getSentinelMember(event.getStateKey()!) ?? null; } return event; } @@ -135,6 +137,8 @@ export default abstract class Exporter { let limit: number; switch (this.exportType) { case ExportType.LastNMessages: + // validated in constructor that numberOfMessages is defined + // when export type is LastNMessages limit = this.exportOptions.numberOfMessages!; break; case ExportType.Timeline: @@ -221,8 +225,14 @@ export default abstract class Exporter { return events; } - protected async getMediaBlob(event: MatrixEvent): Promise { - let blob: Blob | undefined; + /** + * Decrypts if necessary, and fetches media from a matrix event + * @param event - matrix event with media event content + * @resolves when media has been fetched + * @throws if media was unable to be fetched + */ + protected async getMediaBlob(event: MatrixEvent): Promise { + let blob: Blob | undefined = undefined; try { const isEncrypted = event.isEncrypted(); const content = event.getContent(); @@ -231,12 +241,18 @@ export default abstract class Exporter { blob = await decryptFile(content.file); } else { const media = mediaFromContent(content); + if (!media.srcHttp) { + throw new Error("Cannot fetch without srcHttp"); + } const image = await fetch(media.srcHttp); blob = await image.blob(); } } catch (err) { logger.log("Error decrypting media"); } + if (!blob) { + throw new Error("Unable to fetch file"); + } return blob; } diff --git a/test/utils/exportUtils/HTMLExport-test.ts b/test/utils/exportUtils/HTMLExport-test.ts index 04f4f19e3c..90715929a4 100644 --- a/test/utils/exportUtils/HTMLExport-test.ts +++ b/test/utils/exportUtils/HTMLExport-test.ts @@ -14,7 +14,16 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { EventType, IRoomEvent, MatrixClient, MatrixEvent, MsgType, Room, RoomMember } from "matrix-js-sdk/src/matrix"; +import { + EventType, + IRoomEvent, + MatrixClient, + MatrixEvent, + MsgType, + Room, + RoomMember, + RoomState, +} from "matrix-js-sdk/src/matrix"; import fetchMock from "fetch-mock-jest"; import { filterConsole, mkStubRoom, REPEATABLE_DATE, stubClient } from "../../test-utils"; @@ -51,6 +60,20 @@ const EVENT_ATTACHMENT: IRoomEvent = { }, }; +const EVENT_ATTACHMENT_MALFORMED: IRoomEvent = { + event_id: "$2", + type: EventType.RoomMessage, + sender: "@alice:example.com", + origin_server_ts: 1, + content: { + msgtype: MsgType.File, + body: "hello.txt", + file: { + url: undefined, + }, + }, +}; + describe("HTMLExport", () => { let client: jest.Mocked; let room: Room; @@ -107,6 +130,22 @@ describe("HTMLExport", () => { fetchMock.get(media.srcHttp!, body); } + it("should throw when created with invalid config for LastNMessages", async () => { + expect( + () => + new HTMLExporter( + room, + ExportType.LastNMessages, + { + attachmentsIncluded: false, + maxSize: 1_024 * 1_024, + numberOfMessages: undefined, + }, + () => {}, + ), + ).toThrow("Invalid export options"); + }); + it("should have an SDK-branded destination file name", () => { const roomName = "My / Test / Room: Welcome"; const stubOptions: IExportOptions = { @@ -266,6 +305,56 @@ describe("HTMLExport", () => { expect(await file.text()).toBe(avatarContent); }); + it("should handle when an event has no sender", async () => { + const EVENT_MESSAGE_NO_SENDER: IRoomEvent = { + event_id: "$1", + type: EventType.RoomMessage, + sender: "", + origin_server_ts: 0, + content: { + msgtype: "m.text", + body: "Message with no sender", + }, + }; + mockMessages(EVENT_MESSAGE_NO_SENDER); + + const exporter = new HTMLExporter( + room, + ExportType.Timeline, + { + attachmentsIncluded: false, + maxSize: 1_024 * 1_024, + }, + () => {}, + ); + + await exporter.export(); + + const file = getMessageFile(exporter); + expect(await file.text()).toContain(EVENT_MESSAGE_NO_SENDER.content.body); + }); + + it("should handle when events sender cannot be found in room state", async () => { + mockMessages(EVENT_MESSAGE); + + jest.spyOn(RoomState.prototype, "getSentinelMember").mockReturnValue(null); + + const exporter = new HTMLExporter( + room, + ExportType.Timeline, + { + attachmentsIncluded: false, + maxSize: 1_024 * 1_024, + }, + () => {}, + ); + + await exporter.export(); + + const file = getMessageFile(exporter); + expect(await file.text()).toContain(EVENT_MESSAGE.content.body); + }); + it("should include attachments", async () => { mockMessages(EVENT_MESSAGE, EVENT_ATTACHMENT); const attachmentBody = "Lorem ipsum dolor sit amet"; @@ -294,6 +383,68 @@ describe("HTMLExport", () => { expect(text).toBe(attachmentBody); }); + it("should handle when attachment cannot be fetched", async () => { + mockMessages(EVENT_MESSAGE, EVENT_ATTACHMENT_MALFORMED, EVENT_ATTACHMENT); + const attachmentBody = "Lorem ipsum dolor sit amet"; + + mockMxc("mxc://example.org/test-id", attachmentBody); + + const exporter = new HTMLExporter( + room, + ExportType.Timeline, + { + attachmentsIncluded: true, + maxSize: 1_024 * 1_024, + }, + () => {}, + ); + + await exporter.export(); + + // good attachment present + const files = getFiles(exporter); + const file = files[Object.keys(files).find((k) => k.endsWith(".txt"))!]; + expect(file).not.toBeUndefined(); + + // Ensure that the attachment has the expected content + const text = await file.text(); + expect(text).toBe(attachmentBody); + + // messages export still successful + const messagesFile = getMessageFile(exporter); + expect(await messagesFile.text()).toBeTruthy(); + }); + + it("should handle when attachment srcHttp is falsy", async () => { + mockMessages(EVENT_MESSAGE, EVENT_ATTACHMENT); + const attachmentBody = "Lorem ipsum dolor sit amet"; + + mockMxc("mxc://example.org/test-id", attachmentBody); + + jest.spyOn(client, "mxcUrlToHttp").mockReturnValue(null); + + const exporter = new HTMLExporter( + room, + ExportType.Timeline, + { + attachmentsIncluded: true, + maxSize: 1_024 * 1_024, + }, + () => {}, + ); + + await exporter.export(); + + // attachment not present + const files = getFiles(exporter); + const file = files[Object.keys(files).find((k) => k.endsWith(".txt"))!]; + expect(file).toBeUndefined(); + + // messages export still successful + const messagesFile = getMessageFile(exporter); + expect(await messagesFile.text()).toBeTruthy(); + }); + it("should omit attachments", async () => { mockMessages(EVENT_MESSAGE, EVENT_ATTACHMENT); @@ -323,6 +474,7 @@ describe("HTMLExport", () => { { attachmentsIncluded: false, maxSize: 1_024 * 1_024, + numberOfMessages: 5000, }, () => {}, );