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/preset-env": "^7.12.16",
"@docusaurus/core": "2.0.0-alpha.72",
"@docusaurus/theme-common": "2.0.0-alpha.72",
"@docusaurus/utils-validation": "2.0.0-alpha.72",
"babel-loader": "^8.2.2",
"clsx": "^1.1.1",

View file

@ -4,6 +4,7 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import {createStorageSlot} from '@docusaurus/theme-common';
// First: read the env variables (provided by Webpack)
/* eslint-disable prefer-destructuring */
@ -17,7 +18,10 @@ const PWA_DEBUG = process.env.PWA_DEBUG;
const debug = PWA_DEBUG; // shortcut
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() {
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/
*/
async function isAppInstalledEventFired() {
return localStorage.getItem(APP_INSTALLED_EVENT_FIRED_KEY) === 'true';
return AppInstalledEventFiredStorage.get() === 'true';
}
async function isAppInstalledRelatedApps() {
if ('getInstalledRelatedApps' in window.navigator) {
@ -229,10 +233,10 @@ function addLegacyAppInstalledEventsListeners() {
console.log('[Docusaurus-PWA][registerSw]: event appinstalled', event);
}
localStorage.setItem(APP_INSTALLED_EVENT_FIRED_KEY, 'true');
AppInstalledEventFiredStorage.set('true');
if (debug) {
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();
if (debug) {
console.log(
'[Docusaurus-PWA][registerSw]: localStorage.getItem(APP_INSTALLED_EVENT_FIRED_KEY)',
localStorage.getItem(APP_INSTALLED_EVENT_FIRED_KEY),
'[Docusaurus-PWA][registerSw]: AppInstalledEventFiredStorage.get()',
AppInstalledEventFiredStorage.get(),
);
}
if (localStorage.getItem(APP_INSTALLED_EVENT_FIRED_KEY)) {
localStorage.removeItem(APP_INSTALLED_EVENT_FIRED_KEY);
if (AppInstalledEventFiredStorage.get()) {
AppInstalledEventFiredStorage.del();
if (debug) {
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

View file

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

View file

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

View file

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

View file

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

View file

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

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;
}