diff --git a/src/WidgetUtils.js b/src/WidgetUtils.js new file mode 100644 index 0000000000..34c998978d --- /dev/null +++ b/src/WidgetUtils.js @@ -0,0 +1,58 @@ +/* +Copyright 2017 Vector Creations Ltd + +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 MatrixClientPeg from './MatrixClientPeg'; + +export default class WidgetUtils { + + /* Returns true if user is able to send state events to modify widgets in this room + * @param roomId -- The ID of the room to check + * @return Boolean -- true if the user can modify widgets in this room + * @throws Error -- specifies the error reason + */ + static canUserModifyWidgets(roomId) { + if (!roomId) { + console.warn('No room ID specified'); + return false; + } + + const client = MatrixClientPeg.get(); + if (!client) { + console.warn('User must be be logged in'); + return false; + } + + const room = client.getRoom(roomId); + if (!room) { + console.warn(`Room ID ${roomId} is not recognised`); + return false; + } + + const me = client.credentials.userId; + if (!me) { + console.warn('Failed to get user ID'); + return false; + } + + const member = room.getMember(me); + if (!member || member.membership !== "join") { + console.warn(`User ${me} is not in room ${roomId}`); + return false; + } + + return room.currentState.maySendStateEvent('im.vector.modular.widgets', me); + } +} diff --git a/src/components/views/elements/AppPermission.js b/src/components/views/elements/AppPermission.js new file mode 100644 index 0000000000..dbdf74dbbc --- /dev/null +++ b/src/components/views/elements/AppPermission.js @@ -0,0 +1,74 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import url from 'url'; +import { _t } from '../../../languageHandler'; + +export default class AppPermission extends React.Component { + constructor(props) { + super(props); + + const curlBase = this.getCurlBase(); + this.state = { curlBase: curlBase}; + } + + // Return string representation of content URL without query parameters + getCurlBase() { + const wurl = url.parse(this.props.url); + let curl; + let curlString; + + const searchParams = new URLSearchParams(wurl.search); + + if(this.isScalarWurl(wurl) && searchParams && searchParams.get('url')) { + curl = url.parse(searchParams.get('url')); + if(curl) { + curl.search = curl.query = ""; + curlString = curl.format(); + } + } + if (!curl && wurl) { + wurl.search = wurl.query = ""; + curlString = wurl.format(); + } + return curlString; + } + + isScalarWurl(wurl) { + if(wurl && wurl.hostname && ( + wurl.hostname === 'scalar.vector.im' || + wurl.hostname === 'scalar-staging.riot.im' || + wurl.hostname === 'demo.riot.im' || + wurl.hostname === 'localhost' + )) { + return true; + } + return false; + } + + render() { + return ( +
+
+ {_t('Warning!')}/ +
+
+ Do you want to load widget from URL: {this.state.curlBase} +
+ +
+ ); + } +} + +AppPermission.propTypes = { + url: PropTypes.string.isRequired, + onPermissionGranted: PropTypes.func.isRequired, +}; +AppPermission.defaultProps = { + onPermissionGranted: function() {}, +}; diff --git a/src/components/views/elements/AppTile.js b/src/components/views/elements/AppTile.js index 9573b9fd9f..7b7de55c75 100644 --- a/src/components/views/elements/AppTile.js +++ b/src/components/views/elements/AppTile.js @@ -24,6 +24,9 @@ import SdkConfig from '../../../SdkConfig'; import Modal from '../../../Modal'; import { _t } from '../../../languageHandler'; import sdk from '../../../index'; +import AppPermission from './AppPermission'; +import MessageSpinner from './MessageSpinner'; +import WidgetUtils from '../../../WidgetUtils'; const ALLOWED_APP_URL_SCHEMES = ['https:', 'http:']; const betaHelpMsg = 'This feature is currently experimental and is intended for beta testing only'; @@ -37,6 +40,9 @@ export default React.createClass({ name: React.PropTypes.string.isRequired, room: React.PropTypes.object.isRequired, type: React.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: React.PropTypes.bool, }, getDefaultProps: function() { @@ -46,9 +52,13 @@ export default React.createClass({ }, getInitialState: function() { + const widgetPermissionId = [this.props.room.roomId, encodeURIComponent(this.props.url)].join('_'); + const hasPermissionToLoad = localStorage.getItem(widgetPermissionId); return { loading: false, widgetUrl: this.props.url, + widgetPermissionId: widgetPermissionId, + hasPermissionToLoad: Boolean(hasPermissionToLoad === 'true'), error: null, deleting: false, }; @@ -91,6 +101,10 @@ export default React.createClass({ }); }, + _canUserModify: function() { + return WidgetUtils.canUserModifyWidgets(this.props.room.roomId); + }, + _onEditClick: function(e) { console.log("Edit widget ID ", this.props.id); const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager"); @@ -100,20 +114,49 @@ export default React.createClass({ }, "mx_IntegrationsManager"); }, + /* If user has permission to modify widgets, delete the widget, otherwise revoke access for the widget to load in the user's browser + */ _onDeleteClick: function() { - console.log("Delete widget %s", this.props.id); - this.setState({deleting: true}); - MatrixClientPeg.get().sendStateEvent( - this.props.room.roomId, - 'im.vector.modular.widgets', - {}, // empty content - this.props.id, - ).then(() => { - console.log('Deleted widget'); - }, (e) => { - console.error('Failed to delete widget', e); - this.setState({deleting: false}); - }); + if (this._canUserModify()) { + console.log("Delete widget %s", this.props.id); + this.setState({deleting: true}); + MatrixClientPeg.get().sendStateEvent( + this.props.room.roomId, + 'im.vector.modular.widgets', + {}, // empty content + this.props.id, + ).then(() => { + console.log('Deleted widget'); + }, (e) => { + console.error('Failed to delete widget', e); + this.setState({deleting: false}); + }); + } else { + console.log("Revoke widget permissions - %s", this.props.id); + this._revokeWidgetPermission(); + } + }, + + // Widget labels to render, depending upon user permissions + // These strings are translated at the point that they are inserted in to the DOM, in the render method + _deleteWidgetLabel() { + if (this._canUserModify()) { + return 'Delete widget'; + } + return 'Revoke widget access'; + }, + + /* TODO -- Store permission in account data so that it is persisted across multiple devices */ + _grantWidgetPermission() { + console.warn('Granting permission to load widget - ', this.state.widgetUrl); + localStorage.setItem(this.state.widgetPermissionId, true); + this.setState({hasPermissionToLoad: true}); + }, + + _revokeWidgetPermission() { + console.warn('Revoking permission to load widget - ', this.state.widgetUrl); + localStorage.removeItem(this.state.widgetPermissionId); + this.setState({hasPermissionToLoad: false}); }, formatAppTileName: function() { @@ -133,34 +176,56 @@ export default React.createClass({ return
; } + // Note that there is advice saying allow-scripts shouldn't be used with allow-same-origin + // because that would allow the iframe to prgramatically remove the sandbox attribute, but + // this would only be for content hosted on the same origin as the riot 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 "+ + "allow-same-origin allow-scripts allow-presentation"; + const parsedWidgetUrl = url.parse(this.state.widgetUrl); + let safeWidgetUrl = ''; + if (ALLOWED_APP_URL_SCHEMES.indexOf(parsedWidgetUrl.protocol) !== -1) { + safeWidgetUrl = url.format(parsedWidgetUrl); + } + if (this.state.loading) { appTileBody = ( -
Loading...
+
+ +
); - } else { - // Note that there is advice saying allow-scripts shouldn't be used with allow-same-origin - // because that would allow the iframe to prgramatically remove the sandbox attribute, but - // this would only be for content hosted on the same origin as the riot 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 "+ - "allow-same-origin allow-scripts"; - const parsedWidgetUrl = url.parse(this.state.widgetUrl); - let safeWidgetUrl = ''; - if (ALLOWED_APP_URL_SCHEMES.indexOf(parsedWidgetUrl.protocol) !== -1) { - safeWidgetUrl = url.format(parsedWidgetUrl); - } + } else if (this.state.hasPermissionToLoad == true) { appTileBody = (
-
); + } else { + appTileBody = ( +
+ +
+ ); } // editing is done in scalar - const showEditButton = Boolean(this._scalarClient); + const showEditButton = Boolean(this._scalarClient && this._canUserModify()); + const deleteWidgetLabel = this._deleteWidgetLabel(); + let deleteIcon = 'img/cancel.svg'; + let deleteClasses = 'mx_filterFlipColor mx_AppTileMenuBarWidget'; + if(this._canUserModify()) { + deleteIcon = 'img/cancel-red.svg'; + deleteClasses += ' mx_AppTileMenuBarWidgetDelete'; + } return (
@@ -172,14 +237,18 @@ export default React.createClass({ {showEditButton && Edit} {/* Delete widget */} - {_t("Cancel")} diff --git a/src/components/views/elements/MessageSpinner.js b/src/components/views/elements/MessageSpinner.js new file mode 100644 index 0000000000..bc0a326338 --- /dev/null +++ b/src/components/views/elements/MessageSpinner.js @@ -0,0 +1,34 @@ +/* +Copyright 2017 Vector Creations Ltd + +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 React from 'react'; + +module.exports = React.createClass({ + displayName: 'MessageSpinner', + + render: function() { + const w = this.props.w || 32; + const h = this.props.h || 32; + const imgClass = this.props.imgClassName || ""; + const msg = this.props.msg || "Loading..."; + return ( +
+
{msg}
  + +
+ ); + }, +}); diff --git a/src/components/views/rooms/AppsDrawer.js b/src/components/views/rooms/AppsDrawer.js index 3b8acc3f40..5427d4ec6d 100644 --- a/src/components/views/rooms/AppsDrawer.js +++ b/src/components/views/rooms/AppsDrawer.js @@ -26,6 +26,7 @@ import SdkConfig from '../../../SdkConfig'; import ScalarAuthClient from '../../../ScalarAuthClient'; import ScalarMessaging from '../../../ScalarMessaging'; import { _t } from '../../../languageHandler'; +import WidgetUtils from '../../../WidgetUtils'; module.exports = React.createClass({ @@ -147,6 +148,15 @@ module.exports = React.createClass({ }); }, + _canUserModify: function() { + try { + return WidgetUtils.canUserModifyWidgets(this.props.room.roomId); + } catch(err) { + console.error(err); + return false; + } + }, + onClickAddWidget: function(e) { if (e) { e.preventDefault(); @@ -164,7 +174,7 @@ module.exports = React.createClass({ render: function() { const apps = this.state.apps.map( (app, index, arr) => { - return ; + />); }); - const addWidget = this.state.apps && this.state.apps.length < 2 && + const addWidget = this.state.apps && this.state.apps.length < 2 && this._canUserModify() && (