Merge branch 'master' into develop
This commit is contained in:
commit
6d1b702214
11 changed files with 327 additions and 87 deletions
|
@ -1,3 +1,11 @@
|
||||||
|
Changes in [1.11.85](https://github.com/element-hq/element-web/releases/tag/v1.11.85) (2024-11-12)
|
||||||
|
==================================================================================================
|
||||||
|
# Security
|
||||||
|
- Fixes for [CVE-2024-51750](https://www.cve.org/CVERecord?id=CVE-2024-51750) / [GHSA-w36j-v56h-q9pc](https://github.com/element-hq/element-web/security/advisories/GHSA-w36j-v56h-q9pc)
|
||||||
|
- Fixes for [CVE-2024-51749](https://www.cve.org/CVERecord?id=CVE-2024-51749) / [GHSA-5486-384g-mcx2](https://github.com/element-hq/element-web/security/advisories/GHSA-5486-384g-mcx2)
|
||||||
|
- Update JS SDK with the fixes for [CVE-2024-50336](https://www.cve.org/CVERecord?id=CVE-2024-50336) / [GHSA-xvg8-m4x3-w6xr](https://github.com/matrix-org/matrix-js-sdk/security/advisories/GHSA-xvg8-m4x3-w6xr)
|
||||||
|
|
||||||
|
|
||||||
Changes in [1.11.84](https://github.com/element-hq/element-web/releases/tag/v1.11.84) (2024-11-05)
|
Changes in [1.11.84](https://github.com/element-hq/element-web/releases/tag/v1.11.84) (2024-11-05)
|
||||||
==================================================================================================
|
==================================================================================================
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
|
@ -38,7 +38,7 @@ const config: Config = {
|
||||||
"recorderWorkletFactory": "<rootDir>/__mocks__/empty.js",
|
"recorderWorkletFactory": "<rootDir>/__mocks__/empty.js",
|
||||||
"^fetch-mock$": "<rootDir>/node_modules/fetch-mock",
|
"^fetch-mock$": "<rootDir>/node_modules/fetch-mock",
|
||||||
},
|
},
|
||||||
transformIgnorePatterns: ["/node_modules/(?!matrix-js-sdk).+$"],
|
transformIgnorePatterns: ["/node_modules/(?!(mime|matrix-js-sdk)).+$"],
|
||||||
collectCoverageFrom: [
|
collectCoverageFrom: [
|
||||||
"<rootDir>/src/**/*.{js,ts,tsx}",
|
"<rootDir>/src/**/*.{js,ts,tsx}",
|
||||||
// getSessionLock is piped into a different JS context via stringification, and the coverage functionality is
|
// getSessionLock is piped into a different JS context via stringification, and the coverage functionality is
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "element-web",
|
"name": "element-web",
|
||||||
"version": "1.11.84",
|
"version": "1.11.85",
|
||||||
"description": "A feature-rich client for Matrix.org",
|
"description": "A feature-rich client for Matrix.org",
|
||||||
"author": "New Vector Ltd.",
|
"author": "New Vector Ltd.",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
@ -125,6 +125,7 @@
|
||||||
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop",
|
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop",
|
||||||
"matrix-widget-api": "^1.10.0",
|
"matrix-widget-api": "^1.10.0",
|
||||||
"memoize-one": "^6.0.0",
|
"memoize-one": "^6.0.0",
|
||||||
|
"mime": "^4.0.4",
|
||||||
"oidc-client-ts": "^3.0.1",
|
"oidc-client-ts": "^3.0.1",
|
||||||
"opus-recorder": "^8.0.3",
|
"opus-recorder": "^8.0.3",
|
||||||
"pako": "^2.0.3",
|
"pako": "^2.0.3",
|
||||||
|
|
|
@ -6,32 +6,48 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||||
Please see LICENSE files in the repository root for full details.
|
Please see LICENSE files in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
|
||||||
import type { Page } from "@playwright/test";
|
import type { Page } from "@playwright/test";
|
||||||
import { test, expect } from "../../element-web-test";
|
import { test, expect } from "../../element-web-test";
|
||||||
import { ElementAppPage } from "../../pages/ElementAppPage";
|
import { ElementAppPage } from "../../pages/ElementAppPage";
|
||||||
|
import { Credentials } from "../../plugins/homeserver";
|
||||||
|
|
||||||
const STICKER_PICKER_WIDGET_ID = "fake-sticker-picker";
|
const STICKER_PICKER_WIDGET_ID = "fake-sticker-picker";
|
||||||
const STICKER_PICKER_WIDGET_NAME = "Fake Stickers";
|
const STICKER_PICKER_WIDGET_NAME = "Fake Stickers";
|
||||||
const STICKER_NAME = "Test Sticker";
|
const STICKER_NAME = "Test Sticker";
|
||||||
const ROOM_NAME_1 = "Sticker Test";
|
const ROOM_NAME_1 = "Sticker Test";
|
||||||
const ROOM_NAME_2 = "Sticker Test Two";
|
const ROOM_NAME_2 = "Sticker Test Two";
|
||||||
const STICKER_MESSAGE = JSON.stringify({
|
const STICKER_IMAGE = fs.readFileSync("playwright/sample-files/riot.png");
|
||||||
action: "m.sticker",
|
|
||||||
api: "fromWidget",
|
function getStickerMessage(contentUri: string, mimetype: string): string {
|
||||||
data: {
|
return JSON.stringify({
|
||||||
name: "teststicker",
|
action: "m.sticker",
|
||||||
description: STICKER_NAME,
|
api: "fromWidget",
|
||||||
file: "test.png",
|
data: {
|
||||||
content: {
|
name: "teststicker",
|
||||||
body: STICKER_NAME,
|
description: STICKER_NAME,
|
||||||
msgtype: "m.sticker",
|
file: "test.png",
|
||||||
url: "mxc://localhost/somewhere",
|
content: {
|
||||||
|
body: STICKER_NAME,
|
||||||
|
info: {
|
||||||
|
h: 480,
|
||||||
|
mimetype: mimetype,
|
||||||
|
size: 13818,
|
||||||
|
w: 480,
|
||||||
|
},
|
||||||
|
msgtype: "m.sticker",
|
||||||
|
url: contentUri,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
requestId: "1",
|
||||||
requestId: "1",
|
widgetId: STICKER_PICKER_WIDGET_ID,
|
||||||
widgetId: STICKER_PICKER_WIDGET_ID,
|
});
|
||||||
});
|
}
|
||||||
const WIDGET_HTML = `
|
|
||||||
|
function getWidgetHtml(contentUri: string, mimetype: string) {
|
||||||
|
const stickerMessage = getStickerMessage(contentUri, mimetype);
|
||||||
|
return `
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<title>Fake Sticker Picker</title>
|
<title>Fake Sticker Picker</title>
|
||||||
|
@ -51,13 +67,13 @@ const WIDGET_HTML = `
|
||||||
<button name="Send" id="sendsticker">Press for sticker</button>
|
<button name="Send" id="sendsticker">Press for sticker</button>
|
||||||
<script>
|
<script>
|
||||||
document.getElementById('sendsticker').onclick = () => {
|
document.getElementById('sendsticker').onclick = () => {
|
||||||
window.parent.postMessage(${STICKER_MESSAGE}, '*')
|
window.parent.postMessage(${stickerMessage}, '*')
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
`;
|
`;
|
||||||
|
}
|
||||||
async function openStickerPicker(app: ElementAppPage) {
|
async function openStickerPicker(app: ElementAppPage) {
|
||||||
const options = await app.openMessageComposerOptions();
|
const options = await app.openMessageComposerOptions();
|
||||||
await options.getByRole("menuitem", { name: "Sticker" }).click();
|
await options.getByRole("menuitem", { name: "Sticker" }).click();
|
||||||
|
@ -71,7 +87,8 @@ async function sendStickerFromPicker(page: Page) {
|
||||||
await expect(page.locator(".mx_AppTileFullWidth#stickers")).not.toBeVisible();
|
await expect(page.locator(".mx_AppTileFullWidth#stickers")).not.toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function expectTimelineSticker(page: Page, roomId: string) {
|
async function expectTimelineSticker(page: Page, roomId: string, contentUri: string) {
|
||||||
|
const contentId = contentUri.split("/").slice(-1)[0];
|
||||||
// Make sure it's in the right room
|
// Make sure it's in the right room
|
||||||
await expect(page.locator(".mx_EventTile_sticker > a")).toHaveAttribute("href", new RegExp(`/${roomId}/`));
|
await expect(page.locator(".mx_EventTile_sticker > a")).toHaveAttribute("href", new RegExp(`/${roomId}/`));
|
||||||
|
|
||||||
|
@ -80,13 +97,43 @@ async function expectTimelineSticker(page: Page, roomId: string) {
|
||||||
// download URL.
|
// download URL.
|
||||||
await expect(page.locator(`img[alt="${STICKER_NAME}"]`)).toHaveAttribute(
|
await expect(page.locator(`img[alt="${STICKER_NAME}"]`)).toHaveAttribute(
|
||||||
"src",
|
"src",
|
||||||
new RegExp("/download/localhost/somewhere"),
|
new RegExp(`/localhost/${contentId}`),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function expectFileTile(page: Page, roomId: string, contentUri: string) {
|
||||||
|
await expect(page.locator(".mx_MFileBody_info_filename")).toContainText(STICKER_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setWidgetAccountData(
|
||||||
|
app: ElementAppPage,
|
||||||
|
user: Credentials,
|
||||||
|
stickerPickerUrl: string,
|
||||||
|
provideCreatorUserId: boolean = true,
|
||||||
|
) {
|
||||||
|
await app.client.setAccountData("m.widgets", {
|
||||||
|
[STICKER_PICKER_WIDGET_ID]: {
|
||||||
|
content: {
|
||||||
|
type: "m.stickerpicker",
|
||||||
|
name: STICKER_PICKER_WIDGET_NAME,
|
||||||
|
url: stickerPickerUrl,
|
||||||
|
creatorUserId: provideCreatorUserId ? user.userId : undefined,
|
||||||
|
},
|
||||||
|
sender: user.userId,
|
||||||
|
state_key: STICKER_PICKER_WIDGET_ID,
|
||||||
|
type: "m.widget",
|
||||||
|
id: STICKER_PICKER_WIDGET_ID,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
test.describe("Stickers", () => {
|
test.describe("Stickers", () => {
|
||||||
test.use({
|
test.use({
|
||||||
displayName: "Sally",
|
displayName: "Sally",
|
||||||
|
room: async ({ app }, use) => {
|
||||||
|
const roomId = await app.client.createRoom({ name: ROOM_NAME_1 });
|
||||||
|
await use({ roomId });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// We spin up a web server for the sticker picker so that we're not testing to see if
|
// We spin up a web server for the sticker picker so that we're not testing to see if
|
||||||
|
@ -96,34 +143,19 @@ test.describe("Stickers", () => {
|
||||||
//
|
//
|
||||||
// See sendStickerFromPicker() for more detail on iframe comms.
|
// See sendStickerFromPicker() for more detail on iframe comms.
|
||||||
let stickerPickerUrl: string;
|
let stickerPickerUrl: string;
|
||||||
test.beforeEach(async ({ webserver }) => {
|
|
||||||
stickerPickerUrl = webserver.start(WIDGET_HTML);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should send a sticker to multiple rooms", async ({ page, app, user }) => {
|
test("should send a sticker to multiple rooms", async ({ webserver, page, app, user, room }) => {
|
||||||
const roomId1 = await app.client.createRoom({ name: ROOM_NAME_1 });
|
|
||||||
const roomId2 = await app.client.createRoom({ name: ROOM_NAME_2 });
|
const roomId2 = await app.client.createRoom({ name: ROOM_NAME_2 });
|
||||||
|
const { content_uri: contentUri } = await app.client.uploadContent(STICKER_IMAGE, { type: "image/png" });
|
||||||
await app.client.setAccountData("m.widgets", {
|
const widgetHtml = getWidgetHtml(contentUri, "image/png");
|
||||||
[STICKER_PICKER_WIDGET_ID]: {
|
stickerPickerUrl = webserver.start(widgetHtml);
|
||||||
content: {
|
setWidgetAccountData(app, user, stickerPickerUrl);
|
||||||
type: "m.stickerpicker",
|
|
||||||
name: STICKER_PICKER_WIDGET_NAME,
|
|
||||||
url: stickerPickerUrl,
|
|
||||||
creatorUserId: user.userId,
|
|
||||||
},
|
|
||||||
sender: user.userId,
|
|
||||||
state_key: STICKER_PICKER_WIDGET_ID,
|
|
||||||
type: "m.widget",
|
|
||||||
id: STICKER_PICKER_WIDGET_ID,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await app.viewRoomByName(ROOM_NAME_1);
|
await app.viewRoomByName(ROOM_NAME_1);
|
||||||
await expect(page).toHaveURL(`/#/room/${roomId1}`);
|
await expect(page).toHaveURL(`/#/room/${room.roomId}`);
|
||||||
await openStickerPicker(app);
|
await openStickerPicker(app);
|
||||||
await sendStickerFromPicker(page);
|
await sendStickerFromPicker(page);
|
||||||
await expectTimelineSticker(page, roomId1);
|
await expectTimelineSticker(page, room.roomId, contentUri);
|
||||||
|
|
||||||
// Ensure that when we switch to a different room that the sticker
|
// Ensure that when we switch to a different room that the sticker
|
||||||
// goes to the right place
|
// goes to the right place
|
||||||
|
@ -131,31 +163,40 @@ test.describe("Stickers", () => {
|
||||||
await expect(page).toHaveURL(`/#/room/${roomId2}`);
|
await expect(page).toHaveURL(`/#/room/${roomId2}`);
|
||||||
await openStickerPicker(app);
|
await openStickerPicker(app);
|
||||||
await sendStickerFromPicker(page);
|
await sendStickerFromPicker(page);
|
||||||
await expectTimelineSticker(page, roomId2);
|
await expectTimelineSticker(page, roomId2, contentUri);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should handle a sticker picker widget missing creatorUserId", async ({ page, app, user }) => {
|
test("should handle a sticker picker widget missing creatorUserId", async ({
|
||||||
const roomId1 = await app.client.createRoom({ name: ROOM_NAME_1 });
|
webserver,
|
||||||
|
page,
|
||||||
await app.client.setAccountData("m.widgets", {
|
app,
|
||||||
[STICKER_PICKER_WIDGET_ID]: {
|
user,
|
||||||
content: {
|
room,
|
||||||
type: "m.stickerpicker",
|
}) => {
|
||||||
name: STICKER_PICKER_WIDGET_NAME,
|
const { content_uri: contentUri } = await app.client.uploadContent(STICKER_IMAGE, { type: "image/png" });
|
||||||
url: stickerPickerUrl,
|
const widgetHtml = getWidgetHtml(contentUri, "image/png");
|
||||||
// No creatorUserId
|
stickerPickerUrl = webserver.start(widgetHtml);
|
||||||
},
|
setWidgetAccountData(app, user, stickerPickerUrl, false);
|
||||||
sender: user.userId,
|
|
||||||
state_key: STICKER_PICKER_WIDGET_ID,
|
|
||||||
type: "m.widget",
|
|
||||||
id: STICKER_PICKER_WIDGET_ID,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await app.viewRoomByName(ROOM_NAME_1);
|
await app.viewRoomByName(ROOM_NAME_1);
|
||||||
await expect(page).toHaveURL(`/#/room/${roomId1}`);
|
await expect(page).toHaveURL(`/#/room/${room.roomId}`);
|
||||||
await openStickerPicker(app);
|
await openStickerPicker(app);
|
||||||
await sendStickerFromPicker(page);
|
await sendStickerFromPicker(page);
|
||||||
await expectTimelineSticker(page, roomId1);
|
await expectTimelineSticker(page, room.roomId, contentUri);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should render invalid mimetype as a file", async ({ webserver, page, app, user, room }) => {
|
||||||
|
const { content_uri: contentUri } = await app.client.uploadContent(STICKER_IMAGE, {
|
||||||
|
type: "application/octet-stream",
|
||||||
|
});
|
||||||
|
const widgetHtml = getWidgetHtml(contentUri, "application/octet-stream");
|
||||||
|
stickerPickerUrl = webserver.start(widgetHtml);
|
||||||
|
setWidgetAccountData(app, user, stickerPickerUrl);
|
||||||
|
|
||||||
|
await app.viewRoomByName(ROOM_NAME_1);
|
||||||
|
await expect(page).toHaveURL(`/#/room/${room.roomId}`);
|
||||||
|
await openStickerPicker(app);
|
||||||
|
await sendStickerFromPicker(page);
|
||||||
|
await expectFileTile(page, room.roomId, contentUri);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -98,25 +98,29 @@ export default class DateSeparator extends React.Component<IProps, IState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
private getLabel(): string {
|
private getLabel(): string {
|
||||||
const date = new Date(this.props.ts);
|
try {
|
||||||
const disableRelativeTimestamps = !SettingsStore.getValue(UIFeature.TimelineEnableRelativeDates);
|
const date = new Date(this.props.ts);
|
||||||
|
const disableRelativeTimestamps = !SettingsStore.getValue(UIFeature.TimelineEnableRelativeDates);
|
||||||
|
|
||||||
// During the time the archive is being viewed, a specific day might not make sense, so we return the full date
|
// During the time the archive is being viewed, a specific day might not make sense, so we return the full date
|
||||||
if (this.props.forExport || disableRelativeTimestamps) return formatFullDateNoTime(date);
|
if (this.props.forExport || disableRelativeTimestamps) return formatFullDateNoTime(date);
|
||||||
|
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const yesterday = new Date();
|
const yesterday = new Date();
|
||||||
const days = getDaysArray("long");
|
const days = getDaysArray("long");
|
||||||
yesterday.setDate(today.getDate() - 1);
|
yesterday.setDate(today.getDate() - 1);
|
||||||
|
|
||||||
if (date.toDateString() === today.toDateString()) {
|
if (date.toDateString() === today.toDateString()) {
|
||||||
return this.relativeTimeFormat.format(0, "day"); // Today
|
return this.relativeTimeFormat.format(0, "day"); // Today
|
||||||
} else if (date.toDateString() === yesterday.toDateString()) {
|
} else if (date.toDateString() === yesterday.toDateString()) {
|
||||||
return this.relativeTimeFormat.format(-1, "day"); // Yesterday
|
return this.relativeTimeFormat.format(-1, "day"); // Yesterday
|
||||||
} else if (today.getTime() - date.getTime() < 6 * 24 * 60 * 60 * 1000) {
|
} else if (today.getTime() - date.getTime() < 6 * 24 * 60 * 60 * 1000) {
|
||||||
return days[date.getDay()]; // Sunday-Saturday
|
return days[date.getDay()]; // Sunday-Saturday
|
||||||
} else {
|
} else {
|
||||||
return formatFullDateNoTime(date);
|
return formatFullDateNoTime(date);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return _t("common|message_timestamp_invalid");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -6,7 +6,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||||
Please see LICENSE files in the repository root for full details.
|
Please see LICENSE files in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import mime from "mime";
|
||||||
import React, { createRef } from "react";
|
import React, { createRef } from "react";
|
||||||
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
import {
|
import {
|
||||||
EventType,
|
EventType,
|
||||||
MsgType,
|
MsgType,
|
||||||
|
@ -15,6 +17,7 @@ import {
|
||||||
M_LOCATION,
|
M_LOCATION,
|
||||||
M_POLL_END,
|
M_POLL_END,
|
||||||
M_POLL_START,
|
M_POLL_START,
|
||||||
|
IContent,
|
||||||
} from "matrix-js-sdk/src/matrix";
|
} from "matrix-js-sdk/src/matrix";
|
||||||
|
|
||||||
import SettingsStore from "../../../settings/SettingsStore";
|
import SettingsStore from "../../../settings/SettingsStore";
|
||||||
|
@ -144,6 +147,103 @@ export default class MessageEvent extends React.Component<IProps> implements IMe
|
||||||
this.forceUpdate();
|
this.forceUpdate();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates that the filename extension and advertised mimetype
|
||||||
|
* of the supplied image/file message content match and are actuallly video/image content.
|
||||||
|
* For image/video messages with a thumbnail it also validates the mimetype is an image.
|
||||||
|
* @param content The mxEvent content of the message
|
||||||
|
* @returns A boolean indicating whether the validation passed
|
||||||
|
*/
|
||||||
|
private validateImageOrVideoMimetype = (content: IContent): boolean => {
|
||||||
|
// As per the spec if filename is not present the body represents the filename
|
||||||
|
const filename = content.filename ?? content.body;
|
||||||
|
if (!filename) {
|
||||||
|
logger.log("Failed to validate image/video content, filename null");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Check mimetype of the thumbnail
|
||||||
|
if (!this.validateThumbnailMimetype(content)) {
|
||||||
|
logger.log("Failed to validate file/image thumbnail");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if there is no mimetype from the extesion or the mimetype is not image/video validation fails
|
||||||
|
const typeFromExtension = mime.getType(filename) ?? undefined;
|
||||||
|
const extensionMajorMimetype = this.parseMajorMimetype(typeFromExtension);
|
||||||
|
if (!typeFromExtension || !this.validateAllowedMimetype(typeFromExtension, ["image", "video"])) {
|
||||||
|
logger.log("Failed to validate image/video content, invalid or missing extension");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if the content mimetype is set check it is an image/video and that it matches the extesion mimetype otherwise validation fails
|
||||||
|
const contentMimetype = content.info?.mimetype;
|
||||||
|
if (contentMimetype) {
|
||||||
|
const contentMajorMimetype = this.parseMajorMimetype(contentMimetype);
|
||||||
|
if (
|
||||||
|
!this.validateAllowedMimetype(contentMimetype, ["image", "video"]) ||
|
||||||
|
extensionMajorMimetype !== contentMajorMimetype
|
||||||
|
) {
|
||||||
|
logger.log("Failed to validate image/video content, invalid or missing mimetype");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates that the advertised mimetype of the sticker content
|
||||||
|
* is an image.
|
||||||
|
* For stickers with a thumbnail it also validates the mimetype is an image.
|
||||||
|
* @param content The mxEvent content of the message
|
||||||
|
* @returns A boolean indicating whether the validation passed
|
||||||
|
*/
|
||||||
|
private validateStickerMimetype = (content: IContent): boolean => {
|
||||||
|
// Validate mimetype of the thumbnail
|
||||||
|
const thumbnailResult = this.validateThumbnailMimetype(content);
|
||||||
|
if (!thumbnailResult) {
|
||||||
|
logger.log("Failed to validate sticker thumbnail");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Validate mimetype of the content info is valid if it is set
|
||||||
|
const contentMimetype = content.info?.mimetype;
|
||||||
|
if (contentMimetype && !this.validateAllowedMimetype(contentMimetype, ["image"])) {
|
||||||
|
logger.log("Failed to validate image/video content, invalid or missing mimetype/extensions");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For image/video messages or stickers that have a thumnail mimetype specified,
|
||||||
|
* validates that the major mimetime is image.
|
||||||
|
* @param content The mxEvent content of the message
|
||||||
|
* @returns A boolean indicating whether the validation passed
|
||||||
|
*/
|
||||||
|
private validateThumbnailMimetype = (content: IContent): boolean => {
|
||||||
|
const thumbnailMimetype = content.info?.thumbnail_info?.mimetype;
|
||||||
|
return !thumbnailMimetype || this.validateAllowedMimetype(thumbnailMimetype, ["image"]);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates that the major part of a mimetime from an allowed list.
|
||||||
|
* @param mimetype The mimetype to validate
|
||||||
|
* @param allowedMajorMimeTypes The list of allowed major mimetimes
|
||||||
|
* @returns A boolean indicating whether the validation passed
|
||||||
|
*/
|
||||||
|
private validateAllowedMimetype = (mimetype: string, allowedMajorMimeTypes: string[]): boolean => {
|
||||||
|
const majorMimetype = this.parseMajorMimetype(mimetype);
|
||||||
|
return !!majorMimetype && allowedMajorMimeTypes.includes(majorMimetype);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses and returns the the major part of a mimetype(before the "/").
|
||||||
|
* @param mimetype As optional mimetype string to parse
|
||||||
|
* @returns The major part of the mimetype string or undefined
|
||||||
|
*/
|
||||||
|
private parseMajorMimetype(mimetype?: string): string | undefined {
|
||||||
|
return mimetype?.split("/")[0];
|
||||||
|
}
|
||||||
|
|
||||||
public render(): React.ReactNode {
|
public render(): React.ReactNode {
|
||||||
const content = this.props.mxEvent.getContent();
|
const content = this.props.mxEvent.getContent();
|
||||||
const type = this.props.mxEvent.getType();
|
const type = this.props.mxEvent.getType();
|
||||||
|
@ -165,6 +265,13 @@ export default class MessageEvent extends React.Component<IProps> implements IMe
|
||||||
BodyType = UnknownBody;
|
BodyType = UnknownBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
((BodyType === MImageBody || BodyType == MVideoBody) && !this.validateImageOrVideoMimetype(content)) ||
|
||||||
|
(BodyType === MStickerBody && !this.validateStickerMimetype(content))
|
||||||
|
) {
|
||||||
|
BodyType = this.bodyTypes.get(MsgType.File)!;
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: move to eventTypes when location sharing spec stabilises
|
// TODO: move to eventTypes when location sharing spec stabilises
|
||||||
if (M_LOCATION.matches(type) || (type === EventType.RoomMessage && msgtype === MsgType.Location)) {
|
if (M_LOCATION.matches(type) || (type === EventType.RoomMessage && msgtype === MsgType.Location)) {
|
||||||
BodyType = MLocationBody;
|
BodyType = MLocationBody;
|
||||||
|
|
|
@ -504,6 +504,7 @@
|
||||||
"matrix": "Matrix",
|
"matrix": "Matrix",
|
||||||
"message": "Message",
|
"message": "Message",
|
||||||
"message_layout": "Message layout",
|
"message_layout": "Message layout",
|
||||||
|
"message_timestamp_invalid": "Invalid timestamp",
|
||||||
"microphone": "Microphone",
|
"microphone": "Microphone",
|
||||||
"model": "Model",
|
"model": "Model",
|
||||||
"modern": "Modern",
|
"modern": "Modern",
|
||||||
|
|
|
@ -91,6 +91,12 @@ describe("DateSeparator", () => {
|
||||||
expect(getComponent({ ts, forExport: false }).container.textContent).toEqual(result);
|
expect(getComponent({ ts, forExport: false }).container.textContent).toEqual(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders invalid date separator correctly", () => {
|
||||||
|
const ts = new Date(-8640000000000004).getTime();
|
||||||
|
const { asFragment } = getComponent({ ts });
|
||||||
|
expect(asFragment()).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
describe("when forExport is true", () => {
|
describe("when forExport is true", () => {
|
||||||
it.each(testCases)("formats date in full when current time is %s", (_d, ts) => {
|
it.each(testCases)("formats date in full when current time is %s", (_d, ts) => {
|
||||||
expect(getComponent({ ts, forExport: true }).container.textContent).toEqual(
|
expect(getComponent({ ts, forExport: true }).container.textContent).toEqual(
|
||||||
|
|
|
@ -33,6 +33,16 @@ jest.mock("../../../../../src/components/views/messages/MImageBody", () => ({
|
||||||
default: () => <div data-testid="image-body" />,
|
default: () => <div data-testid="image-body" />,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../../../../src/components/views/messages/MVideoBody", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: () => <div data-testid="video-body" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../../../../src/components/views/messages/MFileBody", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: () => <div data-testid="file-body" />,
|
||||||
|
}));
|
||||||
|
|
||||||
jest.mock("../../../../../src/components/views/messages/MImageReplyBody", () => ({
|
jest.mock("../../../../../src/components/views/messages/MImageReplyBody", () => ({
|
||||||
__esModule: true,
|
__esModule: true,
|
||||||
default: () => <div data-testid="image-reply-body" />,
|
default: () => <div data-testid="image-reply-body" />,
|
||||||
|
@ -95,8 +105,8 @@ describe("MessageEvent", () => {
|
||||||
describe("when an image with a caption is sent", () => {
|
describe("when an image with a caption is sent", () => {
|
||||||
let result: RenderResult;
|
let result: RenderResult;
|
||||||
|
|
||||||
beforeEach(() => {
|
function createEvent(mimetype: string, filename: string, msgtype: string) {
|
||||||
event = mkEvent({
|
return mkEvent({
|
||||||
event: true,
|
event: true,
|
||||||
type: EventType.RoomMessage,
|
type: EventType.RoomMessage,
|
||||||
user: client.getUserId()!,
|
user: client.getUserId()!,
|
||||||
|
@ -105,19 +115,19 @@ describe("MessageEvent", () => {
|
||||||
body: "caption for a test image",
|
body: "caption for a test image",
|
||||||
format: "org.matrix.custom.html",
|
format: "org.matrix.custom.html",
|
||||||
formatted_body: "<strong>caption for a test image</strong>",
|
formatted_body: "<strong>caption for a test image</strong>",
|
||||||
msgtype: MsgType.Image,
|
msgtype: msgtype,
|
||||||
filename: "image.webp",
|
filename: filename,
|
||||||
info: {
|
info: {
|
||||||
w: 40,
|
w: 40,
|
||||||
h: 50,
|
h: 50,
|
||||||
|
mimetype: mimetype,
|
||||||
},
|
},
|
||||||
url: "mxc://server/image",
|
url: "mxc://server/image",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
result = renderMessageEvent();
|
}
|
||||||
});
|
|
||||||
|
|
||||||
it("should render a TextualBody and an ImageBody", () => {
|
function mockMedia() {
|
||||||
fetchMock.getOnce(
|
fetchMock.getOnce(
|
||||||
"https://server/_matrix/media/v3/download/server/image",
|
"https://server/_matrix/media/v3/download/server/image",
|
||||||
{
|
{
|
||||||
|
@ -125,8 +135,38 @@ describe("MessageEvent", () => {
|
||||||
},
|
},
|
||||||
{ sendAsJson: false },
|
{ sendAsJson: false },
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("should render a TextualBody and an ImageBody", () => {
|
||||||
|
event = createEvent("image/webp", "image.webp", MsgType.Image);
|
||||||
|
result = renderMessageEvent();
|
||||||
|
mockMedia();
|
||||||
result.getByTestId("image-body");
|
result.getByTestId("image-body");
|
||||||
result.getByTestId("textual-body");
|
result.getByTestId("textual-body");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should render a TextualBody and a FileBody for mismatched extension", () => {
|
||||||
|
event = createEvent("image/webp", "image.exe", MsgType.Image);
|
||||||
|
result = renderMessageEvent();
|
||||||
|
mockMedia();
|
||||||
|
result.getByTestId("file-body");
|
||||||
|
result.getByTestId("textual-body");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render a TextualBody and an VideoBody", () => {
|
||||||
|
event = createEvent("video/mp4", "video.mp4", MsgType.Video);
|
||||||
|
result = renderMessageEvent();
|
||||||
|
mockMedia();
|
||||||
|
result.getByTestId("video-body");
|
||||||
|
result.getByTestId("textual-body");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render a TextualBody and a FileBody for non-video mimetype", () => {
|
||||||
|
event = createEvent("application/octet-stream", "video.mp4", MsgType.Video);
|
||||||
|
result = renderMessageEvent();
|
||||||
|
mockMedia();
|
||||||
|
result.getByTestId("file-body");
|
||||||
|
result.getByTestId("textual-body");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,5 +1,32 @@
|
||||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`DateSeparator renders invalid date separator correctly 1`] = `
|
||||||
|
<DocumentFragment>
|
||||||
|
<div
|
||||||
|
aria-label="Invalid timestamp"
|
||||||
|
class="mx_TimelineSeparator"
|
||||||
|
role="separator"
|
||||||
|
>
|
||||||
|
<hr
|
||||||
|
role="none"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="mx_DateSeparator_dateContent"
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
aria-hidden="true"
|
||||||
|
class="mx_DateSeparator_dateHeading"
|
||||||
|
>
|
||||||
|
Invalid timestamp
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<hr
|
||||||
|
role="none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DocumentFragment>
|
||||||
|
`;
|
||||||
|
|
||||||
exports[`DateSeparator renders the date separator correctly 1`] = `
|
exports[`DateSeparator renders the date separator correctly 1`] = `
|
||||||
<DocumentFragment>
|
<DocumentFragment>
|
||||||
<div
|
<div
|
||||||
|
|
|
@ -8323,6 +8323,11 @@ mime@1.6.0:
|
||||||
resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1"
|
resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1"
|
||||||
integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==
|
integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==
|
||||||
|
|
||||||
|
mime@^4.0.4:
|
||||||
|
version "4.0.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/mime/-/mime-4.0.4.tgz#9f851b0fc3c289d063b20a7a8055b3014b25664b"
|
||||||
|
integrity sha512-v8yqInVjhXyqP6+Kw4fV3ZzeMRqEW6FotRsKXjRS5VMTNIuXsdRoAvklpoRgSqXm6o9VNH4/C0mgedko9DdLsQ==
|
||||||
|
|
||||||
mimic-fn@^2.1.0:
|
mimic-fn@^2.1.0:
|
||||||
version "2.1.0"
|
version "2.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b"
|
resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b"
|
||||||
|
|
Loading…
Reference in a new issue