fix(v2): fail-safe usage of browser storage (localStorage/sessionStorage) when access is denied (#4501)

* fix: Fix unsafe uses of localStorage

Puts all uses of localStorage behind an abstraction which doesn't fail
when localStorage isn't available.

* cleanup fail-safe browser storage usage

Co-authored-by: slorber <lorber.sebastien@gmail.com>
This commit is contained in:
John Knox 2021-04-13 11:38:12 +01:00 committed by GitHub
parent cbb31783d7
commit 2c57f44bd6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 160 additions and 52 deletions

View file

@ -17,6 +17,7 @@
"@babel/plugin-proposal-optional-chaining": "^7.12.16", "@babel/plugin-proposal-optional-chaining": "^7.12.16",
"@babel/preset-env": "^7.12.16", "@babel/preset-env": "^7.12.16",
"@docusaurus/core": "2.0.0-alpha.72", "@docusaurus/core": "2.0.0-alpha.72",
"@docusaurus/theme-common": "2.0.0-alpha.72",
"@docusaurus/utils-validation": "2.0.0-alpha.72", "@docusaurus/utils-validation": "2.0.0-alpha.72",
"babel-loader": "^8.2.2", "babel-loader": "^8.2.2",
"clsx": "^1.1.1", "clsx": "^1.1.1",

View file

@ -4,6 +4,7 @@
* This source code is licensed under the MIT license found in the * This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
*/ */
import {createStorageSlot} from '@docusaurus/theme-common';
// First: read the env variables (provided by Webpack) // First: read the env variables (provided by Webpack)
/* eslint-disable prefer-destructuring */ /* eslint-disable prefer-destructuring */
@ -17,7 +18,10 @@ const PWA_DEBUG = process.env.PWA_DEBUG;
const debug = PWA_DEBUG; // shortcut const debug = PWA_DEBUG; // shortcut
const MAX_MOBILE_WIDTH = 940; const MAX_MOBILE_WIDTH = 940;
const APP_INSTALLED_EVENT_FIRED_KEY = 'docusaurus.pwa.event.appInstalled.fired';
const AppInstalledEventFiredStorage = createStorageSlot(
'docusaurus.pwa.event.appInstalled.fired',
);
async function clearRegistrations() { async function clearRegistrations() {
const registrations = await navigator.serviceWorker.getRegistrations(); const registrations = await navigator.serviceWorker.getRegistrations();
@ -58,7 +62,7 @@ https://stackoverflow.com/questions/51735869/check-if-user-has-already-installed
- display-mode: standalone is not exactly the same concept, but looks like a decent fallback https://petelepage.com/blog/2019/07/is-my-pwa-installed/ - display-mode: standalone is not exactly the same concept, but looks like a decent fallback https://petelepage.com/blog/2019/07/is-my-pwa-installed/
*/ */
async function isAppInstalledEventFired() { async function isAppInstalledEventFired() {
return localStorage.getItem(APP_INSTALLED_EVENT_FIRED_KEY) === 'true'; return AppInstalledEventFiredStorage.get() === 'true';
} }
async function isAppInstalledRelatedApps() { async function isAppInstalledRelatedApps() {
if ('getInstalledRelatedApps' in window.navigator) { if ('getInstalledRelatedApps' in window.navigator) {
@ -229,10 +233,10 @@ function addLegacyAppInstalledEventsListeners() {
console.log('[Docusaurus-PWA][registerSw]: event appinstalled', event); console.log('[Docusaurus-PWA][registerSw]: event appinstalled', event);
} }
localStorage.setItem(APP_INSTALLED_EVENT_FIRED_KEY, 'true'); AppInstalledEventFiredStorage.set('true');
if (debug) { if (debug) {
console.log( console.log(
"[Docusaurus-PWA][registerSw]: localStorage.setItem(APP_INSTALLED_EVENT_FIRED_KEY, 'true');", "[Docusaurus-PWA][registerSw]: AppInstalledEventFiredStorage.set('true')",
); );
} }
@ -255,15 +259,15 @@ function addLegacyAppInstalledEventsListeners() {
// event.preventDefault(); // event.preventDefault();
if (debug) { if (debug) {
console.log( console.log(
'[Docusaurus-PWA][registerSw]: localStorage.getItem(APP_INSTALLED_EVENT_FIRED_KEY)', '[Docusaurus-PWA][registerSw]: AppInstalledEventFiredStorage.get()',
localStorage.getItem(APP_INSTALLED_EVENT_FIRED_KEY), AppInstalledEventFiredStorage.get(),
); );
} }
if (localStorage.getItem(APP_INSTALLED_EVENT_FIRED_KEY)) { if (AppInstalledEventFiredStorage.get()) {
localStorage.removeItem(APP_INSTALLED_EVENT_FIRED_KEY); AppInstalledEventFiredStorage.del();
if (debug) { if (debug) {
console.log( console.log(
'[Docusaurus-PWA][registerSw]: localStorage.removeItem(APP_INSTALLED_EVENT_FIRED_KEY)', '[Docusaurus-PWA][registerSw]: AppInstalledEventFiredStorage.del()',
); );
} }
// After uninstalling the app, if the user doesn't clear all data, then // After uninstalling the app, if the user doesn't clear all data, then

View file

@ -6,11 +6,11 @@
*/ */
import {useState, useEffect, useCallback} from 'react'; import {useState, useEffect, useCallback} from 'react';
import {useThemeConfig} from '@docusaurus/theme-common'; import {useThemeConfig, createStorageSlot} from '@docusaurus/theme-common';
import type {useAnnouncementBarReturns} from '@theme/hooks/useAnnouncementBar'; import type {useAnnouncementBarReturns} from '@theme/hooks/useAnnouncementBar';
const STORAGE_DISMISS_KEY = 'docusaurus.announcement.dismiss'; const DismissStorage = createStorageSlot('docusaurus.announcement.dismiss');
const STORAGE_ID_KEY = 'docusaurus.announcement.id'; const IdStorage = createStorageSlot('docusaurus.announcement.id');
const useAnnouncementBar = (): useAnnouncementBarReturns => { const useAnnouncementBar = (): useAnnouncementBarReturns => {
const {announcementBar} = useThemeConfig(); const {announcementBar} = useThemeConfig();
@ -18,7 +18,7 @@ const useAnnouncementBar = (): useAnnouncementBarReturns => {
const [isClosed, setClosed] = useState(true); const [isClosed, setClosed] = useState(true);
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
localStorage.setItem(STORAGE_DISMISS_KEY, 'true'); DismissStorage.set('true');
setClosed(true); setClosed(true);
}, []); }, []);
@ -28,7 +28,7 @@ const useAnnouncementBar = (): useAnnouncementBarReturns => {
} }
const {id} = announcementBar; const {id} = announcementBar;
let viewedId = localStorage.getItem(STORAGE_ID_KEY); let viewedId = IdStorage.get();
// retrocompatibility due to spelling mistake of default id // retrocompatibility due to spelling mistake of default id
// see https://github.com/facebook/docusaurus/issues/3338 // see https://github.com/facebook/docusaurus/issues/3338
@ -38,16 +38,13 @@ const useAnnouncementBar = (): useAnnouncementBarReturns => {
const isNewAnnouncement = id !== viewedId; const isNewAnnouncement = id !== viewedId;
localStorage.setItem(STORAGE_ID_KEY, id); IdStorage.set(id);
if (isNewAnnouncement) { if (isNewAnnouncement) {
localStorage.setItem(STORAGE_DISMISS_KEY, 'false'); DismissStorage.set('false');
} }
if ( if (isNewAnnouncement || DismissStorage.get() === 'false') {
isNewAnnouncement ||
localStorage.getItem(STORAGE_DISMISS_KEY) === 'false'
) {
setClosed(false); setClosed(false);
} }
}, []); }, []);

View file

@ -7,6 +7,7 @@
import {useState, useCallback, useEffect} from 'react'; import {useState, useCallback, useEffect} from 'react';
import type {useTabGroupChoiceReturns} from '@theme/hooks/useTabGroupChoice'; import type {useTabGroupChoiceReturns} from '@theme/hooks/useTabGroupChoice';
import {createStorageSlot, listStorageKeys} from '@docusaurus/theme-common';
const TAB_CHOICE_PREFIX = 'docusaurus.tab.'; const TAB_CHOICE_PREFIX = 'docusaurus.tab.';
@ -15,21 +16,16 @@ const useTabGroupChoice = (): useTabGroupChoiceReturns => {
readonly [groupId: string]: string; readonly [groupId: string]: string;
}>({}); }>({});
const setChoiceSyncWithLocalStorage = useCallback((groupId, newChoice) => { const setChoiceSyncWithLocalStorage = useCallback((groupId, newChoice) => {
try { createStorageSlot(`${TAB_CHOICE_PREFIX}${groupId}`).set(newChoice);
localStorage.setItem(`${TAB_CHOICE_PREFIX}${groupId}`, newChoice);
} catch (err) {
console.error(err);
}
}, []); }, []);
useEffect(() => { useEffect(() => {
try { try {
const localStorageChoices = {}; const localStorageChoices = {};
for (let i = 0; i < localStorage.length; i += 1) { for (const storageKey of listStorageKeys()) {
const storageKey = localStorage.key(i) as string;
if (storageKey.startsWith(TAB_CHOICE_PREFIX)) { if (storageKey.startsWith(TAB_CHOICE_PREFIX)) {
const groupId = storageKey.substring(TAB_CHOICE_PREFIX.length); const groupId = storageKey.substring(TAB_CHOICE_PREFIX.length);
localStorageChoices[groupId] = localStorage.getItem(storageKey); localStorageChoices[groupId] = createStorageSlot(storageKey).get();
} }
} }
setChoices(localStorageChoices); setChoices(localStorageChoices);

View file

@ -9,7 +9,9 @@ import {useState, useCallback, useEffect} from 'react';
import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment'; import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
import type {useThemeReturns} from '@theme/hooks/useTheme'; import type {useThemeReturns} from '@theme/hooks/useTheme';
import {useThemeConfig} from '@docusaurus/theme-common'; import {useThemeConfig, createStorageSlot} from '@docusaurus/theme-common';
const ThemeStorage = createStorageSlot('theme');
const themes = { const themes = {
light: 'light', light: 'light',
@ -31,11 +33,7 @@ const getInitialTheme = (defaultMode: Themes | undefined): Themes => {
}; };
const storeTheme = (newTheme: Themes) => { const storeTheme = (newTheme: Themes) => {
try { createStorageSlot('theme').set(coerceToTheme(newTheme));
localStorage.setItem('theme', coerceToTheme(newTheme));
} catch (err) {
console.error(err);
}
}; };
const useTheme = (): useThemeReturns => { const useTheme = (): useThemeReturns => {
@ -63,9 +61,9 @@ const useTheme = (): useThemeReturns => {
} }
try { try {
const localStorageTheme = localStorage.getItem('theme'); const storedTheme = ThemeStorage.get();
if (localStorageTheme !== null) { if (storedTheme !== null) {
setTheme(coerceToTheme(localStorageTheme)); setTheme(coerceToTheme(storedTheme));
} }
} catch (err) { } catch (err) {
console.error(err); console.error(err);

View file

@ -16,6 +16,8 @@ export {
FooterLinkItem, FooterLinkItem,
} from './utils/useThemeConfig'; } from './utils/useThemeConfig';
export {createStorageSlot, listStorageKeys} from './utils/storageUtils';
export {useAlternatePageUtils} from './utils/useAlternatePageUtils'; export {useAlternatePageUtils} from './utils/useAlternatePageUtils';
export {docVersionSearchTag, DEFAULT_SEARCH_TAG} from './utils/searchUtils'; export {docVersionSearchTag, DEFAULT_SEARCH_TAG} from './utils/searchUtils';

View file

@ -5,6 +5,7 @@
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
*/ */
import {createStorageSlot} from '../storageUtils';
import {DocsVersionPersistence} from '../useThemeConfig'; import {DocsVersionPersistence} from '../useThemeConfig';
const storageKey = (pluginId: string) => `docs-preferred-version-${pluginId}`; const storageKey = (pluginId: string) => `docs-preferred-version-${pluginId}`;
@ -15,30 +16,18 @@ const DocsPreferredVersionStorage = {
persistence: DocsVersionPersistence, persistence: DocsVersionPersistence,
versionName: string, versionName: string,
): void => { ): void => {
if (persistence === 'none') { createStorageSlot(storageKey(pluginId), {persistence}).set(versionName);
// noop
} else {
window.localStorage.setItem(storageKey(pluginId), versionName);
}
}, },
read: ( read: (
pluginId: string, pluginId: string,
persistence: DocsVersionPersistence, persistence: DocsVersionPersistence,
): string | null => { ): string | null => {
if (persistence === 'none') { return createStorageSlot(storageKey(pluginId), {persistence}).get();
return null;
} else {
return window.localStorage.getItem(storageKey(pluginId));
}
}, },
clear: (pluginId: string, persistence: DocsVersionPersistence): void => { clear: (pluginId: string, persistence: DocsVersionPersistence): void => {
if (persistence === 'none') { createStorageSlot(storageKey(pluginId), {persistence}).del();
// noop
} else {
window.localStorage.removeItem(storageKey(pluginId));
}
}, },
}; };

View file

@ -0,0 +1,121 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
const StorageTypes = ['localStorage', 'sessionStorage', 'none'] as const;
export type StorageType = typeof StorageTypes[number];
const DefaultStorageType: StorageType = 'localStorage';
// Will return null browser storage is unavailable (like running Docusaurus in iframe)
// See https://github.com/facebook/docusaurus/pull/4501
function getBrowserStorage(
storageType: StorageType = DefaultStorageType,
): Storage | null {
if (typeof window === 'undefined') {
throw new Error(
'Browser storage is not available on NodeJS / Docusaurus SSR process',
);
}
if (storageType === 'none') {
return null;
} else {
try {
return window[storageType];
} catch (e) {
logOnceBrowserStorageNotAvailableWarning(e);
return null;
}
}
}
/**
* Poor man's memoization to avoid logging multiple times the same warning
* Sometimes, localStorage/sessionStorage is unavailable due to browser policies
*/
let hasLoggedBrowserStorageNotAvailableWarning = false;
function logOnceBrowserStorageNotAvailableWarning(error: Error) {
if (!hasLoggedBrowserStorageNotAvailableWarning) {
console.warn(
`Docusaurus browser storage is not available.
Possible reasons: running Docusaurus in an Iframe, in an Incognito browser session, or using too strict browser privacy settings.`,
error,
);
hasLoggedBrowserStorageNotAvailableWarning = true;
}
}
// Convenient storage interface for a single storage key
export interface StorageSlot {
get: () => string | null;
set: (value: string) => void;
del: () => void;
}
const NoopStorageSlot: StorageSlot = {
get: () => null,
set: () => {},
del: () => {},
};
// Fail-fast, as storage APIs should not be used during the SSR process
function createServerStorageSlot(key: string): StorageSlot {
function throwError(): never {
throw new Error(`Illegal storage API usage for storage key=${key}.
Docusaurus storage APIs are not supposed to be called on the server-rendering process.
Please only call storage APIs in effects and event handlers.`);
}
return {
get: throwError,
set: throwError,
del: throwError,
};
}
/**
* Creates an object for accessing a particular key in localStorage.
*/
export const createStorageSlot = (
key: string,
options?: {persistence?: StorageType},
): StorageSlot => {
if (typeof window === 'undefined') {
return createServerStorageSlot(key);
}
const browserStorage = getBrowserStorage(options?.persistence);
if (browserStorage === null) {
return NoopStorageSlot;
}
return {
get: () => browserStorage.getItem(key),
set: (value) => browserStorage.setItem(key, value),
del: () => browserStorage.removeItem(key),
};
};
/**
* Returns a list of all the keys currently stored in browser storage
* or an empty list if browser storage can't be accessed.
*/
export function listStorageKeys(
storageType: StorageType = DefaultStorageType,
): string[] {
const browserStorage = getBrowserStorage(storageType);
if (!browserStorage) {
return [];
}
const keys: string[] = [];
for (let i = 0; i < browserStorage.length; i += 1) {
const key = browserStorage.key(i);
if (key !== null) {
keys.push(key);
}
}
return keys;
}