diff --git a/client/coral-admin/src/actions/moderation.js b/client/coral-admin/src/actions/moderation.js index 16c2bd2f7..a46d2dccd 100644 --- a/client/coral-admin/src/actions/moderation.js +++ b/client/coral-admin/src/actions/moderation.js @@ -5,14 +5,7 @@ export const singleView = () => ({ type: actions.SINGLE_VIEW }); // hide shortcuts note export const hideShortcutsNote = () => (dispatch, _, { localStorage }) => { - try { - if (localStorage) { - localStorage.setItem('coral:shortcutsNote', 'hide'); - } - } catch (e) { - // above will fail in Safari private mode - } - + localStorage.setItem('coral:shortcutsNote', 'hide'); dispatch({ type: actions.HIDE_SHORTCUTS_NOTE }); }; diff --git a/client/coral-admin/src/index.js b/client/coral-admin/src/index.js index 0cf0a8cf9..158870dec 100644 --- a/client/coral-admin/src/index.js +++ b/client/coral-admin/src/index.js @@ -15,7 +15,8 @@ import { hideShortcutsNote } from './actions/moderation'; smoothscroll.polyfill(); function init({ store, localStorage }) { - if (localStorage && localStorage.getItem('coral:shortcutsNote') === 'hide') { + const shouldHide = localStorage.getItem('coral:shortcutsNote') === 'hide'; + if (shouldHide) { store.dispatch(hideShortcutsNote()); } } diff --git a/client/coral-framework/actions/auth.js b/client/coral-framework/actions/auth.js index 5390153e6..5e3b0846f 100644 --- a/client/coral-framework/actions/auth.js +++ b/client/coral-framework/actions/auth.js @@ -15,9 +15,7 @@ export const checkLogin = () => ( rest('/auth') .then(result => { if (!result.user) { - if (localStorage) { - cleanAuthData(localStorage); - } + cleanAuthData(localStorage); dispatch(checkLoginSuccess(null)); return; } @@ -52,10 +50,8 @@ const checkLoginSuccess = user => ({ }); export const setAuthToken = token => (dispatch, _, { localStorage }) => { - if (localStorage) { - localStorage.setItem('exp', jwtDecode(token).exp); - localStorage.setItem('token', token); - } + localStorage.setItem('exp', jwtDecode(token).exp); + localStorage.setItem('token', token); // Dispatch the set auth token action. For some browsers and situations, we // may not be able to persist the auth token any other way. Keep it in redux! @@ -70,11 +66,8 @@ export const handleSuccessfulLogin = (user, token) => ( { client, localStorage, postMessage } ) => { const { exp } = jwtDecode(token); - - if (localStorage) { - localStorage.setItem('exp', exp); - localStorage.setItem('token', token); - } + localStorage.setItem('exp', exp); + localStorage.setItem('token', token); // Send the message via the messages service to the window.opener if it // exists. @@ -105,9 +98,8 @@ export const logout = () => async ( ) => { await rest('/auth', { method: 'DELETE' }); - if (localStorage) { - cleanAuthData(localStorage); - } + // Clear the auth data persisted to localStorage. + cleanAuthData(localStorage); // Reset the websocket. client.resetWebsocket(); diff --git a/client/coral-framework/services/i18n.js b/client/coral-framework/services/i18n.js index 9c65c9c4e..e1c3ab99e 100644 --- a/client/coral-framework/services/i18n.js +++ b/client/coral-framework/services/i18n.js @@ -52,26 +52,41 @@ let lang; let timeagoInstance; function setLocale(storage, locale) { - try { - if (storage) { - storage.setItem('locale', locale); - } - } catch (err) { - console.error(err); - } + storage.setItem('locale', locale); } -function getLocale(storage) { +// detectLanguage will try to get the locale from storage if available, +// otherwise will try to get it from the navigator, otherwise, it will fallback +// to the default language. +function detectLanguage(storage) { try { - return ( - (storage && storage.getItem('locale')) || - navigator.language || - defaultLanguage - ).split('-')[0]; + const lang = storage.getItem('locale') || navigator.language; + if (lang) { + return lang; + } } catch (err) { - console.error(err); - return null; + console.warn( + 'Error while trying to detect language, will fallback to', + err + ); } + + console.warn('Could not detect language, will fallback to', defaultLanguage); + return defaultLanguage; +} + +// getLocale will get the users locale from the local detector and parse it to a +// format we can work with. +function getLocale(storage) { + // Get the language from the local detector. + const lang = detectLanguage(storage); + + // Some language strings come with additional subtags as defined in: + // + // https://www.ietf.org/rfc/bcp/bcp47.txt + // + // So we should strip that off if we find it. + return lang.split('-')[0]; } export function setupTranslations() { diff --git a/client/coral-framework/services/storage.js b/client/coral-framework/services/storage.js index 66e2c96cd..7b7acf655 100644 --- a/client/coral-framework/services/storage.js +++ b/client/coral-framework/services/storage.js @@ -1,40 +1,116 @@ import uuid from 'uuid/v4'; -function getStorage(type) { - let storage; - try { - storage = window[type]; - const x = '__storage_test__'; - storage.setItem(x, x); - storage.removeItem(x); - } catch (e) { - const ignore = - e instanceof DOMException && - // everything except Firefox - (e.code === 22 || - // SecurityError related to having 3rd party cookies disabled. - e.code === 18 || - // Firefox +function testStorageAccess(storage) { + const key = '__storage_test__'; - e.code === 1014 || - // test name field too, because code might not be present + // Create a unique test value. + const expectedValue = String(Date.now()); - // everything except Firefox - e.name === 'QuotaExceededError' || - // Firefox - e.name === 'NS_ERROR_DOM_QUOTA_REACHED'); - if (!ignore) { - console.warn(e); + // Try to set, get, and remove that item. + storage.setItem(key, expectedValue); + const canSetGet = expectedValue === storage.getItem(key); + storage.removeItem(key); + + if (!canSetGet) { + // We can't access the desired storage! + throw new Error('Storage access test failed'); + } +} + +// InMemoryStorage is a dumb implementation of the Storage interface that will +// not persist the data at all. It implements the Storage interface found: +// +// https://developer.mozilla.org/en-US/docs/Web/API/Storage +// +class InMemoryStorage { + constructor() { + this.storage = {}; + } + + get length() { + return Object.keys(this.storage).length; + } + + key(n) { + if (this.length <= n) { + return undefined; } - // When third party cookies are disabled, session storage is readable/ - // writable, but localStorage is not. Try to get the sessionStorage to use. - if (type !== 'sessionStorage') { - return getStorage('sessionStorage'); + return this.storage[Object.keys(this.storage)[n]]; + } + + getItem(key) { + return this.storage[key]; + } + + setItem(key, value) { + this.storage[key] = value; + + try { + // Test sessionStorage. We could have been given access recently. + testStorageAccess(sessionStorage); + + // Test passed! Set the item in sessionStorage. + sessionStorage.setItem(key, value); + console.log( + 'Attempt to persist InMemoryStorage value to sessionStorage succeeded' + ); + } catch (err) { + console.warn( + 'Attempt to persist InMemoryStorage value to sessionStorage failed', + err + ); } } - return storage; + removeItem(key) { + delete this.storage[key]; + + try { + // Test sessionStorage. We could have been given access recently. + testStorageAccess(sessionStorage); + + // Test passed! Remove the item from sessionStorage. + sessionStorage.removeItem(key); + console.log( + 'Attempt to persist InMemoryStorage delete to sessionStorage succeeded' + ); + } catch (err) { + console.warn( + 'Attempt to persist InMemoryStorage delete to sessionStorage failed', + err + ); + } + } +} + +// getStorage will test to see if the requested storage type is available, if it +// is not, it will try sessionStorage, and if that is also not available, it +// will fallback to InMemoryStorage. +function getStorage(type) { + try { + // Get the desired storage from the window and test it out. + const storage = window[type]; + testStorageAccess(storage); + + // Storage test was successful! Return it. + return storage; + } catch (err) { + // When third party cookies are disabled, session storage is readable/ + // writable, but localStorage is not. Try to get the sessionStorage to use. + if (type !== 'sessionStorage') { + console.warn('Could not access', type, 'trying sessionStorage', err); + return getStorage('sessionStorage'); + } + + console.warn( + 'Could not access sessionStorage falling back to InMemoryStorage', + err + ); + } + + // No acceptable storage could be found, returning the InMemoryStorage. + return new InMemoryStorage(); } /** diff --git a/package.json b/package.json index c9243a560..02cc45e70 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "talk", - "version": "4.3.1", + "version": "4.3.2", "description": "A better commenting experience from Mozilla, The New York Times, and the Washington Post. https://coralproject.net", "main": "app.js", "private": true,