diff --git a/.buildkite/pipeline.yaml b/.buildkite/pipeline.yaml index 4fc8254514..6a347ec002 100644 --- a/.buildkite/pipeline.yaml +++ b/.buildkite/pipeline.yaml @@ -20,6 +20,10 @@ steps: # image: "node:10" - label: ":karma: Tests" + agents: + # We use a medium sized instance instead of the normal small ones because + # webpack loves to gorge itself on resources. + queue: "medium" command: # Install chrome - "echo '--- Installing Chrome'" @@ -43,6 +47,10 @@ steps: propagate-environment: true - label: "🔧 Riot Tests" + agents: + # We use a medium sized instance instead of the normal small ones because + # webpack loves to gorge itself on resources. + queue: "medium" command: # Install chrome - "echo '--- Installing Chrome'" diff --git a/res/css/_components.scss b/res/css/_components.scss index 69f4730d85..ec8476ee63 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -71,6 +71,7 @@ @import "./views/dialogs/_ShareDialog.scss"; @import "./views/dialogs/_UnknownDeviceDialog.scss"; @import "./views/dialogs/_UserSettingsDialog.scss"; +@import "./views/dialogs/_WidgetOpenIDPermissionsDialog.scss"; @import "./views/dialogs/keybackup/_CreateKeyBackupDialog.scss"; @import "./views/dialogs/keybackup/_KeyBackupFailedDialog.scss"; @import "./views/dialogs/keybackup/_RestoreKeyBackupDialog.scss"; diff --git a/res/css/views/dialogs/_WidgetOpenIDPermissionsDialog.scss b/res/css/views/dialogs/_WidgetOpenIDPermissionsDialog.scss new file mode 100644 index 0000000000..a419c105a9 --- /dev/null +++ b/res/css/views/dialogs/_WidgetOpenIDPermissionsDialog.scss @@ -0,0 +1,28 @@ +/* +Copyright 2019 Travis Ralston + +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_WidgetOpenIDPermissionsDialog .mx_SettingsFlag { + .mx_ToggleSwitch { + display: inline-block; + vertical-align: middle; + margin-right: 8px; + } + + .mx_SettingsFlag_label { + display: inline-block; + vertical-align: middle; + } +} diff --git a/res/css/views/elements/_Tooltip.scss b/res/css/views/elements/_Tooltip.scss index 78604b1564..2f35bd338e 100644 --- a/res/css/views/elements/_Tooltip.scss +++ b/res/css/views/elements/_Tooltip.scss @@ -54,7 +54,7 @@ limitations under the License. position: fixed; border: 1px solid $menu-border-color; border-radius: 4px; - box-shadow: 4px 4px 12px 0 rgba(118, 131, 156, 0.6); + box-shadow: 4px 4px 12px 0 $menu-box-shadow-color; background-color: $menu-bg-color; z-index: 2000; padding: 10px; diff --git a/src/FromWidgetPostMessageApi.js b/src/FromWidgetPostMessageApi.js index ea7eeba756..4dd3ea6e6d 100644 --- a/src/FromWidgetPostMessageApi.js +++ b/src/FromWidgetPostMessageApi.js @@ -1,5 +1,6 @@ /* Copyright 2018 New Vector Ltd +Copyright 2019 Travis Ralston Licensed under the Apache License, Version 2.0 (the 'License'); you may not use this file except in compliance with the License. @@ -20,17 +21,19 @@ import IntegrationManager from './IntegrationManager'; import WidgetMessagingEndpoint from './WidgetMessagingEndpoint'; import ActiveWidgetStore from './stores/ActiveWidgetStore'; -const WIDGET_API_VERSION = '0.0.1'; // Current API version +const WIDGET_API_VERSION = '0.0.2'; // Current API version const SUPPORTED_WIDGET_API_VERSIONS = [ '0.0.1', + '0.0.2', ]; const INBOUND_API_NAME = 'fromWidget'; -// Listen for and handle incomming requests using the 'fromWidget' postMessage +// Listen for and handle incoming requests using the 'fromWidget' postMessage // API and initiate responses export default class FromWidgetPostMessageApi { constructor() { this.widgetMessagingEndpoints = []; + this.widgetListeners = {}; // {action: func[]} this.start = this.start.bind(this); this.stop = this.stop.bind(this); @@ -45,6 +48,32 @@ export default class FromWidgetPostMessageApi { window.removeEventListener('message', this.onPostMessage); } + /** + * Adds a listener for a given action + * @param {string} action The action to listen for. + * @param {Function} callbackFn A callback function to be called when the action is + * encountered. Called with two parameters: the interesting request information and + * the raw event received from the postMessage API. The raw event is meant to be used + * for sendResponse and similar functions. + */ + addListener(action, callbackFn) { + if (!this.widgetListeners[action]) this.widgetListeners[action] = []; + this.widgetListeners[action].push(callbackFn); + } + + /** + * Removes a listener for a given action. + * @param {string} action The action that was subscribed to. + * @param {Function} callbackFn The original callback function that was used to subscribe + * to updates. + */ + removeListener(action, callbackFn) { + if (!this.widgetListeners[action]) return; + + const idx = this.widgetListeners[action].indexOf(callbackFn); + if (idx !== -1) this.widgetListeners[action].splice(idx, 1); + } + /** * Register a widget endpoint for trusted postMessage communication * @param {string} widgetId Unique widget identifier @@ -117,6 +146,13 @@ export default class FromWidgetPostMessageApi { return; // don't log this - debugging APIs like to spam postMessage which floods the log otherwise } + // Call any listeners we have registered + if (this.widgetListeners[event.data.action]) { + for (const fn of this.widgetListeners[event.data.action]) { + fn(event.data, event); + } + } + // Although the requestId is required, we don't use it. We'll be nice and process the message // if the property is missing, but with a warning for widget developers. if (!event.data.requestId) { @@ -164,6 +200,8 @@ export default class FromWidgetPostMessageApi { if (ActiveWidgetStore.widgetHasCapability(widgetId, 'm.always_on_screen')) { ActiveWidgetStore.setWidgetPersistence(widgetId, val); } + } else if (action === 'get_openid') { + // Handled by caller } else { console.warn('Widget postMessage event unhandled'); this.sendError(event, {message: 'The postMessage was unhandled'}); diff --git a/src/Lifecycle.js b/src/Lifecycle.js index f7579cf3c0..fbb68481ad 100644 --- a/src/Lifecycle.js +++ b/src/Lifecycle.js @@ -31,7 +31,8 @@ import Modal from './Modal'; import sdk from './index'; import ActiveWidgetStore from './stores/ActiveWidgetStore'; import PlatformPeg from "./PlatformPeg"; -import {sendLoginRequest} from "./Login"; +import { sendLoginRequest } from "./Login"; +import * as StorageManager from './utils/StorageManager'; /** * Called at startup, to attempt to build a logged-in Matrix session. It tries @@ -353,6 +354,8 @@ async function _doSetLoggedIn(credentials, clearStorage) { await _clearStorage(); } + await StorageManager.checkConsistency(); + Analytics.setLoggedIn(credentials.guest, credentials.homeserverUrl, credentials.identityServerUrl); if (localStorage) { diff --git a/src/MatrixClientPeg.js b/src/MatrixClientPeg.js index 1cf29c3e82..f5994921de 100644 --- a/src/MatrixClientPeg.js +++ b/src/MatrixClientPeg.js @@ -103,7 +103,7 @@ class MatrixClientPeg { } catch (err) { if (dbType === 'indexeddb') { console.error('Error starting matrixclient store - falling back to memory store', err); - this.matrixClient.store = new Matrix.MatrixInMemoryStore({ + this.matrixClient.store = new Matrix.MemoryStore({ localStorage: global.localStorage, }); } else { @@ -171,7 +171,7 @@ class MatrixClientPeg { return matches[1]; } - _createClient(creds: MatrixClientCreds, useIndexedDb) { + _createClient(creds: MatrixClientCreds) { const opts = { baseUrl: creds.homeserverUrl, idBaseUrl: creds.identityServerUrl, @@ -183,7 +183,7 @@ class MatrixClientPeg { verificationMethods: [verificationMethods.SAS] }; - this.matrixClient = createMatrixClient(opts, useIndexedDb); + this.matrixClient = createMatrixClient(opts); // we're going to add eventlisteners for each matrix event tile, so the // potential number of event listeners is quite high. diff --git a/src/Modal.js b/src/Modal.js index 4d90e313ce..96dbd49324 100644 --- a/src/Modal.js +++ b/src/Modal.js @@ -124,6 +124,10 @@ class ModalManager { this.closeAll = this.closeAll.bind(this); } + hasDialogs() { + return this._priorityModal || this._staticModal || this._modals.length > 0; + } + getOrCreateContainer() { let container = document.getElementById(DIALOG_CONTAINER_ID); diff --git a/src/SlashCommands.js b/src/SlashCommands.js index 039ccb928f..8db9c4400d 100644 --- a/src/SlashCommands.js +++ b/src/SlashCommands.js @@ -29,6 +29,7 @@ import * as querystring from "querystring"; import MultiInviter from './utils/MultiInviter'; import { linkifyAndSanitizeHtml } from './HtmlUtils'; import QuestionDialog from "./components/views/dialogs/QuestionDialog"; +import WidgetUtils from "./utils/WidgetUtils"; class Command { constructor({name, args='', description, runFn, hideCompletionAfterSpace=false}) { @@ -431,7 +432,7 @@ export const CommandMap = { if (!targetRoomId) targetRoomId = roomId; return success( - cli.leave(targetRoomId).then(function() { + cli.leaveRoomChain(targetRoomId).then(function() { dis.dispatch({action: 'view_next_room'}); }), ); @@ -606,6 +607,26 @@ export const CommandMap = { }, }), + addwidget: new Command({ + name: 'addwidget', + args: '', + description: _td('Adds a custom widget by URL to the room'), + runFn: function(roomId, args) { + if (!args || (!args.startsWith("https://") && !args.startsWith("http://"))) { + return reject(_t("Please supply a https:// or http:// widget URL")); + } + if (WidgetUtils.canUserModifyWidgets(roomId)) { + const userId = MatrixClientPeg.get().getUserId(); + const nowMs = (new Date()).getTime(); + const widgetId = encodeURIComponent(`${roomId}_${userId}_${nowMs}`); + return success(WidgetUtils.setRoomWidget( + roomId, widgetId, "m.custom", args, "Custom Widget", {})); + } else { + return reject(_t("You cannot modify widgets in this room.")); + } + }, + }), + // Verify a user, device, and pubkey tuple verify: new Command({ name: 'verify', diff --git a/src/WidgetMessaging.js b/src/WidgetMessaging.js index 5b722df65f..1d8e1b9cd3 100644 --- a/src/WidgetMessaging.js +++ b/src/WidgetMessaging.js @@ -1,5 +1,6 @@ /* Copyright 2017 New Vector Ltd +Copyright 2019 Travis Ralston Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -21,6 +22,11 @@ limitations under the License. import FromWidgetPostMessageApi from './FromWidgetPostMessageApi'; import ToWidgetPostMessageApi from './ToWidgetPostMessageApi'; +import Modal from "./Modal"; +import MatrixClientPeg from "./MatrixClientPeg"; +import SettingsStore from "./settings/SettingsStore"; +import WidgetOpenIDPermissionsDialog from "./components/views/dialogs/WidgetOpenIDPermissionsDialog"; +import WidgetUtils from "./utils/WidgetUtils"; if (!global.mxFromWidgetMessaging) { global.mxFromWidgetMessaging = new FromWidgetPostMessageApi(); @@ -34,12 +40,14 @@ if (!global.mxToWidgetMessaging) { const OUTBOUND_API_NAME = 'toWidget'; export default class WidgetMessaging { - constructor(widgetId, widgetUrl, target) { + constructor(widgetId, widgetUrl, isUserWidget, target) { this.widgetId = widgetId; this.widgetUrl = widgetUrl; + this.isUserWidget = isUserWidget; this.target = target; this.fromWidget = global.mxFromWidgetMessaging; this.toWidget = global.mxToWidgetMessaging; + this._onOpenIdRequest = this._onOpenIdRequest.bind(this); this.start(); } @@ -109,9 +117,57 @@ export default class WidgetMessaging { start() { this.fromWidget.addEndpoint(this.widgetId, this.widgetUrl); + this.fromWidget.addListener("get_openid", this._onOpenIdRequest); } stop() { this.fromWidget.removeEndpoint(this.widgetId, this.widgetUrl); + this.fromWidget.removeListener("get_openid", this._onOpenIdRequest); + } + + async _onOpenIdRequest(ev, rawEv) { + if (ev.widgetId !== this.widgetId) return; // not interesting + + const widgetSecurityKey = WidgetUtils.getWidgetSecurityKey(this.widgetId, this.widgetUrl, this.isUserWidget); + + const settings = SettingsStore.getValue("widgetOpenIDPermissions"); + if (settings.deny && settings.deny.includes(widgetSecurityKey)) { + this.fromWidget.sendResponse(rawEv, {state: "blocked"}); + return; + } + if (settings.allow && settings.allow.includes(widgetSecurityKey)) { + const responseBody = {state: "allowed"}; + const credentials = await MatrixClientPeg.get().getOpenIdToken(); + Object.assign(responseBody, credentials); + this.fromWidget.sendResponse(rawEv, responseBody); + return; + } + + // Confirm that we received the request + this.fromWidget.sendResponse(rawEv, {state: "request"}); + + // Actually ask for permission to send the user's data + Modal.createTrackedDialog("OpenID widget permissions", '', + WidgetOpenIDPermissionsDialog, { + widgetUrl: this.widgetUrl, + widgetId: this.widgetId, + isUserWidget: this.isUserWidget, + + onFinished: async (confirm) => { + const responseBody = {success: confirm}; + if (confirm) { + const credentials = await MatrixClientPeg.get().getOpenIdToken(); + Object.assign(responseBody, credentials); + } + this.messageToWidget({ + api: OUTBOUND_API_NAME, + action: "openid_credentials", + data: responseBody, + }).catch((error) => { + console.error("Failed to send OpenID credentials: ", error); + }); + }, + }, + ); } } diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index a7192b96cb..b7a5b94373 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -50,6 +50,7 @@ import SettingsStore, {SettingLevel} from "../../settings/SettingsStore"; import { startAnyRegistrationFlow } from "../../Registration.js"; import { messageForSyncError } from '../../utils/ErrorUtils'; import ResizeNotifier from "../../utils/ResizeNotifier"; +import TimelineExplosionDialog from "../views/dialogs/TimelineExplosionDialog"; const AutoDiscovery = Matrix.AutoDiscovery; @@ -1067,34 +1068,48 @@ export default React.createClass({ button: _t("Leave"), onFinished: (shouldLeave) => { if (shouldLeave) { - const d = MatrixClientPeg.get().leave(roomId); + const d = MatrixClientPeg.get().leaveRoomChain(roomId); // FIXME: controller shouldn't be loading a view :( const Loader = sdk.getComponent("elements.Spinner"); const modal = Modal.createDialog(Loader, null, 'mx_Dialog_spinner'); - d.then(() => { + d.then((errors) => { modal.close(); + + for (const leftRoomId of Object.keys(errors)) { + const err = errors[leftRoomId]; + if (!err) continue; + + console.error("Failed to leave room " + leftRoomId + " " + err); + let title = _t("Failed to leave room"); + let message = _t("Server may be unavailable, overloaded, or you hit a bug."); + if (err.errcode === 'M_CANNOT_LEAVE_SERVER_NOTICE_ROOM') { + title = _t("Can't leave Server Notices room"); + message = _t( + "This room is used for important messages from the Homeserver, " + + "so you cannot leave it.", + ); + } else if (err && err.message) { + message = err.message; + } + Modal.createTrackedDialog('Failed to leave room', '', ErrorDialog, { + title: title, + description: message, + }); + return; + } + if (this.state.currentRoomId === roomId) { dis.dispatch({action: 'view_next_room'}); } }, (err) => { + // This should only happen if something went seriously wrong with leaving the chain. modal.close(); console.error("Failed to leave room " + roomId + " " + err); - let title = _t("Failed to leave room"); - let message = _t("Server may be unavailable, overloaded, or you hit a bug."); - if (err.errcode == 'M_CANNOT_LEAVE_SERVER_NOTICE_ROOM') { - title = _t("Can't leave Server Notices room"); - message = _t( - "This room is used for important messages from the Homeserver, " + - "so you cannot leave it.", - ); - } else if (err && err.message) { - message = err.message; - } Modal.createTrackedDialog('Failed to leave room', '', ErrorDialog, { - title: title, - description: message, + title: _t("Failed to leave room"), + description: _t("Unknown error"), }); }); } @@ -1288,6 +1303,17 @@ export default React.createClass({ return self._loggedInView.child.canResetTimelineInRoom(roomId); }); + cli.on('sync.unexpectedError', function(err) { + if (err.message && err.message.includes("live timeline ") && err.message.includes(" is no longer live ")) { + console.error("Caught timeline explosion - trying to ask user for more information"); + if (Modal.hasDialogs()) { + console.warn("User has another dialog open - skipping prompt"); + return; + } + Modal.createTrackedDialog('Timeline exploded', '', TimelineExplosionDialog, {}); + } + }); + cli.on('sync', function(state, prevState, data) { // LifecycleStore and others cannot directly subscribe to matrix client for // events because flux only allows store state changes during flux dispatches. diff --git a/src/components/views/dialogs/TimelineExplosionDialog.js b/src/components/views/dialogs/TimelineExplosionDialog.js new file mode 100644 index 0000000000..6e810d0421 --- /dev/null +++ b/src/components/views/dialogs/TimelineExplosionDialog.js @@ -0,0 +1,130 @@ +/* +Copyright 2019 New Vector 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'; +import sdk from '../../../index'; +import SdkConfig from '../../../SdkConfig'; +import { _t } from '../../../languageHandler'; + +// Dev note: this should be a temporary dialog while we work out what is +// actually going on. See https://github.com/vector-im/riot-web/issues/8593 +// for more details. This dialog is almost entirely a copy/paste job of +// BugReportDialog. +export default class TimelineExplosionDialog extends React.Component { + static propTypes = { + onFinished: React.PropTypes.func.isRequired, + }; + + constructor(props, context) { + super(props, context); + this.state = { + busy: false, + progress: null, + }; + } + + _onCancel() { + console.log("Reloading without sending logs for timeline explosion"); + window.location.reload(); + } + + _onSubmit = () => { + const userText = "Caught timeline explosion\n\nhttps://github.com/vector-im/riot-web/issues/8593"; + + this.setState({busy: true, progress: null}); + this._sendProgressCallback(_t("Preparing to send logs")); + + require(['../../../rageshake/submit-rageshake'], (s) => { + s(SdkConfig.get().bug_report_endpoint_url, { + userText, + sendLogs: true, + progressCallback: this._sendProgressCallback, + }).then(() => { + console.log("Logs sent for timeline explosion - reloading Riot"); + window.location.reload(); + }, (err) => { + console.error("Error sending logs for timeline explosion - reloading anyways.", err); + window.location.reload(); + }); + }); + }; + + _sendProgressCallback = (progress) => { + this.setState({progress: progress}); + }; + + render() { + const Loader = sdk.getComponent("elements.Spinner"); + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + + let progress = null; + if (this.state.busy) { + progress = ( +
+ {this.state.progress} ... + +
+ ); + } + + return ( + +
+

+ {_t( + "Riot has run into a problem which makes it difficult to show you " + + "your messages right now. Nothing has been lost and reloading the app " + + "should fix this for you. In order to assist us in troubleshooting the " + + "problem, we'd like to take a look at your debug logs. You do not need " + + "to send your logs unless you want to, but we would really appreciate " + + "it if you did. We'd also like to apologize for having to show this " + + "message to you - we hope your debug logs are the key to solving the " + + "issue once and for all. If you'd like more information on the bug you've " + + "accidentally run into, please visit the issue.", + {}, + { + 'a': (sub) => { + return {sub}; + }, + }, + )} +

+

+ {_t( + "Debug logs contain application usage data including your " + + "username, the IDs or aliases of the rooms or groups you " + + "have visited and the usernames of other users. They do " + + "not contain messages.", + )} +

+ {progress} +
+ +
+ ); + } +} + diff --git a/src/components/views/dialogs/WidgetOpenIDPermissionsDialog.js b/src/components/views/dialogs/WidgetOpenIDPermissionsDialog.js new file mode 100644 index 0000000000..62bd1d2521 --- /dev/null +++ b/src/components/views/dialogs/WidgetOpenIDPermissionsDialog.js @@ -0,0 +1,103 @@ +/* +Copyright 2019 Travis Ralston + +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'; +import PropTypes from 'prop-types'; +import {_t} from "../../../languageHandler"; +import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore"; +import sdk from "../../../index"; +import LabelledToggleSwitch from "../elements/LabelledToggleSwitch"; +import WidgetUtils from "../../../utils/WidgetUtils"; + +export default class WidgetOpenIDPermissionsDialog extends React.Component { + static propTypes = { + onFinished: PropTypes.func.isRequired, + widgetUrl: PropTypes.string.isRequired, + widgetId: PropTypes.string.isRequired, + isUserWidget: PropTypes.bool.isRequired, + }; + + constructor() { + super(); + + this.state = { + rememberSelection: false, + }; + } + + _onAllow = () => { + this._onPermissionSelection(true); + }; + + _onDeny = () => { + this._onPermissionSelection(false); + }; + + _onPermissionSelection(allowed) { + if (this.state.rememberSelection) { + console.log(`Remembering ${this.props.widgetId} as allowed=${allowed} for OpenID`); + + const currentValues = SettingsStore.getValue("widgetOpenIDPermissions"); + if (!currentValues.allow) currentValues.allow = []; + if (!currentValues.deny) currentValues.deny = []; + + const securityKey = WidgetUtils.getWidgetSecurityKey( + this.props.widgetId, + this.props.widgetUrl, + this.props.isUserWidget); + (allowed ? currentValues.allow : currentValues.deny).push(securityKey); + SettingsStore.setValue("widgetOpenIDPermissions", null, SettingLevel.DEVICE, currentValues); + } + + this.props.onFinished(allowed); + } + + _onRememberSelectionChange = (newVal) => { + this.setState({rememberSelection: newVal}); + }; + + render() { + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + + return ( + +
+

+ {_t( + "A widget located at %(widgetUrl)s would like to verify your identity. " + + "By allowing this, the widget will be able to verify your user ID, but not " + + "perform actions as you.", { + widgetUrl: this.props.widgetUrl, + }, + )} +

+ +
+ +
+ ); + } +} diff --git a/src/components/views/directory/NetworkDropdown.js b/src/components/views/directory/NetworkDropdown.js index ebfff5ae8c..722fe21c1b 100644 --- a/src/components/views/directory/NetworkDropdown.js +++ b/src/components/views/directory/NetworkDropdown.js @@ -131,10 +131,11 @@ export default class NetworkDropdown extends React.Component { _getMenuOptions() { const options = []; + const roomDirectory = this.props.config.roomDirectory || {}; let servers = []; - if (this.props.config.roomDirectory.servers) { - servers = servers.concat(this.props.config.roomDirectory.servers); + if (roomDirectory.servers) { + servers = servers.concat(roomDirectory.servers); } if (!servers.includes(MatrixClientPeg.getHomeServerName())) { diff --git a/src/components/views/elements/AppTile.js b/src/components/views/elements/AppTile.js index 8ed408ffbe..9444da5be4 100644 --- a/src/components/views/elements/AppTile.js +++ b/src/components/views/elements/AppTile.js @@ -351,7 +351,8 @@ export default class AppTile extends React.Component { _setupWidgetMessaging() { // 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.refs.appFrame.contentWindow); + const widgetMessaging = new WidgetMessaging( + this.props.id, this.props.url, this.props.userWidget, this.refs.appFrame.contentWindow); ActiveWidgetStore.setWidgetMessaging(this.props.id, widgetMessaging); widgetMessaging.getCapabilities().then((requestedCapabilities) => { console.log(`Widget ${this.props.id} requested capabilities: ` + requestedCapabilities); @@ -447,10 +448,14 @@ export default class AppTile extends React.Component { } // Toggle the view state of the apps drawer - dis.dispatch({ - action: 'appsDrawer', - show: !this.props.show, - }); + if (this.props.userWidget) { + this._onMinimiseClick(); + } else { + dis.dispatch({ + action: 'appsDrawer', + show: !this.props.show, + }); + } } _getSafeUrl() { @@ -626,7 +631,7 @@ export default class AppTile extends React.Component { { /* Maximise widget */ } { showMaximiseButton && } { /* Title */ } diff --git a/src/components/views/elements/LabelledToggleSwitch.js b/src/components/views/elements/LabelledToggleSwitch.js index 292c978e88..0cb9b224cf 100644 --- a/src/components/views/elements/LabelledToggleSwitch.js +++ b/src/components/views/elements/LabelledToggleSwitch.js @@ -31,15 +31,29 @@ export default class LabelledToggleSwitch extends React.Component { // Whether or not to disable the toggle switch disabled: PropTypes.bool, + + // True to put the toggle in front of the label + // Default false. + toggleInFront: PropTypes.bool, }; render() { // This is a minimal version of a SettingsFlag + + let firstPart = {this.props.label}; + let secondPart = ; + + if (this.props.toggleInFront) { + const temp = firstPart; + firstPart = secondPart; + secondPart = temp; + } + return (
- {this.props.label} - + {firstPart} + {secondPart}
); } diff --git a/src/components/views/rooms/AppsDrawer.js b/src/components/views/rooms/AppsDrawer.js index 449ca5f83d..e0e7a48b8c 100644 --- a/src/components/views/rooms/AppsDrawer.js +++ b/src/components/views/rooms/AppsDrawer.js @@ -144,7 +144,7 @@ module.exports = React.createClass({ _launchManageIntegrations: function() { const IntegrationsManager = sdk.getComponent('views.settings.IntegrationsManager'); - this._scalarClient.connect().done(() => { + this.scalarClient.connect().done(() => { const src = (this.scalarClient !== null && this.scalarClient.hasCredentials()) ? this.scalarClient.getScalarInterfaceUrlForRoom(this.props.room, 'add_integ') : null; diff --git a/src/components/views/rooms/LinkPreviewWidget.js b/src/components/views/rooms/LinkPreviewWidget.js index 5f32a6a613..8b6f295080 100644 --- a/src/components/views/rooms/LinkPreviewWidget.js +++ b/src/components/views/rooms/LinkPreviewWidget.js @@ -52,7 +52,7 @@ module.exports = React.createClass({ this.props.onHeightChanged, ); }, (error)=>{ - console.error("Failed to get preview for " + this.props.link + " " + error); + console.error("Failed to get URL preview: " + error); }).done(); }, diff --git a/src/components/views/settings/Notifications.js b/src/components/views/settings/Notifications.js index b8f8279bb0..f344f2c897 100644 --- a/src/components/views/settings/Notifications.js +++ b/src/components/views/settings/Notifications.js @@ -837,7 +837,7 @@ module.exports = React.createClass({ + label={_t('Enable desktop notifications for this device')} /> + label={_t('Enable audible notifications for this device')} /> { emailNotificationsRows } diff --git a/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.js b/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.js index f4293a60dc..b44d7b019d 100644 --- a/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.js +++ b/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.js @@ -197,6 +197,10 @@ export default class SecurityRoomSettingsTab extends React.Component { }); }; + _updateBlacklistDevicesFlag = (checked) => { + MatrixClientPeg.get().getRoom(this.props.roomId).setBlacklistUnverifiedDevices(checked); + }; + _renderRoomAccess() { const client = MatrixClientPeg.get(); const room = client.getRoom(this.props.roomId); @@ -318,6 +322,7 @@ export default class SecurityRoomSettingsTab extends React.Component { let encryptionSettings = null; if (isEncrypted) { encryptionSettings = ; } diff --git a/src/components/views/settings/tabs/user/HelpUserSettingsTab.js b/src/components/views/settings/tabs/user/HelpUserSettingsTab.js index d001a3f2e6..e45b0d0389 100644 --- a/src/components/views/settings/tabs/user/HelpUserSettingsTab.js +++ b/src/components/views/settings/tabs/user/HelpUserSettingsTab.js @@ -235,8 +235,8 @@ export default class HelpUserSettingsTab extends React.Component {
{_t("Advanced")}
- {_t("Homeserver is")} {MatrixClientPeg.get().getHomeserverUrl()}
- {_t("Identity Server is")} {MatrixClientPeg.get().getIdentityServerUrl()}
+ {_t("Homeserver is")} {MatrixClientPeg.get().getHomeserverUrl()}
+ {_t("Identity Server is")} {MatrixClientPeg.get().getIdentityServerUrl()}
{_t("Access Token:") + ' '} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index ef4bc75d27..e85537ba03 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -161,6 +161,9 @@ "Define the power level of a user": "Define the power level of a user", "Deops user with given id": "Deops user with given id", "Opens the Developer Tools dialog": "Opens the Developer Tools dialog", + "Adds a custom widget by URL to the room": "Adds a custom widget by URL to the room", + "Please supply a https:// or http:// widget URL": "Please supply a https:// or http:// widget URL", + "You cannot modify widgets in this room.": "You cannot modify widgets in this room.", "Verifies a user, device, and pubkey tuple": "Verifies a user, device, and pubkey tuple", "Unknown (user, device) pair:": "Unknown (user, device) pair:", "Device already verified!": "Device already verified!", @@ -497,9 +500,9 @@ "Advanced notification settings": "Advanced notification settings", "There are advanced notifications which are not shown here": "There are advanced notifications which are not shown here", "You might have configured them in a client other than Riot. You cannot tune them in Riot but they still apply": "You might have configured them in a client other than Riot. You cannot tune them in Riot but they still apply", - "Enable desktop notifications": "Enable desktop notifications", + "Enable desktop notifications for this device": "Enable desktop notifications for this device", "Show message in desktop notification": "Show message in desktop notification", - "Enable audible notifications in web client": "Enable audible notifications in web client", + "Enable audible notifications for this device": "Enable audible notifications for this device", "Off": "Off", "On": "On", "Noisy": "Noisy", @@ -935,6 +938,7 @@ "Failed to remove widget": "Failed to remove widget", "An error ocurred whilst trying to remove the widget from the room": "An error ocurred whilst trying to remove the widget from the room", "Minimize apps": "Minimize apps", + "Maximize apps": "Maximize apps", "Reload widget": "Reload widget", "Popout widget": "Popout widget", "Picture": "Picture", @@ -1173,11 +1177,19 @@ "Share Room Message": "Share Room Message", "Link to selected message": "Link to selected message", "COPY": "COPY", + "Error showing you your room": "Error showing you your room", + "Riot has run into a problem which makes it difficult to show you your messages right now. Nothing has been lost and reloading the app should fix this for you. In order to assist us in troubleshooting the problem, we'd like to take a look at your debug logs. You do not need to send your logs unless you want to, but we would really appreciate it if you did. We'd also like to apologize for having to show this message to you - we hope your debug logs are the key to solving the issue once and for all. If you'd like more information on the bug you've accidentally run into, please visit the issue.": "Riot has run into a problem which makes it difficult to show you your messages right now. Nothing has been lost and reloading the app should fix this for you. In order to assist us in troubleshooting the problem, we'd like to take a look at your debug logs. You do not need to send your logs unless you want to, but we would really appreciate it if you did. We'd also like to apologize for having to show this message to you - we hope your debug logs are the key to solving the issue once and for all. If you'd like more information on the bug you've accidentally run into, please visit the issue.", + "Send debug logs and reload Riot": "Send debug logs and reload Riot", + "Reload Riot without sending logs": "Reload Riot without sending logs", "You are currently blacklisting unverified devices; to send messages to these devices you must verify them.": "You are currently blacklisting unverified devices; to send messages to these devices you must verify them.", "We recommend you go through the verification process for each device to confirm they belong to their legitimate owner, but you can resend the message without verifying if you prefer.": "We recommend you go through the verification process for each device to confirm they belong to their legitimate owner, but you can resend the message without verifying if you prefer.", "Room contains unknown devices": "Room contains unknown devices", "\"%(RoomName)s\" contains devices that you haven't seen before.": "\"%(RoomName)s\" contains devices that you haven't seen before.", "Unknown devices": "Unknown devices", + "A widget would like to verify your identity": "A widget would like to verify your identity", + "A widget located at %(widgetUrl)s would like to verify your identity. By allowing this, the widget will be able to verify your user ID, but not perform actions as you.": "A widget located at %(widgetUrl)s would like to verify your identity. By allowing this, the widget will be able to verify your user ID, but not perform actions as you.", + "Remember my selection for this widget": "Remember my selection for this widget", + "Deny": "Deny", "Unable to load backup status": "Unable to load backup status", "Recovery Key Mismatch": "Recovery Key Mismatch", "Backup could not be decrypted with this key: please verify that you entered the correct recovery key.": "Backup could not be decrypted with this key: please verify that you entered the correct recovery key.", diff --git a/src/settings/Settings.js b/src/settings/Settings.js index 4fe53633ff..6e17ffbbd7 100644 --- a/src/settings/Settings.js +++ b/src/settings/Settings.js @@ -340,6 +340,13 @@ export const SETTINGS = { displayName: _td('Show developer tools'), default: false, }, + "widgetOpenIDPermissions": { + supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS, + default: { + allow: [], + deny: [], + }, + }, "RoomList.orderByImportance": { supportedLevels: LEVELS_ACCOUNT_SETTINGS, displayName: _td('Order rooms in the room list by most important first instead of most recent'), diff --git a/src/utils/StorageManager.js b/src/utils/StorageManager.js new file mode 100644 index 0000000000..5edb43fb0b --- /dev/null +++ b/src/utils/StorageManager.js @@ -0,0 +1,101 @@ +/* +Copyright 2019 New Vector 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 Matrix from 'matrix-js-sdk'; +import Analytics from '../Analytics'; + +const localStorage = window.localStorage; + +// just *accessing* indexedDB throws an exception in firefox with +// indexeddb disabled. +let indexedDB; +try { + indexedDB = window.indexedDB; +} catch (e) {} + +// The JS SDK will add a prefix of "matrix-js-sdk:" to the sync store name. +const SYNC_STORE_NAME = "riot-web-sync"; +const CRYPTO_STORE_NAME = "matrix-js-sdk:crypto"; + +function log(msg) { + console.log(`StorageManager: ${msg}`); +} + +function error(msg) { + console.error(`StorageManager: ${msg}`); +} + +function track(action) { + Analytics.trackEvent("StorageManager", action); +} + +export async function checkConsistency() { + log("Checking storage consistency"); + log(`Local storage supported? ${!!localStorage}`); + log(`IndexedDB supported? ${!!indexedDB}`); + + let dataInLocalStorage = false; + let dataInCryptoStore = false; + let healthy = true; + + if (localStorage) { + dataInLocalStorage = localStorage.length > 0; + log(`Local storage contains data? ${dataInLocalStorage}`); + } else { + healthy = false; + error("Local storage cannot be used on this browser"); + track("Local storage disabled"); + } + + if (indexedDB && localStorage) { + const dataInSyncStore = await Matrix.IndexedDBStore.exists( + indexedDB, SYNC_STORE_NAME, + ); + log(`Sync store contains data? ${dataInSyncStore}`); + } else { + healthy = false; + error("Sync store cannot be used on this browser"); + track("Sync store disabled"); + } + + if (indexedDB) { + dataInCryptoStore = await Matrix.IndexedDBCryptoStore.exists( + indexedDB, CRYPTO_STORE_NAME, + ); + log(`Crypto store contains data? ${dataInCryptoStore}`); + } else { + healthy = false; + error("Crypto store cannot be used on this browser"); + track("Crypto store disabled"); + } + + if (dataInLocalStorage && !dataInCryptoStore) { + healthy = false; + error( + "Data exists in local storage but not in crypto store. " + + "IndexedDB storage has likely been evicted by the browser!", + ); + track("Crypto store evicted"); + } + + if (healthy) { + log("Storage consistency checks passed"); + track("Consistency checks passed"); + } else { + error("Storage consistency checks failed"); + track("Consistency checks failed"); + } +} diff --git a/src/utils/WidgetUtils.js b/src/utils/WidgetUtils.js index b5a2ae31fb..41a241c905 100644 --- a/src/utils/WidgetUtils.js +++ b/src/utils/WidgetUtils.js @@ -1,6 +1,7 @@ /* Copyright 2017 Vector Creations Ltd Copyright 2018 New Vector Ltd +Copyright 2019 Travis Ralston Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -25,6 +26,7 @@ import WidgetEchoStore from '../stores/WidgetEchoStore'; // before waitFor[Room/User]Widget rejects its promise const WIDGET_WAIT_TIME = 20000; import SettingsStore from "../settings/SettingsStore"; +import ActiveWidgetStore from "../stores/ActiveWidgetStore"; /** * Encodes a URI according to a set of template variables. Variables will be @@ -396,4 +398,25 @@ export default class WidgetUtils { return capWhitelist; } + + static getWidgetSecurityKey(widgetId, widgetUrl, isUserWidget) { + let widgetLocation = ActiveWidgetStore.getRoomId(widgetId); + + if (isUserWidget) { + const userWidget = WidgetUtils.getUserWidgetsArray() + .find((w) => w.id === widgetId && w.content && w.content.url === widgetUrl); + + if (!userWidget) { + throw new Error("No matching user widget to form security key"); + } + + widgetLocation = userWidget.sender; + } + + if (!widgetLocation) { + throw new Error("Failed to locate where the widget resides"); + } + + return encodeURIComponent(`${widgetLocation}::${widgetUrl}`); + } } diff --git a/src/utils/createMatrixClient.js b/src/utils/createMatrixClient.js index 2acd1fae28..dee9324460 100644 --- a/src/utils/createMatrixClient.js +++ b/src/utils/createMatrixClient.js @@ -32,27 +32,18 @@ try { * @param {Object} opts options to pass to Matrix.createClient. This will be * extended with `sessionStore` and `store` members. * - * @param {bool} useIndexedDb True to attempt to use indexeddb, or false to force - * use of the memory store. Default: true. - * * @property {string} indexedDbWorkerScript Optional URL for a web worker script * for IndexedDB store operations. By default, indexeddb ops are done on * the main thread. * * @returns {MatrixClient} the newly-created MatrixClient */ -export default function createMatrixClient(opts, useIndexedDb) { - if (useIndexedDb === undefined) useIndexedDb = true; - +export default function createMatrixClient(opts) { const storeOpts = { useAuthorizationHeader: true, }; - if (localStorage) { - storeOpts.sessionStore = new Matrix.WebStorageSessionStore(localStorage); - } - - if (indexedDB && localStorage && useIndexedDb) { + if (indexedDB && localStorage) { storeOpts.store = new Matrix.IndexedDBStore({ indexedDB: indexedDB, dbName: "riot-web-sync", @@ -61,6 +52,16 @@ export default function createMatrixClient(opts, useIndexedDb) { }); } + if (localStorage) { + storeOpts.sessionStore = new Matrix.WebStorageSessionStore(localStorage); + } + + if (indexedDB) { + storeOpts.cryptoStore = new Matrix.IndexedDBCryptoStore( + indexedDB, "matrix-js-sdk:crypto", + ); + } + opts = Object.assign(storeOpts, opts); return Matrix.createClient(opts);