diff --git a/src/components/thumbnail-card/__tests__/ThumbnailCardDetails.test.tsx b/src/components/thumbnail-card/__tests__/ThumbnailCardDetails.test.tsx index 8ffba561f0..067430e9b2 100644 --- a/src/components/thumbnail-card/__tests__/ThumbnailCardDetails.test.tsx +++ b/src/components/thumbnail-card/__tests__/ThumbnailCardDetails.test.tsx @@ -12,7 +12,7 @@ jest.mock('../../../utils/dom', () => ({ useIsContentOverflowed: jest.fn() })); describe('components/thumbnail-card/ThumbnailCardDetails', () => { beforeEach(() => { - libDom.useIsContentOverflowed.mockReturnValue(false); + (libDom.useIsContentOverflowed as jest.Mock).mockReturnValue(false); }); test('should render', () => { @@ -44,7 +44,7 @@ describe('components/thumbnail-card/ThumbnailCardDetails', () => { }); test('should render a Tooltip if text is overflowed', async () => { - libDom.useIsContentOverflowed.mockReturnValue(true); + (libDom.useIsContentOverflowed as jest.Mock).mockReturnValue(true); renderComponent(); await userEvent.tab(); diff --git a/src/utils/Browser.js b/src/utils/Browser.js.flow similarity index 99% rename from src/utils/Browser.js rename to src/utils/Browser.js.flow index 122f1b9544..b0dd61203f 100644 --- a/src/utils/Browser.js +++ b/src/utils/Browser.js.flow @@ -11,7 +11,7 @@ class Browser { * Returns the user agent. * Helps in mocking out. * - * @return {String} navigator userAgent + * @return {string} navigator userAgent */ static getUserAgent(): string { return global.navigator.userAgent; diff --git a/src/utils/Browser.ts b/src/utils/Browser.ts new file mode 100644 index 0000000000..e436ae69b3 --- /dev/null +++ b/src/utils/Browser.ts @@ -0,0 +1,115 @@ +let isDashSupported: boolean | undefined; + +class Browser { + /** + * Returns the user agent. + * Helps in mocking out. + */ + static getUserAgent(): string { + return globalThis.navigator.userAgent; + } + + /** + * Returns whether browser is mobile, including tablets. + * + * We rely on user agent (UA) to avoid matching desktops with touchscreens. + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Browser_detection_using_the_user_agent#mobile_tablet_or_desktop + */ + static isMobile(): boolean { + const userAgent = Browser.getUserAgent(); + return ( + /iphone|ipad|ipod|android|blackberry|bb10|mini|windows\sce|palm/i.test(userAgent) || /Mobi/i.test(userAgent) + ); + } + + /** Returns whether browser is IE. */ + static isIE(): boolean { + return /Trident/i.test(Browser.getUserAgent()); + } + + /** Returns whether browser is Firefox. */ + static isFirefox(): boolean { + const userAgent = Browser.getUserAgent(); + return /Firefox/i.test(userAgent) && !/Seamonkey\//i.test(userAgent); + } + + /** Returns whether browser is Safari. */ + static isSafari(): boolean { + const userAgent = Browser.getUserAgent(); + return /AppleWebKit/i.test(userAgent) && !/Chrome\//i.test(userAgent); + } + + /** + * Returns whether browser is Mobile Safari. + * + * @see https://developer.chrome.com/docs/multidevice/user-agent/ + */ + static isMobileSafari(): boolean { + return Browser.isMobile() && Browser.isSafari() && !Browser.isMobileChromeOniOS(); + } + + /** + * Returns whether browser is Mobile Chrome on iOS. + * + * @see https://developer.chrome.com/docs/multidevice/user-agent/ + */ + static isMobileChromeOniOS(): boolean { + const userAgent = Browser.getUserAgent(); + return Browser.isMobile() && /AppleWebKit/i.test(userAgent) && /CriOS\//i.test(userAgent); + } + + /** + * Returns whether browser can download via HTML5. + * + * @see https://github.com/Modernizr/Modernizr/blob/master/feature-detects/a/download.js + */ + static canDownload(): boolean { + return ( + !Browser.isMobile() || + (!(window as Window & { externalHost?: unknown }).externalHost && 'download' in document.createElement('a')) + ); + } + + /** + * Checks the browser for Dash support using H264 high. + * Dash requires MediaSource extensions to exist and be applicable + * to the H264 container (since we use H264 and not webm) + */ + static canPlayDash(recheck: boolean = false): boolean { + if (typeof isDashSupported === 'undefined' || recheck) { + const mse = globalThis.MediaSource; + isDashSupported = + !!mse && + typeof mse.isTypeSupported === 'function' && + mse.isTypeSupported('video/mp4; codecs="avc1.64001E"'); + } + + return !!isDashSupported; + } + + /** + * Checks whether the browser has support for the Clipboard API. This new API supercedes + * the `execCommand`-based API and uses Promises for detecting whether it works or not. + * + * This check determines if the browser can support writing to the clipboard. + * @see https://www.w3.org/TR/clipboard-apis/#async-clipboard-api + * @see https://developer.mozilla.org/en-US/docs/Web/API/Clipboard + */ + static canWriteToClipboard(): boolean { + return !!globalThis.navigator.clipboard?.writeText; + } + + /** + * Checks whether the browser has support for the Clipboard API. This new API supercedes + * the `execCommand`-based API and uses Promises for detecting whether it works or not. + * + * This check determines if the browser can support reading from the clipboard. + * @see https://www.w3.org/TR/clipboard-apis/#async-clipboard-api + * @see https://developer.mozilla.org/en-US/docs/Web/API/Clipboard + */ + static canReadFromClipboard(): boolean { + return !!globalThis.navigator.clipboard?.readText; + } +} + +export default Browser; diff --git a/src/utils/Cache.js b/src/utils/Cache.js.flow similarity index 100% rename from src/utils/Cache.js rename to src/utils/Cache.js.flow diff --git a/src/utils/Cache.ts b/src/utils/Cache.ts new file mode 100644 index 0000000000..9ff788bc96 --- /dev/null +++ b/src/utils/Cache.ts @@ -0,0 +1,55 @@ +import merge from 'lodash/merge'; +import type { StringAnyMap } from '../common/types/core'; + +class Cache { + cache: StringAnyMap; + + constructor() { + this.cache = {}; + } + + /** Caches a simple object in memory. */ + set(key: string, value: unknown): void { + this.cache[key] = value; + } + + /** Merges cached values for objects. */ + merge(key: string, value: unknown): void { + if (this.has(key)) { + this.set(key, merge({}, this.get(key), value)); + } else { + throw new Error(`Key ${key} not in cache!`); + } + } + + /** Deletes object from in-memory cache. */ + unset(key: string): void { + delete this.cache[key]; + } + + /** Deletes all cached objects whose keys match the given prefix. */ + unsetAll(prefix: string): void { + Object.keys(this.cache).forEach((key: string) => { + if (key.startsWith(prefix)) { + delete this.cache[key]; + } + }); + } + + /** Checks if cache has provided key. */ + has(key: string): boolean { + return {}.hasOwnProperty.call(this.cache, key); + } + + /** Fetches a cached object from in-memory cache if available. */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + get(key: string): any { + if (this.has(key)) { + return this.cache[key]; + } + + return undefined; + } +} + +export default Cache; diff --git a/src/utils/LocalStore.js b/src/utils/LocalStore.js.flow similarity index 100% rename from src/utils/LocalStore.js rename to src/utils/LocalStore.js.flow diff --git a/src/utils/LocalStore.ts b/src/utils/LocalStore.ts new file mode 100644 index 0000000000..26be9d4a61 --- /dev/null +++ b/src/utils/LocalStore.ts @@ -0,0 +1,96 @@ +import Cache from './Cache'; +import type APICache from './Cache'; + +const KEY_PREFIX = 'localStore'; +const SERVICE_VERSION = '0'; + +class LocalStore { + memoryStore: APICache; + + localStorage: typeof localStorage; + + isLocalStorageAvailable: boolean; + + constructor() { + this.memoryStore = new Cache(); + try { + this.localStorage = window.localStorage; + this.isLocalStorageAvailable = this.canUseLocalStorage(); + } catch (e) { + this.isLocalStorageAvailable = false; + } + } + + /** Builds a key for the session store. */ + buildKey(key: string): string { + return `${KEY_PREFIX}/${SERVICE_VERSION}/${key}`; + } + + /** + * Test to see browser can use local storage. + * See http://stackoverflow.com/questions/14555347 + * Note that this will return false if we are actually hitting the maximum localStorage + * size (5MB / 2.5M chars) + */ + canUseLocalStorage(): boolean { + if (!this.localStorage) { + return false; + } + + try { + this.localStorage.setItem(this.buildKey('TestKey'), 'testValue'); + this.localStorage.removeItem(this.buildKey('TestKey')); + return true; + } catch (e) { + return false; + } + } + + /** Set an item. */ + setItem(key: string, value: unknown): void { + if (this.isLocalStorageAvailable) { + try { + this.localStorage.setItem(this.buildKey(key), JSON.stringify(value)); + } catch (e) { + // no-op + } + } else { + this.memoryStore.set(key, value); + } + } + + /** Get an item. */ + getItem(key: string): unknown { + if (this.isLocalStorageAvailable) { + try { + const item = this.localStorage.getItem(this.buildKey(key)); + if (!item) { + return null; + } + + return JSON.parse(item); + } catch (e) { + return null; + } + } else { + return this.memoryStore.get(key); + } + } + + /** Remove an item. */ + removeItem(key: string): void { + if (this.isLocalStorageAvailable) { + try { + this.localStorage.removeItem(this.buildKey(key)); + } catch (e) { + // no-op + } + + return; + } + + this.memoryStore.unset(key); + } +} + +export default LocalStore; diff --git a/src/utils/TokenService.js b/src/utils/TokenService.js.flow similarity index 100% rename from src/utils/TokenService.js rename to src/utils/TokenService.js.flow diff --git a/src/utils/TokenService.ts b/src/utils/TokenService.ts new file mode 100644 index 0000000000..63dd2ccf62 --- /dev/null +++ b/src/utils/TokenService.ts @@ -0,0 +1,108 @@ +import { TYPED_ID_FOLDER_PREFIX, TYPED_ID_FILE_PREFIX } from '../constants'; +import type { Token, TokenLiteral } from '../common/types/core'; + +const error = new Error( + 'Bad id or auth token. ID should be typed id like file_123 or folder_123! Token should be a string or function.', +); + +class TokenService { + /** + * Fetches a single token. The supplied value can be a literal token or a function + * that returns a promise resolving to a string, null, undefined, or a read/write pair. + */ + static async getToken(id: string, tokenOrTokenFunction?: Token): Promise { + // Make sure we are getting typed ids + // Tokens should either be null or undefined or string or functions + // Anything else is not supported and throw error + if ( + (tokenOrTokenFunction !== null && + tokenOrTokenFunction !== undefined && + typeof tokenOrTokenFunction !== 'string' && + typeof tokenOrTokenFunction !== 'function') || + (!id.startsWith(TYPED_ID_FOLDER_PREFIX) && !id.startsWith(TYPED_ID_FILE_PREFIX)) + ) { + throw error; + } + + // Token is a simple string or null or undefined + if (!tokenOrTokenFunction || typeof tokenOrTokenFunction === 'string') { + return tokenOrTokenFunction; + } + + // Token is a function which returns a promise. + // Promise on resolution returns a string/null/undefined token or token pair. + const token = await tokenOrTokenFunction(id); + if (!token || typeof token === 'string' || (typeof token === 'object' && (token.read || token.write))) { + return token; + } + + throw error; + } + + /** Gets a string read token; defaults to a simple token string when given a map. */ + static async getReadToken(id: string, tokenOrTokenFunction?: Token): Promise { + const token: TokenLiteral = await TokenService.getToken(id, tokenOrTokenFunction); + if (token && typeof token === 'object') { + return token.read; + } + + return token; + } + + /** Gets read tokens for one or more typed ids, returning an id-to-token map. */ + static async getReadTokens( + id: string | string[], + tokenOrTokenFunction: Token, + ): Promise> { + const ids: string[] = Array.isArray(id) ? id : [id]; + const promises: Array> = ids.map((typedId: string) => + TokenService.getReadToken(typedId, tokenOrTokenFunction), + ); + const tokens: Array = await Promise.all(promises); + const tokenMap: Record = {}; + tokens.forEach((token, index) => { + tokenMap[ids[index]] = token; + }); + + return Promise.resolve(tokenMap); + } + + /** Gets a string write token; falls back to read token or a simple token string. */ + static async getWriteToken(id: string, tokenOrTokenFunction?: Token): Promise { + const token: TokenLiteral = await TokenService.getToken(id, tokenOrTokenFunction); + if (token && typeof token === 'object') { + return token.write || token.read; + } + + return token; + } + + /** + * Invokes the token generator to cache tokens for multiple typed ids. + * Does not return tokens; intended for generator-side prefetch only. + */ + static async cacheTokens(ids: Array, tokenOrTokenFunction?: Token): Promise { + // Make sure we are getting typed ids + // Tokens should either be null or undefined or string or functions + // Anything else is not supported and throw error + if ( + (tokenOrTokenFunction !== null && + tokenOrTokenFunction !== undefined && + typeof tokenOrTokenFunction !== 'string' && + typeof tokenOrTokenFunction !== 'function') || + !ids.every(itemId => itemId.startsWith(TYPED_ID_FOLDER_PREFIX) || itemId.startsWith(TYPED_ID_FILE_PREFIX)) + ) { + throw error; + } + + // Only need to fetch and cache multiple tokens when the user supplied token was a + // token function. This function should internally cache the tokens for future use. + if (typeof tokenOrTokenFunction === 'function') { + await tokenOrTokenFunction(ids); + } + + return Promise.resolve(); + } +} + +export default TokenService; diff --git a/src/utils/__tests__/Browser.test.js b/src/utils/__tests__/Browser.test.ts similarity index 86% rename from src/utils/__tests__/Browser.test.js rename to src/utils/__tests__/Browser.test.ts index 0c3af79477..5c10787e43 100644 --- a/src/utils/__tests__/Browser.test.js +++ b/src/utils/__tests__/Browser.test.ts @@ -1,5 +1,8 @@ import browser from '../Browser'; +type WindowWithExternalHost = Window & { externalHost?: unknown }; +type TestNavigator = Omit & { clipboard?: unknown }; + describe('util/Browser/isMobile()', () => { test('should return false if not mobile', () => { browser.getUserAgent = jest.fn().mockReturnValueOnce('foobar'); @@ -104,22 +107,22 @@ describe('util/Browser/canDownload()', () => { test('should return false if browser is mobile and externalHost is present', () => { browser.isMobile = jest.fn().mockReturnValue(true); - window.externalHost = {}; + (window as WindowWithExternalHost).externalHost = {}; expect(browser.canDownload()).toBe(false); - window.externalHost = undefined; + (window as WindowWithExternalHost).externalHost = undefined; }); test("should return false if browser is mobile and doesn't support downloads", () => { browser.isMobile = jest.fn().mockReturnValue(true); - window.externalHost = undefined; - global.document.createElement = jest.fn().mockReturnValue({}); + (window as WindowWithExternalHost).externalHost = undefined; + document.createElement = jest.fn().mockReturnValue({}); expect(browser.canDownload()).toBe(false); }); test('should return true if browser is mobile and supports downloads', () => { browser.isMobile = jest.fn().mockReturnValue(true); - window.externalHost = undefined; - global.document.createElement = jest.fn().mockReturnValue({ download: true }); + (window as WindowWithExternalHost).externalHost = undefined; + document.createElement = jest.fn().mockReturnValue({ download: true }); expect(browser.canDownload()).toBe(true); }); }); @@ -129,12 +132,12 @@ describe('util/Browser/canPlayDash()', () => { expect(browser.canPlayDash()).toBeFalsy(); }); test('should return false when isTypeSupported is not a function', () => { - global.MediaSource = { isTypeSupported: 'string' }; + (globalThis as unknown as { MediaSource?: unknown }).MediaSource = { isTypeSupported: 'string' }; expect(browser.canPlayDash(true)).toBeFalsy(); }); test('should return true when h264 is supported', () => { const isTypeSupportedMock = jest.fn(); - global.MediaSource = { + (globalThis as unknown as { MediaSource?: unknown }).MediaSource = { isTypeSupported: isTypeSupportedMock.mockReturnValueOnce(true), }; @@ -146,7 +149,7 @@ describe('util/Browser/canPlayDash()', () => { describe('Browser clipboard API', () => { // @see https://caniuse.com/#search=clipboard afterEach(() => { - global.navigator.clipboard = undefined; + (navigator as TestNavigator).clipboard = undefined; }); test('should return false when clipboard is unavailable', () => { @@ -155,7 +158,7 @@ describe('Browser clipboard API', () => { }); test('should return false when clipboard is partially available', () => { - global.navigator.clipboard = { + (navigator as TestNavigator).clipboard = { read: jest.fn(), write: jest.fn(), }; @@ -165,7 +168,7 @@ describe('Browser clipboard API', () => { }); test('should return true when clipboard is fully available', () => { - global.navigator.clipboard = { + (navigator as TestNavigator).clipboard = { read: jest.fn(), write: jest.fn(), readText: jest.fn(), diff --git a/src/utils/__tests__/Cache.test.js b/src/utils/__tests__/Cache.test.ts similarity index 100% rename from src/utils/__tests__/Cache.test.js rename to src/utils/__tests__/Cache.test.ts diff --git a/src/utils/__tests__/LocalStore.test.js b/src/utils/__tests__/LocalStore.test.ts similarity index 96% rename from src/utils/__tests__/LocalStore.test.js rename to src/utils/__tests__/LocalStore.test.ts index a3d5989d14..687c6b5471 100644 --- a/src/utils/__tests__/LocalStore.test.js +++ b/src/utils/__tests__/LocalStore.test.ts @@ -20,9 +20,7 @@ describe('util/LocalStore', () => { }); beforeEach(() => { - localStorage.getItem.mockClear(); - localStorage.removeItem.mockClear(); - localStorage.setItem.mockClear(); + jest.clearAllMocks(); localStore = new LocalStore(); }); diff --git a/src/utils/__tests__/TokenService.test.js b/src/utils/__tests__/TokenService.test.ts similarity index 93% rename from src/utils/__tests__/TokenService.test.js rename to src/utils/__tests__/TokenService.test.ts index 7cf3207771..d29858ac7c 100644 --- a/src/utils/__tests__/TokenService.test.js +++ b/src/utils/__tests__/TokenService.test.ts @@ -1,4 +1,5 @@ import Tokenservice from '../TokenService'; +import type { Token } from '../../common/types/core'; const readWriteTokenGenerator = () => Promise.resolve({ read: 'read_token', write: 'write_token' }); const readTokenGenerator = () => Promise.resolve({ read: 'read_token' }); @@ -37,7 +38,7 @@ describe('util/Tokenservice', () => { expect(Tokenservice.getToken('123')).rejects.toThrow(/Bad id or auth token/)); test('should reject when not given proper token function', () => - expect(Tokenservice.getToken('file_123', {})).rejects.toThrow(/Bad id or auth token/)); + expect(Tokenservice.getToken('file_123', {} as unknown as Token)).rejects.toThrow(/Bad id or auth token/)); test('should reject when token generator returns junk', () => expect(Tokenservice.getToken('file_123', junkTokenGenerator)).rejects.toThrow(/Bad id or auth token/)); @@ -78,7 +79,9 @@ describe('util/Tokenservice', () => { expect(Tokenservice.getWriteToken('123')).rejects.toThrow(/Bad id or auth token/)); test('should reject when not given proper token function', () => - expect(Tokenservice.getWriteToken('file_123', {})).rejects.toThrow(/Bad id or auth token/)); + expect(Tokenservice.getWriteToken('file_123', {} as unknown as Token)).rejects.toThrow( + /Bad id or auth token/, + )); test('should reject when token generator returns junk', () => expect(Tokenservice.getWriteToken('file_123', junkTokenGenerator)).rejects.toThrow(/Bad id or auth token/)); @@ -119,7 +122,9 @@ describe('util/Tokenservice', () => { expect(Tokenservice.getReadToken('123')).rejects.toThrow(/Bad id or auth token/)); test('should reject when not given proper token function', () => - expect(Tokenservice.getReadToken('file_123', {})).rejects.toThrow(/Bad id or auth token/)); + expect(Tokenservice.getReadToken('file_123', {} as unknown as Token)).rejects.toThrow( + /Bad id or auth token/, + )); test('should reject when token generator returns junk', () => expect(Tokenservice.getReadToken('file_123', junkTokenGenerator)).rejects.toThrow(/Bad id or auth token/)); @@ -153,6 +158,8 @@ describe('util/Tokenservice', () => { expect(Tokenservice.cacheTokens(['123', 'folder_123'])).rejects.toThrow(/Bad id or auth token/)); test('should reject when not given proper token function', () => - expect(Tokenservice.cacheTokens(['file_123', 'folder_123'], {})).rejects.toThrow(/Bad id or auth token/)); + expect(Tokenservice.cacheTokens(['file_123', 'folder_123'], {} as unknown as Token)).rejects.toThrow( + /Bad id or auth token/, + )); }); }); diff --git a/src/utils/uploadsSHA1Worker.js b/src/utils/uploadsSHA1Worker.js index a7aad3177b..89fac52085 100644 --- a/src/utils/uploadsSHA1Worker.js +++ b/src/utils/uploadsSHA1Worker.js @@ -167,7 +167,7 @@ const createWorker = () => { // self inside a worker refers to a DedicatedWorkerGlobalScope // https://developer.mozilla.org/en-US/docs/Web/API/DedicatedWorkerGlobalScope - self.onmessage = (event) => { + self.onmessage = event => { const { data } = event; const { part, fileSize, partContents } = data; @@ -188,9 +188,9 @@ const createWorker = () => { type: 'partDone', part: data.part, duration: Date.now() - startTimestamp, - partContents + partContents, }, - [partContents] + [partContents], ); expectedOffset += part.size; if (part.offset + part.size === fileSize) { @@ -202,7 +202,7 @@ const createWorker = () => { type: 'error', name: err.name, message: err.message, - part + part, }; self.postMessage(message); } diff --git a/src/utils/validators.js b/src/utils/validators.js index f840a84c7d..f056bf8e2e 100644 --- a/src/utils/validators.js +++ b/src/utils/validators.js @@ -4,7 +4,8 @@ import tldsHapi from '@hapi/address/lib/tlds'; function hostnameValidator(value: string): boolean { // @see https://github.com/hapijs/joi/blame/3516cf0b995c9fe415634c4612c0ac2f8792f0b4/lib/types/string/index.js#L530 - const regex = /^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])$/; + const regex = + /^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])$/; return regex.test(value); } diff --git a/src/utils/webcrypto.js b/src/utils/webcrypto.js index ff3edefb5a..3c70fad2ee 100644 --- a/src/utils/webcrypto.js +++ b/src/utils/webcrypto.js @@ -1,10 +1,11 @@ -import sha1 from 'js-sha1'; /** * @flow * @file Wrapper to provide a consistent interface for the webcrypto API * @author Box */ +import sha1 from 'js-sha1'; + /** * Returns the correct crypto library based on browser implementation *