diff --git a/package.json b/package.json index f8b4287197b..7272147a986 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "counterpart": "^0.18.6", "diff-dom": "^4.2.2", "diff-match-patch": "^1.0.5", + "embed-video": "^2.0.4", "emojibase-data": "^5.1.1", "emojibase-regex": "^4.1.1", "escape-html": "^1.0.3", @@ -77,6 +78,7 @@ "highlight.js": "^10.5.0", "html-entities": "^1.4.0", "is-ip": "^3.1.0", + "jquery": "^3.6.0", "katex": "^0.12.0", "linkifyjs": "^2.1.9", "lodash": "^4.17.20", diff --git a/res/css/_components.scss b/res/css/_components.scss index d894688cacf..6a7f70015ea 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -111,8 +111,8 @@ @import "./views/elements/_AddressSelector.scss"; @import "./views/elements/_AddressTile.scss"; @import "./views/elements/_DesktopBuildsNotice.scss"; -@import "./views/elements/_DirectorySearchBox.scss"; @import "./views/elements/_DesktopCapturerSourcePicker.scss"; +@import "./views/elements/_DirectorySearchBox.scss"; @import "./views/elements/_Dropdown.scss"; @import "./views/elements/_EditableItemList.scss"; @import "./views/elements/_ErrorBoundary.scss"; @@ -159,6 +159,7 @@ @import "./views/messages/_MStickerBody.scss"; @import "./views/messages/_MTextBody.scss"; @import "./views/messages/_MVideoBody.scss"; +@import "./views/messages/_MWidgetBody.scss"; @import "./views/messages/_MessageActionBar.scss"; @import "./views/messages/_MessageTimestamp.scss"; @import "./views/messages/_MjolnirBody.scss"; @@ -217,7 +218,6 @@ @import "./views/settings/_DevicesPanel.scss"; @import "./views/settings/_E2eAdvancedPanel.scss"; @import "./views/settings/_EmailAddresses.scss"; -@import "./views/settings/_SpellCheckLanguages.scss"; @import "./views/settings/_IntegrationManager.scss"; @import "./views/settings/_Notifications.scss"; @import "./views/settings/_PhoneNumbers.scss"; @@ -225,6 +225,7 @@ @import "./views/settings/_SecureBackupPanel.scss"; @import "./views/settings/_SetIdServer.scss"; @import "./views/settings/_SetIntegrationManager.scss"; +@import "./views/settings/_SpellCheckLanguages.scss"; @import "./views/settings/_UpdateCheckButton.scss"; @import "./views/settings/tabs/_SettingsTab.scss"; @import "./views/settings/tabs/room/_GeneralRoomSettingsTab.scss"; diff --git a/res/css/views/messages/_MWidgetBody.scss b/res/css/views/messages/_MWidgetBody.scss new file mode 100644 index 00000000000..5d779577141 --- /dev/null +++ b/res/css/views/messages/_MWidgetBody.scss @@ -0,0 +1,24 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +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. +*/ + +.mx_MWidgetBody { + height: 250px; + width: 400px; + + .mx_AppTileFullWidth { + height: 100%; + } +} diff --git a/src/components/views/elements/AppTile.js b/src/components/views/elements/AppTile.js index e206fda7972..0f83833823b 100644 --- a/src/components/views/elements/AppTile.js +++ b/src/components/views/elements/AppTile.js @@ -322,8 +322,15 @@ export default class AppTile extends React.Component { // this would only be for content hosted on the same origin as the element client: anything // hosted on the same origin as the client will get the same access as if you clicked // a link to it. - const sandboxFlags = "allow-forms allow-popups allow-popups-to-escape-sandbox "+ + let sandboxFlags = "allow-forms allow-popups allow-popups-to-escape-sandbox "+ "allow-same-origin allow-scripts allow-presentation"; + if (this.props.strictSandbox) { + // XXX: A compromised wrapped HTML widget can access localStorage with allow-same-origin. + // We already filter down the HTML though, so this should be fine. Just scary. + // Note: Removing allow-same-origin means that postMessage requests from the frame get + // an `origin` with the literal string "null" and no way to deduce it. + sandboxFlags = "allow-forms allow-scripts allow-presentation allow-same-origin"; + } // Additional iframe feature pemissions // (see - https://sites.google.com/a/chromium.org/dev/Home/chromium-security/deprecating-permissions-in-cross-origin-iframes and https://wicg.github.io/feature-policy/) @@ -485,6 +492,9 @@ AppTile.propTypes = { userWidget: PropTypes.bool, // sets the pointer-events property on the iframe pointerEvents: PropTypes.string, + // If true, the sandbox prevents most things. Intended for inline widgets + // with untrusted HTML. + strictSandbox: PropTypes.bool, }; AppTile.defaultProps = { diff --git a/src/components/views/messages/MWidgetBody.tsx b/src/components/views/messages/MWidgetBody.tsx new file mode 100644 index 00000000000..46f8cb60110 --- /dev/null +++ b/src/components/views/messages/MWidgetBody.tsx @@ -0,0 +1,100 @@ +/* +Copyright 2019, 2020 The Matrix.org Foundation C.I.C. + +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 WidgetUtils from "../../../utils/WidgetUtils"; +import * as React from 'react'; +import * as sdk from "../../../index"; +import AppTile from "../elements/AppTile"; +import {MatrixClientPeg} from "../../../MatrixClientPeg"; +import {IApp} from "../../../stores/WidgetStore"; + +export default class MWidgetBody extends React.Component { + // static propTypes: { + // mxEvent: PropTypes.object.isRequired, // MatrixEvent + // + // // Passthroughs for TextualBody + // highlights: PropTypes.array, + // highlightLink: PropTypes.string, + // showUrlPreview: PropTypes.bool, + // onHeightChanged: PropTypes.func, + // tileShape: PropTypes.string, + // }; + + renderAsText() { + const TextualBody = sdk.getComponent("messages.TextualBody"); + return ; + } + + render() { + const widgetInfo = this.props.mxEvent.getContent(); + + let widgetUrl = widgetInfo['widget_url']; + let wantedCapabilities = []; + let showTitle = false; + let strictFrame = false; + if (!widgetUrl) { + const widgetHtml = widgetInfo['widget_html']; + if (!widgetHtml) return this.renderAsText(); + + const info = WidgetUtils.wrapWidgetHtml(widgetHtml); + widgetUrl = info.url; + wantedCapabilities = info.wantedCapabilities; + showTitle = true; + strictFrame = true; + } + if (!widgetUrl) return this.renderAsText(); + + const app: IApp = { + ...widgetInfo, + // XXX: Is this a secure enough widget ID? + id: this.props.mxEvent.getRoomId() + "_" + this.props.mxEvent.getId(), + url: widgetUrl, + }; + + return
+ +
; + }; +} diff --git a/src/components/views/messages/MessageEvent.js b/src/components/views/messages/MessageEvent.js index 866e0f521d0..3afab0d57d9 100644 --- a/src/components/views/messages/MessageEvent.js +++ b/src/components/views/messages/MessageEvent.js @@ -71,6 +71,9 @@ export default class MessageEvent extends React.Component { 'm.file': sdk.getComponent('messages.MFileBody'), 'm.audio': sdk.getComponent('messages.MAudioBody'), 'm.video': sdk.getComponent('messages.MVideoBody'), + 'm.widget': SettingsStore.getValue("feature_inline_widgets") + ? sdk.getComponent('messages.MWidgetBody') + : sdk.getComponent('messages.TextualBody'), }; const evTypes = { 'm.sticker': sdk.getComponent('messages.MStickerBody'), diff --git a/src/components/views/rooms/SendMessageComposer.js b/src/components/views/rooms/SendMessageComposer.js index ba3076c07d3..d29c7615b1e 100644 --- a/src/components/views/rooms/SendMessageComposer.js +++ b/src/components/views/rooms/SendMessageComposer.js @@ -40,11 +40,12 @@ import {_t, _td} from '../../../languageHandler'; import ContentMessages from '../../../ContentMessages'; import {Key, isOnlyCtrlOrCmdKeyEvent} from "../../../Keyboard"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; +import WidgetUtils from "../../../utils/WidgetUtils"; +import SettingsStore from "../../../settings/SettingsStore"; import RateLimitedFunc from '../../../ratelimitedfunc'; import {Action} from "../../../dispatcher/actions"; import {containsEmoji} from "../../../effects/utils"; import {CHAT_EFFECTS} from '../../../effects'; -import SettingsStore from "../../../settings/SettingsStore"; import CountlyAnalytics from "../../../CountlyAnalytics"; import {MatrixClientPeg} from "../../../MatrixClientPeg"; import EMOJI_REGEX from 'emojibase-regex'; @@ -335,6 +336,15 @@ export default class SendMessageComposer extends React.Component { let shouldSend = true; + const inlineWidget = startsWith(this.model, "https://") + ? WidgetUtils.tryConvertInputToInlineWidget(textSerialize(this.model)) + : null; + if (inlineWidget && SettingsStore.getValue("feature_inline_widgets")) { + console.log("Message can be an inline widget - sending widget"); + this.context.sendMessage(this.props.room.roomId, inlineWidget); + shouldSend = false; + } + if (!containsEmote(this.model) && this._isSlashCommand()) { const [cmd, commandText] = this._getSlashCommand(); if (cmd) { diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 63449eb99fa..f66d1a5b4d0 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -784,6 +784,7 @@ "Change notification settings": "Change notification settings", "Spaces prototype. Incompatible with Communities, Communities v2 and Custom Tags. Requires compatible homeserver for some features.": "Spaces prototype. Incompatible with Communities, Communities v2 and Custom Tags. Requires compatible homeserver for some features.", "Render LaTeX maths in messages": "Render LaTeX maths in messages", + "Widgets in the timeline": "Widgets in the timeline", "Communities v2 prototypes. Requires compatible homeserver. Highly experimental - use with caution.": "Communities v2 prototypes. Requires compatible homeserver. Highly experimental - use with caution.", "New spinner design": "New spinner design", "Message Pinning": "Message Pinning", diff --git a/src/settings/Settings.ts b/src/settings/Settings.ts index 4f589ba49a1..c21e293e813 100644 --- a/src/settings/Settings.ts +++ b/src/settings/Settings.ts @@ -134,6 +134,12 @@ export const SETTINGS: {[setting: string]: ISetting} = { supportedLevels: LEVELS_FEATURE, default: false, }, + "feature_inline_widgets": { + isFeature: true, + displayName: _td("Widgets in the timeline"), + supportedLevels: LEVELS_FEATURE, + default: false, + }, "feature_communities_v2_prototypes": { isFeature: true, displayName: _td( diff --git a/src/utils/WidgetUtils.ts b/src/utils/WidgetUtils.ts index c67f3bad13f..253f689cb5f 100644 --- a/src/utils/WidgetUtils.ts +++ b/src/utils/WidgetUtils.ts @@ -29,6 +29,8 @@ import {objectClone} from "./objects"; import {_t} from "../languageHandler"; import {Capability, IWidget, IWidgetData, MatrixCapabilities} from "matrix-widget-api"; import {IApp} from "../stores/WidgetStore"; +import embedVideo from "embed-video"; +import $ from "jquery"; // How long we wait for the state event echo to come back from the server // before waitFor[Room/User]Widget rejects its promise @@ -44,6 +46,65 @@ export interface IWidgetEvent { } export default class WidgetUtils { + /** + * Tries to convert a given input string into an inline widget. If the + * input string does not translate to an inline widget, a falsey value + * is returned. Otherwise, the content object for an m.room.message + * representing the inline widget is returned. + * @param {string} inputText The input text to try and parse. + * @returns {*} The m.room.message content object or a falsey value. + */ + static tryConvertInputToInlineWidget(inputText) { + // Dev note: borrowed from Dimension with permission + // https://github.com/turt2live/matrix-dimension/blob/f773b7a3ae4af4a6929d58c0528574f6b240d574/web/app/configs/widget/youtube/youtube.widget.component.ts#L59-L70 + const embedCode = embedVideo(inputText); + if (!embedCode) return null; // can't be converted + // HACK: Grab the video URL from the iframe embed code + const videoUrl = "https:" + $(embedCode).attr("src"); + + return { + msgtype: "m.widget", + body: inputText, + type: "m.custom", + widget_url: videoUrl, + }; + } + + /** + * Sanitizes and wraps a block of HTML for a local widget. + * @param {string} html The widget's HTML. + * @returns {{wantedCapabilities: string[], url: string}} An object containing + * the URL for the wrapped HTML and the capabilities the widget wants. + */ + static wrapWidgetHtml(html) { + const wantedCapabilities = []; + + // TODO: Sanitize HTML + // TODO: Parse capabilities, etc from sanitized HTML + + // HACK: Temporary measure + wantedCapabilities.push("m.send.m.room.message"); + wantedCapabilities.push("m.send.m.room.hidden"); + + const wrapperOpts = { + html: html, + capabilities: wantedCapabilities, + }; + + const base64WrapperOpts = btoa(JSON.stringify(wrapperOpts)) + .replace(/\+/g, '-') + .replace(/\//g, '_'); + + const loc = window.location; + let urlBase = `${loc.protocol}//${loc.host}${loc.pathname}`; + if (!urlBase.endsWith("/")) urlBase = `${urlBase}/`; + + return { + url: `${urlBase}inline_widget_wrapper/index.html#${base64WrapperOpts}`, + wantedCapabilities: wantedCapabilities, + }; + } + /* Returns true if user is able to send state events to modify widgets in this room * (Does not apply to non-room-based / user widgets) * @param roomId -- The ID of the room to check diff --git a/yarn.lock b/yarn.lock index 58686248f72..59cfd70b40b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2928,6 +2928,15 @@ electron-to-chromium@^1.3.634: resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.642.tgz#8b884f50296c2ae2a9997f024d0e3e57facc2b94" integrity sha512-cev+jOrz/Zm1i+Yh334Hed6lQVOkkemk2wRozfMF4MtTR7pxf3r3L5Rbd7uX1zMcEqVJ7alJBnJL7+JffkC6FQ== +embed-video@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/embed-video/-/embed-video-2.0.4.tgz#d21d999c99b4e803d410bbe3133c0151ce2cda64" + integrity sha512-nSc/PAf1+TUeRRCQKvv4+5Qzh6F0s4sQajneLIc+SjN+WDdypYzZtIeaCVIHc/9vXF6tCv8z46UtAWSvh674mg== + dependencies: + fetch-ponyfill "^4.0.0" + lodash.escape "^4.0.1" + promise-polyfill "^6.0.2" + emittery@^0.7.1: version "0.7.2" resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.7.2.tgz#25595908e13af0f5674ab419396e2fb394cdfa82" @@ -3701,6 +3710,13 @@ fbjs@^0.8.4: setimmediate "^1.0.5" ua-parser-js "^0.7.18" +fetch-ponyfill@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/fetch-ponyfill/-/fetch-ponyfill-4.1.0.tgz#ae3ce5f732c645eab87e4ae8793414709b239893" + integrity sha1-rjzl9zLGReq4fkroeTQUcJsjmJM= + dependencies: + node-fetch "~1.7.1" + figures@^3.0.0: version "3.2.0" resolved "https://registry.yarnpkg.com/figures/-/figures-3.2.0.tgz#625c18bd293c604dc4a8ddb2febf0c88341746af" @@ -5233,6 +5249,11 @@ jest@^26.6.3: import-local "^3.0.2" jest-cli "^26.6.3" +jquery@^3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.6.0.tgz#c72a09f15c1bdce142f49dbf1170bdf8adac2470" + integrity sha512-JVzAR/AjBvVt2BmYhxRCSYysDsPcssdmTFnzyLEts9qNwmjmu4JTAMYubEfwVOSwpQ1I1sKKFcxhZCI2buerfw== + "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" @@ -5855,7 +5876,7 @@ node-fetch@2.6.1: resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== -node-fetch@^1.0.1: +node-fetch@^1.0.1, node-fetch@~1.7.1: version "1.7.3" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-1.7.3.tgz#980f6f72d85211a5347c6b2bc18c5b84c3eb47ef" integrity sha512-NhZ4CsKx7cYm2vSrBAr2PvFOe6sWDf0UYLRqA6svUYg7+/TSfVAu49jYC4BvQ4Sms9SZgdqGBgroqfDhJdTyKQ== @@ -6468,6 +6489,11 @@ progress@^2.0.0: resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== +promise-polyfill@^6.0.2: + version "6.1.0" + resolved "https://registry.yarnpkg.com/promise-polyfill/-/promise-polyfill-6.1.0.tgz#dfa96943ea9c121fca4de9b5868cb39d3472e057" + integrity sha1-36lpQ+qcEh/KTem1hoyznTRy4Fc= + promise-polyfill@^8.1.3: version "8.2.0" resolved "https://registry.yarnpkg.com/promise-polyfill/-/promise-polyfill-8.2.0.tgz#367394726da7561457aba2133c9ceefbd6267da0"