diff --git a/web-ui/javascript/src/App.js b/web-ui/javascript/src/App.js index a97a931bf..5dbb8bd8a 100644 --- a/web-ui/javascript/src/App.js +++ b/web-ui/javascript/src/App.js @@ -34,7 +34,9 @@ import { } from "./actions"; import {generateFilename} from "./utils/packs"; import { - LOCAL_STORAGE_ANNOUNCE_LAST_SHOWN + LOCAL_STORAGE_ANNOUNCE_LAST_SHOWN, + safeGetItem, + safeSetItem } from "./utils/storage"; import './App.css'; @@ -142,7 +144,7 @@ class App extends React.Component { if (nextProps.evergreen.announce !== this.props.evergreen.announce && nextProps.evergreen.announce !== null) { // Check last announce display time in local storage and compare to announce time let announceTime = Date.parse(nextProps.evergreen.announce.date); - let lastAnnounceShown = localStorage.getItem(LOCAL_STORAGE_ANNOUNCE_LAST_SHOWN) || 0; + let lastAnnounceShown = safeGetItem(LOCAL_STORAGE_ANNOUNCE_LAST_SHOWN) || 0; console.log('announce: ' + announceTime); console.log('last shown: ' + lastAnnounceShown); if (announceTime > lastAnnounceShown) { @@ -167,7 +169,7 @@ class App extends React.Component { }; dismissAnnounceDialog = () => { - localStorage.setItem(LOCAL_STORAGE_ANNOUNCE_LAST_SHOWN, Date.now()); + safeSetItem(LOCAL_STORAGE_ANNOUNCE_LAST_SHOWN, Date.now()); this.setState({announce: null}); }; diff --git a/web-ui/javascript/src/components/ErrorBoundary.js b/web-ui/javascript/src/components/ErrorBoundary.js new file mode 100644 index 000000000..65f3ade4c --- /dev/null +++ b/web-ui/javascript/src/components/ErrorBoundary.js @@ -0,0 +1,47 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +import React from 'react'; + +/* + * The last line of defence around the application: React 16 unmounts the whole tree when a render + * error goes uncaught, which the user sees as a blank page with no way back. This shows what + * happened and offers a reload. It deliberately depends on nothing — no i18n, no store — since + * either of those may be what failed; the two sentences are given in both languages instead. + */ +class ErrorBoundary extends React.Component { + + constructor(props) { + super(props); + this.state = { error: null }; + } + + static getDerivedStateFromError(error) { + return { error }; + } + + componentDidCatch(error, info) { + console.error('Unrecoverable render error', error, info && info.componentStack); + } + + render() { + if (this.state.error) { + const message = (this.state.error && this.state.error.message) || String(this.state.error); + return ( +
+

Something went wrong / Une erreur est survenue

+

STUdio hit an error it could not recover from. Reloading the page is safe: nothing on the device is affected by a display error.

+

STUdio a rencontré une erreur dont il ne peut pas se remettre. Recharger la page est sans risque : rien sur l'appareil n'est affecté par une erreur d'affichage.

+
{message}
+ +
+ ); + } + return this.props.children; + } +} + +export default ErrorBoundary; diff --git a/web-ui/javascript/src/components/ErrorBoundary.test.js b/web-ui/javascript/src/components/ErrorBoundary.test.js new file mode 100644 index 000000000..cb339efcc --- /dev/null +++ b/web-ui/javascript/src/components/ErrorBoundary.test.js @@ -0,0 +1,49 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +/* + * A render error anywhere used to unmount the whole tree: React 16 blanks the page when nothing + * catches. The boundary sits at the root and shows a message instead. It is rendered with + * react-dom into jsdom directly — the project has no component testing library and this does not + * add one. + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import ErrorBoundary from './ErrorBoundary'; + +function Bomb() { + throw new Error('boom'); +} + +describe('ErrorBoundary', () => { + let container; + let consoleError; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + // React reports the caught error on console.error; that is expected here, not noise to fail on. + consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + ReactDOM.unmountComponentAtNode(container); + container.remove(); + consoleError.mockRestore(); + }); + + it('renders its children when nothing throws', () => { + ReactDOM.render(

fine

, container); + expect(container.textContent).toBe('fine'); + }); + + it('shows a message instead of a blank page when a child throws', () => { + ReactDOM.render(, container); + expect(container.querySelector('.error-boundary')).not.toBeNull(); + expect(container.textContent).toContain('boom'); + }); +}); diff --git a/web-ui/javascript/src/components/PackLibrary.js b/web-ui/javascript/src/components/PackLibrary.js index 713111db6..c5dae09a8 100644 --- a/web-ui/javascript/src/components/PackLibrary.js +++ b/web-ui/javascript/src/components/PackLibrary.js @@ -26,9 +26,11 @@ import { import {AppContext} from "../AppContext"; import Modal from "./Modal"; import { - LOCAL_STORAGE_ALLOW_ENRICHED_BINARY_FORMAT + LOCAL_STORAGE_ALLOW_ENRICHED_BINARY_FORMAT, + safeGetItem } from "../utils/storage"; import {chooseDropAction, applyProvenanceVerdict} from "../utils/packs"; +import {parseJson} from "../utils/json"; import {verifyConversion} from "../services/library"; import './PackLibrary.css'; @@ -87,7 +89,11 @@ class PackLibrary extends React.Component { // Ignore missing node data return; } - var data = JSON.parse(packData); + var data = parseJson(packData); + if (!data) { + // A payload that is not JSON: not something this handler produced, so nothing to do + return; + } const driverFormat = this.state.device.metadata.driver; // Which artefact goes to the device is decided in utils/packs. It compares no timestamps and @@ -113,7 +119,7 @@ class PackLibrary extends React.Component { if (action === 'convert') { // No compatible pack: convert the source console.log('pack must be converted for driver: %s', driverFormat); // Ask for enriched raw format preference - if (driverFormat === 'raw' && localStorage.getItem(LOCAL_STORAGE_ALLOW_ENRICHED_BINARY_FORMAT) === null) { + if (driverFormat === 'raw' && safeGetItem(LOCAL_STORAGE_ALLOW_ENRICHED_BINARY_FORMAT) === null) { this.setState({ allowEnrichedDialog: { show: true, @@ -278,8 +284,11 @@ class PackLibrary extends React.Component { // Otherwise ignore missing node data / file return; } - var data = JSON.parse(packData); - + var data = parseJson(packData); + if (!data) { + return; + } + // Transfer pack and show progress this.props.addToLibrary(data.uuid, this.state.device.metadata.driver, this.context); }; @@ -309,7 +318,7 @@ class PackLibrary extends React.Component { onConvertLibraryPack = (pack, format) => { return () => { - if (format === 'raw' && localStorage.getItem(LOCAL_STORAGE_ALLOW_ENRICHED_BINARY_FORMAT) === null) { + if (format === 'raw' && safeGetItem(LOCAL_STORAGE_ALLOW_ENRICHED_BINARY_FORMAT) === null) { // Ask for enriched raw format preference this.setState({ allowEnrichedDialog: { diff --git a/web-ui/javascript/src/components/diagram/widgets/PackDiagramWidget.js b/web-ui/javascript/src/components/diagram/widgets/PackDiagramWidget.js index 276e8d536..e2e5c0675 100644 --- a/web-ui/javascript/src/components/diagram/widgets/PackDiagramWidget.js +++ b/web-ui/javascript/src/components/diagram/widgets/PackDiagramWidget.js @@ -23,6 +23,7 @@ import TrayItemWidget from "./TrayItemWidget"; import Modal from "../../Modal"; import {setEditorDiagram, setEditorFilename, setDiagramErrors} from "../../../actions"; import {generateFilename} from "../../../utils/packs"; +import {parseJson} from "../../../utils/json"; class PackDiagramWidget extends React.Component { @@ -146,7 +147,10 @@ class PackDiagramWidget extends React.Component { // Ignore missing node data return; } - var data = JSON.parse(nodeData); + var data = parseJson(nodeData); + if (!data) { + return; + } var node = null; switch (data.type) { case "stage": diff --git a/web-ui/javascript/src/index.js b/web-ui/javascript/src/index.js index 1792a0b7c..c3ddcc292 100644 --- a/web-ui/javascript/src/index.js +++ b/web-ui/javascript/src/index.js @@ -11,6 +11,7 @@ import { createStore, applyMiddleware } from 'redux'; import thunkMiddleware from 'redux-thunk'; import App from './App'; +import ErrorBoundary from './components/ErrorBoundary'; import rootReducer from './reducers'; import './i18n'; @@ -23,10 +24,12 @@ const store = createStore( ); ReactDOM.render( - - - - - , + + + + + + + , document.getElementById('root') ); diff --git a/web-ui/javascript/src/reducers/settings.js b/web-ui/javascript/src/reducers/settings.js index ef267b35c..835eeb529 100644 --- a/web-ui/javascript/src/reducers/settings.js +++ b/web-ui/javascript/src/reducers/settings.js @@ -6,22 +6,24 @@ import { LOCAL_STORAGE_ANNOUNCE_OPTOUT, - LOCAL_STORAGE_ALLOW_ENRICHED_BINARY_FORMAT + LOCAL_STORAGE_ALLOW_ENRICHED_BINARY_FORMAT, + safeGetItem, + safeSetItem } from "../utils/storage"; const initialState = { - announceOptOut: localStorage.getItem(LOCAL_STORAGE_ANNOUNCE_OPTOUT) === 'true' || false, - allowEnriched: localStorage.getItem(LOCAL_STORAGE_ALLOW_ENRICHED_BINARY_FORMAT) === 'true' || false + announceOptOut: safeGetItem(LOCAL_STORAGE_ANNOUNCE_OPTOUT) === 'true', + allowEnriched: safeGetItem(LOCAL_STORAGE_ALLOW_ENRICHED_BINARY_FORMAT) === 'true' }; const settings = (state = initialState, action) => { switch (action.type) { case 'SET_ANNOUNCE_OPTOUT': - localStorage.setItem(LOCAL_STORAGE_ANNOUNCE_OPTOUT, action.announceOptOut); + safeSetItem(LOCAL_STORAGE_ANNOUNCE_OPTOUT, action.announceOptOut); return { ...state, announceOptOut: action.announceOptOut }; case 'SET_ALLOW_ENRICHED': - localStorage.setItem(LOCAL_STORAGE_ALLOW_ENRICHED_BINARY_FORMAT, action.allowEnriched); + safeSetItem(LOCAL_STORAGE_ALLOW_ENRICHED_BINARY_FORMAT, action.allowEnriched); return { ...state, allowEnriched: action.allowEnriched }; default: return state diff --git a/web-ui/javascript/src/services/library.js b/web-ui/javascript/src/services/library.js index 5f555935d..2136ca9e8 100644 --- a/web-ui/javascript/src/services/library.js +++ b/web-ui/javascript/src/services/library.js @@ -5,6 +5,7 @@ */ import {handleJsonOrError} from "../utils/fetch"; +import {parseJson} from "../utils/json"; export const fetchLibraryInfos = () => { return fetch('/api/library/infos') @@ -31,8 +32,13 @@ export const uploadToLibrary = async (uuid, path, packData, progressHandler) => xhr.upload.onprogress = progressHandler; } xhr.onload = () => { - console.log('xhr upload complete: ' + JSON.parse(xhr.responseText)); - resolve(JSON.parse(xhr.responseText)); + let response = parseJson(xhr.responseText); + if (response === undefined) { + reject(new Error('Upload failed: unexpected response from the server (' + xhr.status + ')')); + return; + } + console.log('xhr upload complete'); + resolve(response); }; xhr.open('post', '/api/library/upload', true); let formData = new FormData(); diff --git a/web-ui/javascript/src/utils/json.js b/web-ui/javascript/src/utils/json.js new file mode 100644 index 000000000..ad964c2e0 --- /dev/null +++ b/web-ui/javascript/src/utils/json.js @@ -0,0 +1,21 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +/* + * JSON.parse for input STUdio does not control: a drag-and-drop payload, an uploaded file, a + * response body. Anything that is not JSON answers undefined instead of throwing out of the + * handler that asked. + */ +export function parseJson(text) { + if (typeof text !== 'string' || text === '') { + return undefined; + } + try { + return JSON.parse(text); + } catch (e) { + return undefined; + } +} diff --git a/web-ui/javascript/src/utils/json.test.js b/web-ui/javascript/src/utils/json.test.js new file mode 100644 index 000000000..360e7f6a3 --- /dev/null +++ b/web-ui/javascript/src/utils/json.test.js @@ -0,0 +1,20 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +const { parseJson } = require('./json'); + +describe('parseJson', () => { + it('parses valid JSON', () => { + expect(parseJson('{"a":1}')).toEqual({ a: 1 }); + }); + + it('answers undefined for anything that is not JSON, instead of throwing', () => { + expect(parseJson('not json')).toBeUndefined(); + expect(parseJson('')).toBeUndefined(); + expect(parseJson(null)).toBeUndefined(); + expect(parseJson(undefined)).toBeUndefined(); + }); +}); diff --git a/web-ui/javascript/src/utils/reader.js b/web-ui/javascript/src/utils/reader.js index 7b9419530..5981d8495 100644 --- a/web-ui/javascript/src/utils/reader.js +++ b/web-ui/javascript/src/utils/reader.js @@ -13,6 +13,7 @@ import CoverNodeModel from "../components/diagram/models/CoverNodeModel"; import MenuNodeModel from "../components/diagram/models/MenuNodeModel"; import StoryNodeModel from "../components/diagram/models/StoryNodeModel"; import PackDiagramModel from "../components/diagram/models/PackDiagramModel"; +import {parseJson} from "./json"; export function readFromArchive(file) { @@ -20,9 +21,17 @@ export function readFromArchive(file) { var zip = new JSZip(); return zip.loadAsync(file) .then((archive) => { - // Read JSON story descriptor - return archive.file("story.json").async('string').then(storyJson => { - let json = JSON.parse(storyJson); + // Read JSON story descriptor. The archive is user input: say what is wrong with it + // rather than let a null or a SyntaxError escape from here. + let descriptor = archive.file("story.json"); + if (!descriptor) { + throw new Error("Not a story pack archive: no story.json inside"); + } + return descriptor.async('string').then(storyJson => { + let json = parseJson(storyJson); + if (!json) { + throw new Error("Not a story pack archive: story.json is not valid JSON"); + } var loadedModel = new PackDiagramModel(json.title, json.version, (json.description || ''), (json.nightModeAvailable || false)); diff --git a/web-ui/javascript/src/utils/reader.test.js b/web-ui/javascript/src/utils/reader.test.js new file mode 100644 index 000000000..67c40c3b2 --- /dev/null +++ b/web-ui/javascript/src/utils/reader.test.js @@ -0,0 +1,30 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +import JSZip from 'jszip'; +import { readFromArchive } from './reader'; + +/* + * An uploaded archive is user input. A story.json that is not JSON used to surface as a raw + * SyntaxError from deep inside the promise chain; it is now a rejection that names the problem. + */ +describe('readFromArchive', () => { + it('rejects a story.json that is not JSON with a message that says so', async () => { + const zip = new JSZip(); + zip.file('story.json', '{not json'); + const blob = await zip.generateAsync({ type: 'blob' }); + + await expect(readFromArchive(blob)).rejects.toThrow(/story\.json/); + }); + + it('rejects an archive without story.json rather than throwing on null', async () => { + const zip = new JSZip(); + zip.file('readme.txt', 'nothing here'); + const blob = await zip.generateAsync({ type: 'blob' }); + + await expect(readFromArchive(blob)).rejects.toThrow(/story\.json/); + }); +}); diff --git a/web-ui/javascript/src/utils/storage.js b/web-ui/javascript/src/utils/storage.js index 411cb161a..f0b435c98 100644 --- a/web-ui/javascript/src/utils/storage.js +++ b/web-ui/javascript/src/utils/storage.js @@ -7,3 +7,26 @@ export const LOCAL_STORAGE_ANNOUNCE_OPTOUT = 'announceOptOut'; export const LOCAL_STORAGE_ANNOUNCE_LAST_SHOWN = 'lastAnnounceShown'; export const LOCAL_STORAGE_ALLOW_ENRICHED_BINARY_FORMAT = 'allowEnrichedBinaryFormat'; + +/* + * localStorage is not always there: Safari in private browsing, some embedded browsers and a few + * privacy settings make every access throw — the accessor itself included. The settings reducer + * read it while building its initial state, so the store threw during creation and the application + * never rendered. An absent storage means "no stored value", and that is what these answer. + */ +export function safeGetItem(key) { + try { + return window.localStorage.getItem(key); + } catch (e) { + return null; + } +} + +export function safeSetItem(key, value) { + try { + window.localStorage.setItem(key, value); + return true; + } catch (e) { + return false; + } +} diff --git a/web-ui/javascript/src/utils/storage.test.js b/web-ui/javascript/src/utils/storage.test.js new file mode 100644 index 000000000..c7c5960b5 --- /dev/null +++ b/web-ui/javascript/src/utils/storage.test.js @@ -0,0 +1,46 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +/* + * localStorage is not always there. Safari in private browsing, some embedded browsers and a few + * privacy settings make every access throw, and the settings reducer read it while building its + * initial state — so the store threw during creation and the application never rendered. These + * helpers turn that into "no stored value", which is what an absent storage means. + */ + +const { safeGetItem, safeSetItem } = require('./storage'); + +describe('safeGetItem / safeSetItem', () => { + const realStorage = Object.getOwnPropertyDescriptor(window, 'localStorage'); + + afterEach(() => { + Object.defineProperty(window, 'localStorage', realStorage); + }); + + function replaceStorageWith(impl) { + Object.defineProperty(window, 'localStorage', { configurable: true, get: () => impl }); + } + + it('reads and writes through when storage works', () => { + safeSetItem('k', 'v'); + expect(safeGetItem('k')).toBe('v'); + }); + + it('answers null when reading throws', () => { + replaceStorageWith({ getItem: () => { throw new Error('SecurityError'); } }); + expect(safeGetItem('k')).toBeNull(); + }); + + it('answers null when the storage accessor itself throws', () => { + Object.defineProperty(window, 'localStorage', { configurable: true, get: () => { throw new Error('SecurityError'); } }); + expect(safeGetItem('k')).toBeNull(); + }); + + it('swallows a failing write and says so', () => { + replaceStorageWith({ setItem: () => { throw new Error('QuotaExceededError'); } }); + expect(safeSetItem('k', 'v')).toBe(false); + }); +});