Better support for widgets overriding their URLs

Move the URL processing into AppTile so that the widget can have a
URL used for embedding in the page and a separate one for popping
out into a browser.
This commit is contained in:
David Baker 2020-04-01 10:00:33 +01:00
parent 78fd8e4569
commit 538147f7fa
6 changed files with 135 additions and 131 deletions

View file

@ -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,6 +538,60 @@ export default class AppTile extends React.Component {
}
}
/**
* Replace the widget template variables in a url with their values
*
* @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();
}
return this._templatedUrl(url);
}
_getPopoutUrl() {
return this._templatedUrl(this._getSafeUrl());
}
_getSafeUrl() {
const parsedWidgetUrl = url.parse(this.state.widgetUrl, true);
if (ENABLE_REACT_PERF) {
@ -526,13 +599,7 @@ export default class AppTile extends React.Component {
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 +629,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 +708,7 @@ export default class AppTile extends React.Component {
<iframe
allow={iframeFeatures}
ref={this._appFrame}
src={this._getSafeUrl()}
src={this._getRenderedUrl()}
allowFullScreen={true}
sandbox={sandboxFlags}
onLoad={this._onLoaded} />
@ -706,7 +773,7 @@ export default class AppTile extends React.Component {
}
return <React.Fragment>
<div className={appTileClass} id={this.props.id}>
<div className={appTileClass} id={this.props.app.id}>
{ this.props.showMenubar &&
<div ref={this._menu_bar} className={menuBarClasses} onClick={this.onClickMenuBar}>
<span className="mx_AppTileMenuBarTitle" style={{pointerEvents: (this.props.handleMinimisePointerEvents ? 'all' : false)}}>
@ -753,12 +820,8 @@ export default class AppTile extends React.Component {
AppTile.displayName = 'AppTile';
AppTile.propTypes = {
id: PropTypes.string.isRequired,
eventId: PropTypes.string, // required for room widgets
url: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
app: PropTypes.object.isRequired,
room: PropTypes.object.isRequired,
type: PropTypes.string.isRequired,
// Specifying 'fullWidth' as true will render the app tile to fill the width of the app drawer continer.
// This should be set to true when there is only one widget in the app drawer, otherwise it should be false.
fullWidth: PropTypes.bool,
@ -805,7 +868,6 @@ AppTile.propTypes = {
};
AppTile.defaultProps = {
url: "",
waitForIframeLoad: true,
showMenubar: true,
showTitle: true,

View file

@ -1,6 +1,6 @@
/*
Copyright 2018 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
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.
@ -75,11 +75,7 @@ export default createReactClass({
const AppTile = sdk.getComponent('elements.AppTile');
return <AppTile
key={app.id}
id={app.id}
eventId={app.eventId}
url={app.url}
name={app.name}
type={app.type}
app={app}
fullWidth={true}
room={persistentWidgetInRoom}
userId={MatrixClientPeg.get().credentials.userId}

View file

@ -160,11 +160,7 @@ export default createReactClass({
return (<AppTile
key={app.id}
id={app.id}
eventId={app.eventId}
url={app.url}
name={app.name}
type={app.type}
app={app}
fullWidth={arr.length<2 ? true : false}
room={this.props.room}
userId={this.props.userId}

View file

@ -240,6 +240,14 @@ export default class Stickerpicker extends React.Component {
// Set default name
stickerpickerWidget.content.name = stickerpickerWidget.name || _t("Stickerpack");
// FIXME: could this use the same code as other apps?
const stickerApp = {
id: stickerpickerWidget.id,
url: stickerpickerWidget.content.url,
name: stickerpickerWidget.content.name,
type: stickerpickerWidget.content.type,
};
stickersContent = (
<div className='mx_Stickers_content_container'>
<div
@ -253,11 +261,8 @@ export default class Stickerpicker extends React.Component {
>
<PersistedElement persistKey={PERSISTED_ELEMENT_KEY} style={{zIndex: STICKERPICKER_Z_INDEX}}>
<AppTile
id={stickerpickerWidget.id}
url={stickerpickerWidget.content.url}
name={stickerpickerWidget.content.name}
app={stickerApp}
room={this.props.room}
type={stickerpickerWidget.content.type}
fullWidth={true}
userId={MatrixClientPeg.get().credentials.userId}
creatorUserId={stickerpickerWidget.sender || MatrixClientPeg.get().credentials.userId}

View file

@ -22,6 +22,7 @@ import {makeType} from "./TypeUtils";
import SdkConfig from '../SdkConfig';
const LIVELINESS_DISCOVERY_ERRORS = [
AutoDiscovery.ERROR_INVALID,
AutoDiscovery.ERROR_INVALID_HOMESERVER,
AutoDiscovery.ERROR_INVALID_IDENTITY_SERVER,
];
@ -232,8 +233,8 @@ export default class AutoDiscoveryUtils {
const preferredHomeserverUrl = hsResult["base_url"];
let preferredHomeserverName = serverName ? serverName : hsResult["server_name"];
const url = new URL(preferredHomeserverUrl);
if (!preferredHomeserverName) preferredHomeserverName = url.hostname;
const url = preferredHomeserverUrl ? new URL(preferredHomeserverUrl) : null;
if (!preferredHomeserverName && url) preferredHomeserverName = url.hostname;
// It should have been set by now, so check it
if (!preferredHomeserverName) {
@ -241,10 +242,12 @@ export default class AutoDiscoveryUtils {
throw newTranslatableError(_td("Unexpected error resolving homeserver configuration"));
}
const hsNameIsDifferent = url ? (url.hostname !== preferredHomeserverName) : null;
return makeType(ValidatedServerConfig, {
hsUrl: preferredHomeserverUrl,
hsName: preferredHomeserverName,
hsNameIsDifferent: url.hostname !== preferredHomeserverName,
hsNameIsDifferent,
isUrl: preferredIdentityUrl,
isDefault: false,
warning: hsResult.error,

View file

@ -30,26 +30,6 @@ import ActiveWidgetStore from "../stores/ActiveWidgetStore";
import {IntegrationManagers} from "../integrations/IntegrationManagers";
import {Capability} from "../widgets/WidgetApi";
/**
* Encodes a URI according to a set of template variables. Variables will be
* passed through encodeURIComponent.
* @param {string} pathTemplate 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 encodeUri(pathTemplate, variables) {
for (const key in variables) {
if (!variables.hasOwnProperty(key)) {
continue;
}
pathTemplate = pathTemplate.replace(
key, encodeURIComponent(variables[key]),
);
}
return pathTemplate;
}
export default class WidgetUtils {
/* 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)
@ -402,18 +382,6 @@ export default class WidgetUtils {
}
static makeAppConfig(appId, app, senderUserId, roomId, eventId) {
const myUserId = MatrixClientPeg.get().credentials.userId;
const user = MatrixClientPeg.get().getUser(myUserId);
const params = {
'$matrix_user_id': myUserId,
'$matrix_room_id': roomId,
'$matrix_display_name': user ? user.displayName : myUserId,
'$matrix_avatar_url': user ? MatrixClientPeg.get().mxcUrlToHttp(user.avatarUrl) : '',
// TODO: Namespace themes through some standard
'$theme': SettingsStore.getValue("theme"),
};
if (!senderUserId) {
throw new Error("Widgets must be created by someone - provide a senderUserId");
}
@ -423,32 +391,6 @@ export default class WidgetUtils {
app.eventId = eventId;
app.name = app.name || app.type;
if (app.type === 'jitsi') {
console.log("Replacing Jitsi widget URL with local wrapper");
if (!app.data || !app.data.conferenceId) {
// Assumed to be a v1 widget: add a data object for visibility on the wrapper
// TODO: Remove this once mobile supports v2 widgets
console.log("Replacing v1 Jitsi widget with v2 equivalent");
const parsed = new URL(app.url);
app.data = {
conferenceId: parsed.searchParams.get("confId"),
domain: "jitsi.riot.im", // v1 widgets have this hardcoded
};
}
app.url = WidgetUtils.getLocalJitsiWrapperUrl({forLocalRender: true});
}
if (app.data) {
Object.keys(app.data).forEach((key) => {
params['$' + key] = app.data[key];
});
app.waitForIframeLoad = (app.data.waitForIframeLoad === 'false' ? false : true);
}
app.url = encodeUri(app.url, params);
return app;
}