diff --git a/src/FromWidgetPostMessageApi.js b/src/FromWidgetPostMessageApi.js index ea76c85643..3c4494c09d 100644 --- a/src/FromWidgetPostMessageApi.js +++ b/src/FromWidgetPostMessageApi.js @@ -24,8 +24,7 @@ import {MatrixClientPeg} from "./MatrixClientPeg"; import RoomViewStore from "./stores/RoomViewStore"; import {IntegrationManagers} from "./integrations/IntegrationManagers"; import SettingsStore from "./settings/SettingsStore"; -import {Capability, KnownWidgetActions} from "./widgets/WidgetApi"; -import SdkConfig from "./SdkConfig"; +import {Capability} from "./widgets/WidgetApi"; const WIDGET_API_VERSION = '0.0.2'; // Current API version const SUPPORTED_WIDGET_API_VERSIONS = [ @@ -220,13 +219,6 @@ export default class FromWidgetPostMessageApi { } } else if (action === 'get_openid') { // Handled by caller - } else if (action === KnownWidgetActions.GetRiotWebConfig) { - if (ActiveWidgetStore.widgetHasCapability(widgetId, Capability.GetRiotWebConfig)) { - this.sendResponse(event, { - api: INBOUND_API_NAME, - config: SdkConfig.get(), - }); - } } else { console.warn('Widget postMessage event unhandled'); this.sendError(event, {message: 'The postMessage was unhandled'}); diff --git a/src/SdkConfig.ts b/src/SdkConfig.ts index 34f3402334..400d29a20f 100644 --- a/src/SdkConfig.ts +++ b/src/SdkConfig.ts @@ -30,8 +30,6 @@ export const DEFAULTS: ConfigOptions = { jitsi: { // Default conference domain preferredDomain: "jitsi.riot.im", - // Default Jitsi Meet API location - externalApiUrl: "https://jitsi.riot.im/libs/external_api.min.js", }, }; diff --git a/src/components/views/elements/AppTile.js b/src/components/views/elements/AppTile.js index 0a8bf7443b..ef69acb1fb 100644 --- a/src/components/views/elements/AppTile.js +++ b/src/components/views/elements/AppTile.js @@ -2,6 +2,7 @@ Copyright 2017 Vector Creations Ltd Copyright 2018 New Vector Ltd Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> +Copyright 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. @@ -41,12 +42,30 @@ import PersistedElement from "./PersistedElement"; const ALLOWED_APP_URL_SCHEMES = ['https:', 'http:']; const ENABLE_REACT_PERF = false; +/** + * Does template substitution on a URL (or any string). Variables will be + * passed through encodeURIComponent. + * @param {string} uriTemplate The path with template variables e.g. '/foo/$bar'. + * @param {Object} variables The key/value pairs to replace the template + * variables with. E.g. { '$bar': 'baz' }. + * @return {string} The result of replacing all template variables e.g. '/foo/baz'. + */ +function uriFromTemplate(uriTemplate, variables) { + let out = uriTemplate; + for (const [key, val] of Object.entries(variables)) { + out = out.replace( + '$' + key, encodeURIComponent(val), + ); + } + return out; +} + export default class AppTile extends React.Component { constructor(props) { super(props); // The key used for PersistedElement - this._persistKey = 'widget_' + this.props.id; + this._persistKey = 'widget_' + this.props.app.id; this.state = this._getNewState(props); @@ -78,7 +97,7 @@ export default class AppTile extends React.Component { // This is a function to make the impact of calling SettingsStore slightly less const hasPermissionToLoad = () => { const currentlyAllowedWidgets = SettingsStore.getValue("allowedWidgets", newProps.room.roomId); - return !!currentlyAllowedWidgets[newProps.eventId]; + return !!currentlyAllowedWidgets[newProps.app.eventId]; }; const PersistedElement = sdk.getComponent("elements.PersistedElement"); @@ -86,7 +105,7 @@ export default class AppTile extends React.Component { initialising: true, // True while we are mangling the widget URL // True while the iframe content is loading loading: this.props.waitForIframeLoad && !PersistedElement.isMounted(this._persistKey), - widgetUrl: this._addWurlParams(newProps.url), + widgetUrl: this._addWurlParams(newProps.app.url), // Assume that widget has permission to load if we are the user who // added it to the room, or if explicitly granted by the user hasPermissionToLoad: newProps.userId === newProps.creatorUserId || hasPermissionToLoad(), @@ -103,7 +122,7 @@ export default class AppTile extends React.Component { * @return {Boolean} True if capability supported */ _hasCapability(capability) { - return ActiveWidgetStore.widgetHasCapability(this.props.id, capability); + return ActiveWidgetStore.widgetHasCapability(this.props.app.id, capability); } /** @@ -125,7 +144,7 @@ export default class AppTile extends React.Component { const params = qs.parse(u.query); // Append widget ID to query parameters - params.widgetId = this.props.id; + params.widgetId = this.props.app.id; // Append current / parent URL, minus the hash because that will change when // we view a different room (ie. may change for persistent widgets) params.parentUrl = window.location.href.split('#', 2)[0]; @@ -137,11 +156,11 @@ export default class AppTile extends React.Component { isMixedContent() { const parentContentProtocol = window.location.protocol; - const u = url.parse(this.props.url); + const u = url.parse(this.props.app.url); const childContentProtocol = u.protocol; if (parentContentProtocol === 'https:' && childContentProtocol !== 'https:') { console.warn("Refusing to load mixed-content app:", - parentContentProtocol, childContentProtocol, window.location, this.props.url); + parentContentProtocol, childContentProtocol, window.location, this.props.app.url); return true; } return false; @@ -164,8 +183,8 @@ export default class AppTile extends React.Component { dis.unregister(this.dispatcherRef); // if it's not remaining on screen, get rid of the PersistedElement container - if (!ActiveWidgetStore.getWidgetPersistence(this.props.id)) { - ActiveWidgetStore.destroyPersistentWidget(this.props.id); + if (!ActiveWidgetStore.getWidgetPersistence(this.props.app.id)) { + ActiveWidgetStore.destroyPersistentWidget(this.props.app.id); const PersistedElement = sdk.getComponent("elements.PersistedElement"); PersistedElement.destroyElement(this._persistKey); } @@ -176,11 +195,11 @@ export default class AppTile extends React.Component { * Component initialisation is only complete when this function has resolved */ setScalarToken() { - if (!WidgetUtils.isScalarUrl(this.props.url)) { + if (!WidgetUtils.isScalarUrl(this.props.app.url)) { console.warn('Non-scalar widget, not setting scalar token!', url); this.setState({ error: null, - widgetUrl: this._addWurlParams(this.props.url), + widgetUrl: this._addWurlParams(this.props.app.url), initialising: false, }); return; @@ -191,7 +210,7 @@ export default class AppTile extends React.Component { console.warn("No integration manager - not setting scalar token", url); this.setState({ error: null, - widgetUrl: this._addWurlParams(this.props.url), + widgetUrl: this._addWurlParams(this.props.app.url), initialising: false, }); return; @@ -204,7 +223,7 @@ export default class AppTile extends React.Component { console.warn('Non-scalar manager, not setting scalar token!', url); this.setState({ error: null, - widgetUrl: this._addWurlParams(this.props.url), + widgetUrl: this._addWurlParams(this.props.app.url), initialising: false, }); return; @@ -217,7 +236,7 @@ export default class AppTile extends React.Component { this._scalarClient.getScalarToken().then((token) => { // Append scalar_token as a query param if not already present this._scalarClient.scalarToken = token; - const u = url.parse(this._addWurlParams(this.props.url)); + const u = url.parse(this._addWurlParams(this.props.app.url)); const params = qs.parse(u.query); if (!params.scalar_token) { params.scalar_token = encodeURIComponent(token); @@ -246,7 +265,7 @@ export default class AppTile extends React.Component { } componentWillReceiveProps(nextProps) { - if (nextProps.url !== this.props.url) { + if (nextProps.app.url !== this.props.app.url) { this._getNewState(nextProps); // Fetch IM token for new URL if we're showing and have permission to load if (this.props.show && this.state.hasPermissionToLoad) { @@ -280,7 +299,7 @@ export default class AppTile extends React.Component { } _onEditClick() { - console.log("Edit widget ID ", this.props.id); + console.log("Edit widget ID ", this.props.app.id); if (this.props.onEditClick) { this.props.onEditClick(); } else { @@ -289,13 +308,13 @@ export default class AppTile extends React.Component { IntegrationManagers.sharedInstance().openAll( this.props.room, 'type_' + this.props.type, - this.props.id, + this.props.app.id, ); } else { IntegrationManagers.sharedInstance().getPrimaryManager().open( this.props.room, 'type_' + this.props.type, - this.props.id, + this.props.app.id, ); } } @@ -303,7 +322,7 @@ export default class AppTile extends React.Component { _onSnapshotClick() { console.warn("Requesting widget snapshot"); - ActiveWidgetStore.getWidgetMessaging(this.props.id).getScreenshot() + ActiveWidgetStore.getWidgetMessaging(this.props.app.id).getScreenshot() .catch((err) => { console.error("Failed to get screenshot", err); }) @@ -351,7 +370,7 @@ export default class AppTile extends React.Component { WidgetUtils.setRoomWidget( this.props.room.roomId, - this.props.id, + this.props.app.id, ).catch((e) => { console.error('Failed to delete widget', e); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); @@ -369,7 +388,7 @@ export default class AppTile extends React.Component { } _onRevokeClicked() { - console.info("Revoke widget permissions - %s", this.props.id); + console.info("Revoke widget permissions - %s", this.props.app.id); this._revokeWidgetPermission(); } @@ -380,10 +399,10 @@ export default class AppTile extends React.Component { // Destroy the old widget messaging before starting it back up again. Some widgets // have startup routines that run when they are loaded, so we just need to reinitialize // the messaging for them. - ActiveWidgetStore.delWidgetMessaging(this.props.id); + ActiveWidgetStore.delWidgetMessaging(this.props.app.id); this._setupWidgetMessaging(); - ActiveWidgetStore.setRoomId(this.props.id, this.props.room.roomId); + ActiveWidgetStore.setRoomId(this.props.app.id, this.props.room.roomId); this.setState({loading: false}); } @@ -391,10 +410,10 @@ export default class AppTile extends React.Component { // FIXME: There's probably no reason to do this here: it should probably be done entirely // in ActiveWidgetStore. const widgetMessaging = new WidgetMessaging( - this.props.id, this.props.url, this.props.userWidget, this._appFrame.current.contentWindow); - ActiveWidgetStore.setWidgetMessaging(this.props.id, widgetMessaging); + this.props.app.id, this._getRenderedUrl(), this.props.userWidget, this._appFrame.current.contentWindow); + ActiveWidgetStore.setWidgetMessaging(this.props.app.id, widgetMessaging); widgetMessaging.getCapabilities().then((requestedCapabilities) => { - console.log(`Widget ${this.props.id} requested capabilities: ` + requestedCapabilities); + console.log(`Widget ${this.props.app.id} requested capabilities: ` + requestedCapabilities); requestedCapabilities = requestedCapabilities || []; // Allow whitelisted capabilities @@ -406,7 +425,7 @@ export default class AppTile extends React.Component { }, this.props.whitelistCapabilities); if (requestedWhitelistCapabilies.length > 0 ) { - console.warn(`Widget ${this.props.id} allowing requested, whitelisted properties: ` + + console.warn(`Widget ${this.props.app.id} allowing requested, whitelisted properties: ` + requestedWhitelistCapabilies, ); } @@ -414,7 +433,7 @@ export default class AppTile extends React.Component { // TODO -- Add UI to warn about and optionally allow requested capabilities - ActiveWidgetStore.setWidgetCapabilities(this.props.id, requestedWhitelistCapabilies); + ActiveWidgetStore.setWidgetCapabilities(this.props.app.id, requestedWhitelistCapabilies); if (this.props.onCapabilityRequest) { this.props.onCapabilityRequest(requestedCapabilities); @@ -422,16 +441,16 @@ export default class AppTile extends React.Component { // We only tell Jitsi widgets that we're ready because they're realistically the only ones // using this custom extension to the widget API. - if (this.props.type === 'jitsi') { + if (this.props.app.type === 'jitsi') { widgetMessaging.flagReadyToContinue(); } }).catch((err) => { - console.log(`Failed to get capabilities for widget type ${this.props.type}`, this.props.id, err); + console.log(`Failed to get capabilities for widget type ${this.props.app.type}`, this.props.app.id, err); }); } _onAction(payload) { - if (payload.widgetId === this.props.id) { + if (payload.widgetId === this.props.app.id) { switch (payload.action) { case 'm.sticker': if (this._hasCapability('m.sticker')) { @@ -460,9 +479,9 @@ export default class AppTile extends React.Component { _grantWidgetPermission() { const roomId = this.props.room.roomId; - console.info("Granting permission for widget to load: " + this.props.eventId); + console.info("Granting permission for widget to load: " + this.props.app.eventId); const current = SettingsStore.getValue("allowedWidgets", roomId); - current[this.props.eventId] = true; + current[this.props.app.eventId] = true; SettingsStore.setValue("allowedWidgets", roomId, SettingLevel.ROOM_ACCOUNT, current).then(() => { this.setState({hasPermissionToLoad: true}); @@ -476,14 +495,14 @@ export default class AppTile extends React.Component { _revokeWidgetPermission() { const roomId = this.props.room.roomId; - console.info("Revoking permission for widget to load: " + this.props.eventId); + console.info("Revoking permission for widget to load: " + this.props.app.eventId); const current = SettingsStore.getValue("allowedWidgets", roomId); - current[this.props.eventId] = false; + current[this.props.app.eventId] = false; SettingsStore.setValue("allowedWidgets", roomId, SettingLevel.ROOM_ACCOUNT, current).then(() => { this.setState({hasPermissionToLoad: false}); // Force the widget to be non-persistent (able to be deleted/forgotten) - ActiveWidgetStore.destroyPersistentWidget(this.props.id); + ActiveWidgetStore.destroyPersistentWidget(this.props.app.id); const PersistedElement = sdk.getComponent("elements.PersistedElement"); PersistedElement.destroyElement(this._persistKey); }).catch(err => { @@ -494,8 +513,8 @@ export default class AppTile extends React.Component { formatAppTileName() { let appTileName = "No name"; - if (this.props.name && this.props.name.trim()) { - appTileName = this.props.name.trim(); + if (this.props.app.name && this.props.app.name.trim()) { + appTileName = this.props.app.name.trim(); } return appTileName; } @@ -519,20 +538,70 @@ export default class AppTile extends React.Component { } } - _getSafeUrl() { - const parsedWidgetUrl = url.parse(this.state.widgetUrl, true); + /** + * Replace the widget template variables in a url with their values + * + * @param {string} u The URL with template variables + * + * @returns {string} url with temlate variables replaced + */ + _templatedUrl(u) { + const myUserId = MatrixClientPeg.get().credentials.userId; + const myUser = MatrixClientPeg.get().getUser(myUserId); + const vars = Object.assign({ + domain: "jitsi.riot.im", // v1 widgets have this hardcoded + }, this.props.app.data, { + 'matrix_user_id': myUserId, + 'matrix_room_id': this.props.room.roomId, + 'matrix_display_name': myUser ? myUser.displayName : myUserId, + 'matrix_avatar_url': myUser ? MatrixClientPeg.get().mxcUrlToHttp(myUser.avatarUrl) : '', + + // TODO: Namespace themes through some standard + 'theme': SettingsStore.getValue("theme"), + }); + + if (vars.conferenceId === undefined) { + // we'll need to parse the conference ID out of the URL for v1 Jitsi widgets + const parsedUrl = new URL(this.props.app.url); + vars.conferenceId = parsedUrl.searchParams.get("confId"); + } + + return uriFromTemplate(u, vars); + } + + /** + * Get the URL used in the iframe + * In cases where we supply our own UI for a widget, this is an internal + * URL different to the one used if the widget is popped out to a separate + * tab / browser + * + * @returns {string} url + */ + _getRenderedUrl() { + let url; + + if (this.props.app.type === 'jitsi') { + console.log("Replacing Jitsi widget URL with local wrapper"); + url = WidgetUtils.getLocalJitsiWrapperUrl({forLocalRender: true}); + url = this._addWurlParams(url); + } else { + url = this._getSafeUrl(this.state.widgetUrl); + } + return this._templatedUrl(url); + } + + _getPopoutUrl() { + return this._templatedUrl(this._getSafeUrl(this.props.app.url)); + } + + _getSafeUrl(u) { + const parsedWidgetUrl = url.parse(u, true); if (ENABLE_REACT_PERF) { parsedWidgetUrl.search = null; parsedWidgetUrl.query.react_perf = true; } let safeWidgetUrl = ''; - if (ALLOWED_APP_URL_SCHEMES.includes(parsedWidgetUrl.protocol) || ( - // Check if the widget URL is a Jitsi widget in Electron - parsedWidgetUrl.protocol === 'vector:' - && parsedWidgetUrl.host === 'vector' - && parsedWidgetUrl.pathname === '/webapp/jitsi.html' - && this.props.type === 'jitsi' - )) { + if (ALLOWED_APP_URL_SCHEMES.includes(parsedWidgetUrl.protocol)) { safeWidgetUrl = url.format(parsedWidgetUrl); } return safeWidgetUrl; @@ -562,9 +631,9 @@ export default class AppTile extends React.Component { _onPopoutWidgetClick() { // Using Object.assign workaround as the following opens in a new window instead of a new tab. - // window.open(this._getSafeUrl(), '_blank', 'noopener=yes'); + // window.open(this._getPopoutUrl(), '_blank', 'noopener=yes'); Object.assign(document.createElement('a'), - { target: '_blank', href: this._getSafeUrl(), rel: 'noreferrer noopener'}).click(); + { target: '_blank', href: this._getPopoutUrl(), rel: 'noreferrer noopener'}).click(); } _onReloadWidgetClick() { @@ -641,7 +710,7 @@ export default class AppTile extends React.Component {