From ba6a16951d3f569c302a0d1b2e449d562bdcc954 Mon Sep 17 00:00:00 2001 From: roman Date: Fri, 21 Aug 2026 17:10:48 +0200 Subject: [PATCH] refactor(utils): migrate utils from Flow to TypeScript --- .../__tests__/ThumbnailCardDetails.test.tsx | 4 +- src/utils/{Browser.js => Browser.js.flow} | 2 +- src/utils/Browser.ts | 117 ++++ src/utils/{Cache.js => Cache.js.flow} | 0 src/utils/Cache.ts | 78 +++ .../{LocalStore.js => LocalStore.js.flow} | 0 src/utils/LocalStore.ts | 98 ++++ .../{TokenService.js => TokenService.js.flow} | 0 src/utils/TokenService.ts | 147 +++++ src/utils/{Xhr.js => Xhr.js.flow} | 0 src/utils/Xhr.ts | 511 ++++++++++++++++++ .../{performance.js => performance.ts} | 0 .../{Browser.test.js => Browser.test.ts} | 25 +- .../{Cache.test.js => Cache.test.ts} | 0 ...{LocalStore.test.js => LocalStore.test.ts} | 4 +- ...enService.test.js => TokenService.test.ts} | 15 +- .../__tests__/{Xhr.test.js => Xhr.test.ts} | 7 +- ....test.js.snap => createTheme.test.ts.snap} | 0 .../{base64.test.js => base64.test.ts} | 0 ...reateTheme.test.js => createTheme.test.ts} | 1 + .../__tests__/{dom.test.js => dom.test.ts} | 25 +- .../__tests__/{env.test.js => env.test.ts} | 0 .../{error.test.js => error.test.ts} | 0 .../{fields.test.js => fields.test.ts} | 0 .../__tests__/{file.test.js => file.test.ts} | 5 +- .../{flatten.test.js => flatten.test.ts} | 20 +- .../{function.test.js => function.test.ts} | 4 +- ...uzzySearch.test.js => fuzzySearch.test.ts} | 0 ...etFileSize.test.js => getFileSize.test.ts} | 0 .../{iframe.test.js => iframe.test.ts} | 0 .../__tests__/{keys.test.js => keys.test.ts} | 0 .../{parseCSV.test.js => parseCSV.test.ts} | 0 ...arseEmails.test.js => parseEmails.test.ts} | 0 ...ativeTime.test.js => relativeTime.test.ts} | 0 .../{sorter.test.js => sorter.test.ts} | 27 +- .../{timestamp.test.js => timestamp.test.ts} | 8 +- .../{uploads.test.js => uploads.test.ts} | 10 +- ...{validators.test.js => validators.test.ts} | 2 - .../{webcrypto.test.js => webcrypto.test.ts} | 83 +-- src/utils/{base64.js => base64.js.flow} | 0 src/utils/base64.ts | 18 + .../{comparator.js => comparator.js.flow} | 0 src/utils/comparator.ts | 73 +++ .../{createTheme.js => createTheme.js.flow} | 0 src/utils/createTheme.ts | 193 +++++++ src/utils/{dom.js => dom.js.flow} | 0 src/utils/dom.ts | 148 +++++ .../{domPolyfill.js => domPolyfill.js.flow} | 0 src/utils/domPolyfill.ts | 30 + src/utils/{download.js => download.js.flow} | 6 +- src/utils/download.ts | 62 +++ src/utils/{env.js => env.js.flow} | 0 src/utils/env.ts | 4 + src/utils/{error.js => error.js.flow} | 0 src/utils/error.ts | 51 ++ src/utils/{fields.js => fields.js.flow} | 0 src/utils/fields.ts | 317 +++++++++++ src/utils/{file.js => file.js.flow} | 2 +- src/utils/file.ts | 58 ++ src/utils/{flatten.js => flatten.js.flow} | 0 src/utils/flatten.ts | 55 ++ src/utils/{function.js => function.js.flow} | 0 src/utils/function.ts | 53 ++ .../{fuzzySearch.js => fuzzySearch.js.flow} | 0 src/utils/fuzzySearch.ts | 54 ++ .../{getFileSize.js => getFileSize.js.flow} | 0 src/utils/getFileSize.ts | 31 ++ src/utils/{hex.js => hex.js.flow} | 0 src/utils/hex.ts | 14 + src/utils/{iframe.js => iframe.js.flow} | 0 src/utils/iframe.ts | 37 ++ src/utils/{keys.js => keys.js.flow} | 0 src/utils/keys.ts | 85 +++ src/utils/{parseCSV.js => parseCSV.js.flow} | 0 src/utils/parseCSV.ts | 43 ++ .../{parseEmails.js => parseEmails.js.flow} | 0 src/utils/parseEmails.ts | 53 ++ .../{performance.js => performance.js.flow} | 0 src/utils/performance.ts | 7 + .../{relativeTime.js => relativeTime.js.flow} | 0 src/utils/relativeTime.ts | 30 + src/utils/{sleep.js => sleep.js.flow} | 0 src/utils/sleep.ts | 2 + src/utils/{sorter.js => sorter.js.flow} | 0 src/utils/sorter.ts | 77 +++ src/utils/{storybook.js => storybook.js.flow} | 0 src/utils/storybook.ts | 18 + src/utils/{uploads.js => uploads.js.flow} | 0 src/utils/uploads.ts | 303 +++++++++++ ...HA1Worker.js => uploadsSHA1Worker.js.flow} | 0 src/utils/uploadsSHA1Worker.ts | 240 ++++++++ src/utils/{url.js => url.js.flow} | 0 src/utils/url.ts | 34 ++ .../{validators.js => validators.js.flow} | 0 src/utils/validators.ts | 46 ++ src/utils/{webcrypto.js => webcrypto.js.flow} | 0 src/utils/webcrypto.ts | 63 +++ 97 files changed, 3298 insertions(+), 102 deletions(-) rename src/utils/{Browser.js => Browser.js.flow} (99%) create mode 100644 src/utils/Browser.ts rename src/utils/{Cache.js => Cache.js.flow} (100%) create mode 100644 src/utils/Cache.ts rename src/utils/{LocalStore.js => LocalStore.js.flow} (100%) create mode 100644 src/utils/LocalStore.ts rename src/utils/{TokenService.js => TokenService.js.flow} (100%) create mode 100644 src/utils/TokenService.ts rename src/utils/{Xhr.js => Xhr.js.flow} (100%) create mode 100644 src/utils/Xhr.ts rename src/utils/__mocks__/{performance.js => performance.ts} (100%) rename src/utils/__tests__/{Browser.test.js => Browser.test.ts} (86%) rename src/utils/__tests__/{Cache.test.js => Cache.test.ts} (100%) rename src/utils/__tests__/{LocalStore.test.js => LocalStore.test.ts} (96%) rename src/utils/__tests__/{TokenService.test.js => TokenService.test.ts} (93%) rename src/utils/__tests__/{Xhr.test.js => Xhr.test.ts} (98%) rename src/utils/__tests__/__snapshots__/{createTheme.test.js.snap => createTheme.test.ts.snap} (100%) rename src/utils/__tests__/{base64.test.js => base64.test.ts} (100%) rename src/utils/__tests__/{createTheme.test.js => createTheme.test.ts} (97%) rename src/utils/__tests__/{dom.test.js => dom.test.ts} (81%) rename src/utils/__tests__/{env.test.js => env.test.ts} (100%) rename src/utils/__tests__/{error.test.js => error.test.ts} (100%) rename src/utils/__tests__/{fields.test.js => fields.test.ts} (100%) rename src/utils/__tests__/{file.test.js => file.test.ts} (92%) rename src/utils/__tests__/{flatten.test.js => flatten.test.ts} (75%) rename src/utils/__tests__/{function.test.js => function.test.ts} (95%) rename src/utils/__tests__/{fuzzySearch.test.js => fuzzySearch.test.ts} (100%) rename src/utils/__tests__/{getFileSize.test.js => getFileSize.test.ts} (100%) rename src/utils/__tests__/{iframe.test.js => iframe.test.ts} (100%) rename src/utils/__tests__/{keys.test.js => keys.test.ts} (100%) rename src/utils/__tests__/{parseCSV.test.js => parseCSV.test.ts} (100%) rename src/utils/__tests__/{parseEmails.test.js => parseEmails.test.ts} (100%) rename src/utils/__tests__/{relativeTime.test.js => relativeTime.test.ts} (100%) rename src/utils/__tests__/{sorter.test.js => sorter.test.ts} (91%) rename src/utils/__tests__/{timestamp.test.js => timestamp.test.ts} (96%) rename src/utils/__tests__/{uploads.test.js => uploads.test.ts} (98%) rename src/utils/__tests__/{validators.test.js => validators.test.ts} (99%) rename src/utils/__tests__/{webcrypto.test.js => webcrypto.test.ts} (54%) rename src/utils/{base64.js => base64.js.flow} (100%) create mode 100644 src/utils/base64.ts rename src/utils/{comparator.js => comparator.js.flow} (100%) create mode 100644 src/utils/comparator.ts rename src/utils/{createTheme.js => createTheme.js.flow} (100%) create mode 100644 src/utils/createTheme.ts rename src/utils/{dom.js => dom.js.flow} (100%) create mode 100644 src/utils/dom.ts rename src/utils/{domPolyfill.js => domPolyfill.js.flow} (100%) create mode 100644 src/utils/domPolyfill.ts rename src/utils/{download.js => download.js.flow} (90%) create mode 100644 src/utils/download.ts rename src/utils/{env.js => env.js.flow} (100%) create mode 100644 src/utils/env.ts rename src/utils/{error.js => error.js.flow} (100%) create mode 100644 src/utils/error.ts rename src/utils/{fields.js => fields.js.flow} (100%) create mode 100644 src/utils/fields.ts rename src/utils/{file.js => file.js.flow} (97%) create mode 100644 src/utils/file.ts rename src/utils/{flatten.js => flatten.js.flow} (100%) create mode 100644 src/utils/flatten.ts rename src/utils/{function.js => function.js.flow} (100%) create mode 100644 src/utils/function.ts rename src/utils/{fuzzySearch.js => fuzzySearch.js.flow} (100%) create mode 100644 src/utils/fuzzySearch.ts rename src/utils/{getFileSize.js => getFileSize.js.flow} (100%) create mode 100644 src/utils/getFileSize.ts rename src/utils/{hex.js => hex.js.flow} (100%) create mode 100644 src/utils/hex.ts rename src/utils/{iframe.js => iframe.js.flow} (100%) create mode 100644 src/utils/iframe.ts rename src/utils/{keys.js => keys.js.flow} (100%) create mode 100644 src/utils/keys.ts rename src/utils/{parseCSV.js => parseCSV.js.flow} (100%) create mode 100644 src/utils/parseCSV.ts rename src/utils/{parseEmails.js => parseEmails.js.flow} (100%) create mode 100644 src/utils/parseEmails.ts rename src/utils/{performance.js => performance.js.flow} (100%) create mode 100644 src/utils/performance.ts rename src/utils/{relativeTime.js => relativeTime.js.flow} (100%) create mode 100644 src/utils/relativeTime.ts rename src/utils/{sleep.js => sleep.js.flow} (100%) create mode 100644 src/utils/sleep.ts rename src/utils/{sorter.js => sorter.js.flow} (100%) create mode 100644 src/utils/sorter.ts rename src/utils/{storybook.js => storybook.js.flow} (100%) create mode 100644 src/utils/storybook.ts rename src/utils/{uploads.js => uploads.js.flow} (100%) create mode 100644 src/utils/uploads.ts rename src/utils/{uploadsSHA1Worker.js => uploadsSHA1Worker.js.flow} (100%) create mode 100644 src/utils/uploadsSHA1Worker.ts rename src/utils/{url.js => url.js.flow} (100%) create mode 100644 src/utils/url.ts rename src/utils/{validators.js => validators.js.flow} (100%) create mode 100644 src/utils/validators.ts rename src/utils/{webcrypto.js => webcrypto.js.flow} (100%) create mode 100644 src/utils/webcrypto.ts 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..435a3e6b3f --- /dev/null +++ b/src/utils/Browser.ts @@ -0,0 +1,117 @@ +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) + * + * @param recheck - recheck support + */ + 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..1846c0c56a --- /dev/null +++ b/src/utils/Cache.ts @@ -0,0 +1,78 @@ +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. + * + * @param {string} key The cache key + * @param {*} value The cache value + */ + set(key: string, value: unknown): void { + this.cache[key] = value; + } + + /** + * Merges cached values for objects. + * + * @param {string} key The cache key + * @param {*} value The cache value + */ + 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. + * + * @param {string} key The cache key + */ + unset(key: string): void { + delete this.cache[key]; + } + + /** + * Deletes all object from in-memory cache + * that match the key as prefix. + * + * @param {string} prefix The cache key 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. + * + * @param {string} key Key of cached object + */ + // 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..b638da8050 --- /dev/null +++ b/src/utils/LocalStore.ts @@ -0,0 +1,98 @@ +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) + * + * @private + */ + 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..d86e91bd74 --- /dev/null +++ b/src/utils/TokenService.ts @@ -0,0 +1,147 @@ +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 { + /** + * Function to fetch a single token. The user supplied token can either + * itself be a simple token or instead be a function that returns a promise. + * This promise then resolves to either a string/null/undefined token or + * a read/write token pair. + * + * @private + * @param {string} id - box item typed id + * @param {string} tokenOrTokenFunction - Optional token or token function + * @return {Promise} that resolves to a token + */ + 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. + * + * @public + * @param {string} id - box item typed id + * @param {Token} tokenOrTokenFunction - Optional token or token function + * @return {Promise} that resolves to a token + */ + 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. + * + * @public + * @param {string|string[]} id - box item typed id(s) + * @param {Token} tokenOrTokenFunction - Token to use or token generation function + * @return {Promise} Promise that resolves with 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. + * Defaults to either the read token or a simple token string. + * + * @public + * @param {string} id - box item typed id + * @param {string} tokenOrTokenFunction - Optional token or token function + * @return {Promise} that resolves to a token + */ + 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; + } + + /** + * Function to fetch and cache multiple tokens. The user supplied token can either + * itself be a simple token or instead be a function that returns a promise. + * This promise then resolves signifying requested tokens were cached. + * + * This function however does not return tokens as it is expected to only be used + * by the token generator to cache all tokens that may be needed in the future. + * + * @public + * @param {Array} idd - box item typed ids + * @param {string} tokenOrTokenFunction - Optional token or token function + * @return {Promise} that resolves to a token map + */ + 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/Xhr.js b/src/utils/Xhr.js.flow similarity index 100% rename from src/utils/Xhr.js rename to src/utils/Xhr.js.flow diff --git a/src/utils/Xhr.ts b/src/utils/Xhr.ts new file mode 100644 index 0000000000..c7058abfcf --- /dev/null +++ b/src/utils/Xhr.ts @@ -0,0 +1,511 @@ +import axios, { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse, CancelTokenSource } from 'axios'; +import getProp from 'lodash/get'; +import includes from 'lodash/includes'; +import lowerCase from 'lodash/lowerCase'; +import TokenService from './TokenService'; +import { + HEADER_ACCEPT, + HEADER_ACCEPT_LANGUAGE, + HEADER_CLIENT_NAME, + HEADER_CLIENT_VERSION, + HEADER_CONTENT_TYPE, + HTTP_GET, + HTTP_POST, + HTTP_PUT, + HTTP_DELETE, + HTTP_OPTIONS, + HTTP_HEAD, + HTTP_STATUS_CODE_RATE_LIMIT, +} from '../constants'; +import type { APIOptions, Method, PayloadType, RequestData } from '../common/types/api'; +import type { StringAnyMap, StringMap, Token } from '../common/types/core'; + +const DEFAULT_UPLOAD_TIMEOUT_MS = 120000; +const MAX_NUM_RETRIES = 3; +const RETRYABLE_HTTP_METHODS = [HTTP_GET, HTTP_OPTIONS, HTTP_HEAD].map(lowerCase); + +class Xhr { + id: string | null | undefined; + + axios: AxiosInstance; + + axiosSource: CancelTokenSource; + + clientName: string | null | undefined; + + language: string | null | undefined; + + token: Token; + + version: string | null | undefined; + + sharedLink: string | null | undefined; + + sharedLinkPassword: string | null | undefined; + + xhr: XMLHttpRequest; + + responseInterceptor: (response: AxiosResponse) => AxiosResponse | Promise; + + requestInterceptor: + | ((config: AxiosRequestConfig) => AxiosRequestConfig | Promise) + | null + | undefined; + + tokenService: TokenService; + + retryCount: number = 0; + + retryableStatusCodes: Array; + + retryTimeout: ReturnType | null | undefined; + + shouldRetry: boolean; + + /** + * [constructor] + * + * @param {Object} options + * @param {string} options.id - item id + * @param {string} options.clientName - Client Name + * @param {string|function} options.token - Auth token + * @param {string} [options.language] - Accept-Language header value + * @param {string} [options.sharedLink] - Shared link + * @param {string} [options.sharedLinkPassword] - Shared link password + * @param {string} [options.requestInterceptor] - Request interceptor + * @param {string} [options.responseInterceptor] - Response interceptor + * @param {number[]} [options.retryableStatusCodes] - Response codes to retry + * @param {boolean} [options.shouldRetry] - Should retry failed requests + * @return {Xhr} Cache instance + */ + constructor({ + id, + clientName, + language, + token, + version, + sharedLink, + sharedLinkPassword, + responseInterceptor, + requestInterceptor, + retryableStatusCodes = [HTTP_STATUS_CODE_RATE_LIMIT], + shouldRetry = true, + }: APIOptions = {}) { + this.clientName = clientName; + this.id = id; + this.language = language; + this.responseInterceptor = responseInterceptor || this.defaultResponseInterceptor; + this.retryableStatusCodes = retryableStatusCodes; + this.sharedLink = sharedLink; + this.sharedLinkPassword = sharedLinkPassword; + this.shouldRetry = shouldRetry; + this.token = token; + this.version = version; + + this.axios = axios.create(); + this.axiosSource = axios.CancelToken.source(); + this.axios.interceptors.response.use(this.responseInterceptor, this.errorInterceptor); + + if (typeof requestInterceptor === 'function') { + this.axios.interceptors.request.use(requestInterceptor); + } + } + + /** + * Default response interceptor which just returns the response + */ + defaultResponseInterceptor(response: AxiosResponse): AxiosResponse { + return response; + } + + /** + * Determines if a request should be retried + */ + shouldRetryRequest(error: AxiosError): boolean { + if (!this.shouldRetry || this.retryCount >= MAX_NUM_RETRIES) { + return false; + } + + const { response, request, config } = error; + // Retry if there is a network error (e.g. ECONNRESET) or rate limited + const status = getProp(response, 'status'); + const method = getProp(config, 'method'); + const isNetworkError = request && !response; + const isRateLimitError = status === HTTP_STATUS_CODE_RATE_LIMIT; + const isOtherRetryableError = + includes(this.retryableStatusCodes, status) && includes(RETRYABLE_HTTP_METHODS, method); + return isNetworkError || isRateLimitError || isOtherRetryableError; + } + + /** + * Calculate the exponential backoff time with randomized jitter. + * + * @param {number} numRetries Which retry number this one will be. Must be > 0 + * @returns {number} The number of milliseconds after which to retry + */ + getExponentialRetryTimeoutInMs(numRetries: number): number { + const randomizationMs = Math.ceil(Math.random() * 1000); + const exponentialMs = 2 ** (numRetries - 1) * 1000; + return exponentialMs + randomizationMs; + } + + /** + * Error interceptor that wraps the passed in responseInterceptor + * + * @param {AxiosError} error - Error object from axios + * @return {Promise} rejected promise with error info + */ + errorInterceptor = (error: AxiosError): Promise => { + const shouldRetry = this.shouldRetryRequest(error); + if (shouldRetry) { + this.retryCount += 1; + const delay = this.getExponentialRetryTimeoutInMs(this.retryCount); + return new Promise((resolve, reject) => { + this.retryTimeout = setTimeout(() => { + this.axios(error.config).then(resolve, reject); + }, delay); + }); + } + + const errorObject = getProp(error, 'response.data') || error; // In the case of 401, response.data is empty so fall back to error + this.responseInterceptor(errorObject as AxiosResponse); + + return Promise.reject(error); + }; + + /** + * Utility to parse a URL. + * + * @param {string} url - Url to parse + */ + getParsedUrl(url: string): { + api: string; + hash: string; + host: string; + hostname: string; + origin: string; + pathname: string; + port: string; + protocol: string; + } { + const a = document.createElement('a'); + a.href = url; + return { + api: url.replace(`${a.origin}/2.0`, ''), + host: a.host, + hostname: a.hostname, + pathname: a.pathname, + origin: a.origin, + protocol: a.protocol, + hash: a.hash, + port: a.port, + }; + } + + /** + * Builds a list of required XHR headers. + * + * @param {string} [id] - Optional box item id + * @param {Object} [args] - Optional existing headers + */ + async getHeaders(id?: string, args: StringMap = {}): Promise { + const headers: StringMap = { + Accept: 'application/json', + [HEADER_CONTENT_TYPE]: 'application/json', + ...args, + }; + + if (this.language && !headers[HEADER_ACCEPT_LANGUAGE]) { + headers[HEADER_ACCEPT_LANGUAGE] = this.language; + } + + if (this.sharedLink) { + headers.BoxApi = `shared_link=${this.sharedLink}`; + + if (this.sharedLinkPassword) { + headers.BoxApi = `${headers.BoxApi}&shared_link_password=${this.sharedLinkPassword}`; + } + } + + if (this.clientName) { + headers[HEADER_CLIENT_NAME] = this.clientName; + } + + if (this.version) { + headers[HEADER_CLIENT_VERSION] = this.version; + } + + // If id is passed in, use that, otherwise default to this.id + const itemId = id || this.id || ''; + const token = await TokenService.getWriteToken(itemId, this.token); + if (token) { + // Only add a token when there was one found + headers.Authorization = `Bearer ${token}`; + } + + return headers; + } + + /** + * HTTP GETs a URL + * + * @param {string} id - Box item id + * @param {string} url - The URL to fetch + * @param {Object} [headers] - Key-value map of headers + * @param {Object} [params] - Key-value map of querystring params + * @return {Promise} - HTTP response + */ + get({ + url, + id, + params = {}, + headers = {}, + }: { + headers?: StringMap; + id?: string; + params?: StringAnyMap; + url: string; + }): Promise { + return this.getHeaders(id, headers).then(hdrs => + this.axios.get(url, { + cancelToken: this.axiosSource.token, + params, + headers: hdrs, + parsedUrl: this.getParsedUrl(url), + } as AxiosRequestConfig), + ); + } + + /** + * HTTP POSTs a URL with JSON data + * + * @param {string} id - Box item id + * @param {string} url - The URL to fetch + * @param {Object} data - JS Object representation of JSON data to send + * @param {Object} params - Optional query params for the request + * @param {Object} [headers] - Key-value map of headers + * @param {string} [method] - xhr type + * @return {Promise} - HTTP response + */ + post({ + url, + id, + data, + params, + headers = {}, + method = HTTP_POST, + }: { + data: PayloadType; + headers?: StringMap; + id?: string; + method?: Method; + params?: StringAnyMap; + url: string; + }): Promise { + return this.getHeaders(id, headers).then(hdrs => + this.axios({ + url, + data, + params, + method, + parsedUrl: this.getParsedUrl(url), + headers: hdrs, + } as AxiosRequestConfig), + ); + } + + /** + * HTTP PUTs a URL with JSON data + * + * @param {string} id - Box item id + * @param {string} url - The URL to fetch + * @param {Object} data - JS Object representation of JSON data to send + * @param {Object} params - Optional query params for the request + * @param {Object} [headers] - Key-value map of headers + * @return {Promise} - HTTP response + */ + put({ url, id, data, params, headers = {} }: RequestData): Promise { + return this.post({ id, url, data, params, headers, method: HTTP_PUT }); + } + + /** + * HTTP DELETEs a URL with JSON data + * + * @param {string} id - Box item id + * @param {string} url - The URL to fetch + * @param {Object} data - JS Object representation of JSON data to send + * @param {Object} [headers] - Key-value map of headers + * @return {Promise} - HTTP response + */ + delete({ + url, + id, + data = {}, + headers = {}, + }: { + data?: StringAnyMap; + headers?: StringMap; + id?: string; + url: string; + }): Promise { + return this.post({ id, url, data, headers, method: HTTP_DELETE }); + } + + /** + * HTTP OPTIONs a URL with JSON data. + * + * @param {string} id - Box item id + * @param {string} url - The URL to post to + * @param {Object} data - The non-file post data that should accompany the post + * @param {Object} [headers] - Key-value map of headers + * @param {Function} successHandler - Load success handler + * @param {Function} errorHandler - Error handler + */ + options({ + id, + url, + data, + headers = {}, + successHandler, + errorHandler, + }: { + data: StringAnyMap; + errorHandler: (error: unknown) => void; + headers?: StringMap; + id?: string; + progressHandler?: (event: ProgressEvent) => void; + successHandler: (response: AxiosResponse) => void; + url: string; + }): Promise { + return this.getHeaders(id, headers) + .then(hdrs => + this.axios({ + url, + data, + method: HTTP_OPTIONS, + headers: hdrs, + }) + .then(successHandler) + .catch(errorHandler), + ) + .catch(errorHandler); + } + + /** + * HTTP POST or PUT a URL with File data. Uses native XHR for progress event. + * + * @param {string} id - Box item id + * @param {string} url - The URL to post to + * @param {Object} [data] - File data and attributes + * @param {Object} [headers] - Key-value map of headers + * @param {string} [method] - XHR method, supports 'POST' and 'PUT' + * @param {Function} successHandler - Load success handler + * @param {Function} errorHandler - Error handler + * @param {Function} progressHandler - Progress handler + * @param {boolean} [withIdleTimeout] - enable idle timeout + * @param {number} [idleTimeoutDuration] - idle timeout duration + * @param {Function} [idleTimeoutHandler] + */ + uploadFile({ + id, + url, + data, + headers = {}, + method = HTTP_POST, + successHandler, + errorHandler, + progressHandler, + withIdleTimeout = false, + idleTimeoutDuration = DEFAULT_UPLOAD_TIMEOUT_MS, + idleTimeoutHandler, + }: { + data?: Blob | StringAnyMap | null; + errorHandler: (error: unknown) => void; + headers?: StringMap; + id?: string; + idleTimeoutDuration?: number; + idleTimeoutHandler?: () => void; + method?: Method; + progressHandler: (event: ProgressEvent) => void; + successHandler: (response: AxiosResponse) => void; + url: string; + withIdleTimeout?: boolean; + }): Promise { + return this.getHeaders(id, headers) + .then(hdrs => { + let idleTimeout; + let progressHandlerToUse = progressHandler; + + if (withIdleTimeout) { + // Func that aborts upload and executes timeout callback + const idleTimeoutFunc = () => { + this.abort(); + + if (idleTimeoutHandler) { + idleTimeoutHandler(); + } + }; + + idleTimeout = setTimeout(idleTimeoutFunc, idleTimeoutDuration); + + // Progress handler that aborts upload if there has been no progress for >= timeoutMs + progressHandlerToUse = event => { + clearTimeout(idleTimeout); + idleTimeout = setTimeout(idleTimeoutFunc, idleTimeoutDuration); + progressHandler(event); + }; + } + this.axios({ + url, + data, + transformRequest: (reqData, reqHeaders) => { + // Remove Accept & Content-Type added by getHeaders() + delete reqHeaders[HEADER_ACCEPT]; + delete reqHeaders[HEADER_CONTENT_TYPE]; + + if (headers[HEADER_CONTENT_TYPE]) { + reqHeaders[HEADER_CONTENT_TYPE] = headers[HEADER_CONTENT_TYPE]; + } + + // Convert to FormData if needed + if (reqData && !(reqData instanceof Blob) && reqData.attributes) { + const formData = new FormData(); + Object.keys(reqData).forEach(key => { + formData.append(key, reqData[key]); + }); + + return formData; + } + + return reqData; + }, + method, + headers: hdrs, + onUploadProgress: progressHandlerToUse, + cancelToken: this.axiosSource.token, + } as AxiosRequestConfig) + .then(response => { + clearTimeout(idleTimeout); + successHandler(response); + }) + .catch(error => { + clearTimeout(idleTimeout); + errorHandler(error); + }); + }) + .catch(errorHandler); + } + + /** Aborts an axios request. */ + abort(): void { + if (this.retryTimeout) { + clearTimeout(this.retryTimeout); + } + if (this.axiosSource) { + this.axiosSource.cancel(); + this.axiosSource = axios.CancelToken.source(); + } + } +} + +export default Xhr; diff --git a/src/utils/__mocks__/performance.js b/src/utils/__mocks__/performance.ts similarity index 100% rename from src/utils/__mocks__/performance.js rename to src/utils/__mocks__/performance.ts 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/__tests__/Xhr.test.js b/src/utils/__tests__/Xhr.test.ts similarity index 98% rename from src/utils/__tests__/Xhr.test.js rename to src/utils/__tests__/Xhr.test.ts index f44a12b8ce..02e5bc5703 100644 --- a/src/utils/__tests__/Xhr.test.js +++ b/src/utils/__tests__/Xhr.test.ts @@ -3,7 +3,7 @@ import TokenService from '../TokenService'; import Xhr from '../Xhr'; jest.mock('../TokenService'); -TokenService.getReadToken.mockImplementation(() => Promise.resolve(`${Math.random()}`)); +(TokenService.getReadToken as jest.Mock).mockImplementation(() => Promise.resolve(`${Math.random()}`)); describe('util/Xhr', () => { let xhrInstance; @@ -375,10 +375,7 @@ describe('util/Xhr', () => { }, }; xhrInstance.axios = jest.fn().mockImplementation(() => { - xhrInstance - .errorInterceptor(error) - .then(() => {}) - .catch(() => {}); + xhrInstance.errorInterceptor(error).then(noop).catch(noop); return Promise.resolve(); }); // first time return true, then false diff --git a/src/utils/__tests__/__snapshots__/createTheme.test.js.snap b/src/utils/__tests__/__snapshots__/createTheme.test.ts.snap similarity index 100% rename from src/utils/__tests__/__snapshots__/createTheme.test.js.snap rename to src/utils/__tests__/__snapshots__/createTheme.test.ts.snap diff --git a/src/utils/__tests__/base64.test.js b/src/utils/__tests__/base64.test.ts similarity index 100% rename from src/utils/__tests__/base64.test.js rename to src/utils/__tests__/base64.test.ts diff --git a/src/utils/__tests__/createTheme.test.js b/src/utils/__tests__/createTheme.test.ts similarity index 97% rename from src/utils/__tests__/createTheme.test.js rename to src/utils/__tests__/createTheme.test.ts index 822a831aff..be5203f0b6 100644 --- a/src/utils/__tests__/createTheme.test.js +++ b/src/utils/__tests__/createTheme.test.ts @@ -57,6 +57,7 @@ describe('components/theme', () => { // Expected errors test('should generate no colors, missing colorKeys', () => { expect(() => { + // @ts-expect-error invalid color key createTheme({ ratio: 1 }); // no key colors }).toThrow(); }); diff --git a/src/utils/__tests__/dom.test.js b/src/utils/__tests__/dom.test.ts similarity index 81% rename from src/utils/__tests__/dom.test.js rename to src/utils/__tests__/dom.test.ts index 9cb13fc225..bd4d70d4d0 100644 --- a/src/utils/__tests__/dom.test.js +++ b/src/utils/__tests__/dom.test.ts @@ -1,4 +1,5 @@ import { renderHook } from '@testing-library/react'; +import * as React from 'react'; import scrollIntoViewIfNeeded from 'scroll-into-view-if-needed'; import { isActivateKey, isFocusableElement, isLeftClick, scrollIntoView, useIsContentOverflowed } from '../dom'; @@ -7,30 +8,30 @@ jest.mock('scroll-into-view-if-needed'); describe('util/dom', () => { describe('isActivateKey', () => { test('should return true for enter and space keys', () => { - expect(isActivateKey({ key: 'Enter' })).toBe(true); - expect(isActivateKey({ key: ' ' })).toBe(true); + expect(isActivateKey({ key: 'Enter' } as unknown as React.KeyboardEvent)).toBe(true); + expect(isActivateKey({ key: ' ' } as unknown as React.KeyboardEvent)).toBe(true); }); test('should return false for all other keys', () => { - expect(isActivateKey({ key: 'Ctrl' })).toBe(false); - expect(isActivateKey({ key: 'Tab' })).toBe(false); + expect(isActivateKey({ key: 'Ctrl' } as unknown as React.KeyboardEvent)).toBe(false); + expect(isActivateKey({ key: 'Tab' } as unknown as React.KeyboardEvent)).toBe(false); }); }); describe('isLeftClick', () => { test('should return true for unmodified left click events', () => { - expect(isLeftClick({ button: 0 })).toBe(true); + expect(isLeftClick({ button: 0 } as unknown as React.MouseEvent)).toBe(true); }); test('should return false for modified left click events', () => { - expect(isLeftClick({ button: 0, altKey: true })).toBe(false); - expect(isLeftClick({ button: 0, ctrlKey: true })).toBe(false); - expect(isLeftClick({ button: 0, metaKey: true })).toBe(false); - expect(isLeftClick({ button: 0, shiftKey: true })).toBe(false); + expect(isLeftClick({ button: 0, altKey: true } as unknown as React.MouseEvent)).toBe(false); + expect(isLeftClick({ button: 0, ctrlKey: true } as unknown as React.MouseEvent)).toBe(false); + expect(isLeftClick({ button: 0, metaKey: true } as unknown as React.MouseEvent)).toBe(false); + expect(isLeftClick({ button: 0, shiftKey: true } as unknown as React.MouseEvent)).toBe(false); }); test('should return false for unmodified right click events', () => { - expect(isLeftClick({ button: 1 })).toBe(false); + expect(isLeftClick({ button: 1 } as unknown as React.MouseEvent)).toBe(false); }); }); @@ -45,7 +46,7 @@ describe('util/dom', () => { }); test('should call scrollIntoViewIfNeeded when parent element is found', () => { - const itemEl = document.querySelector('.button'); + const itemEl = document.querySelector('.button') as HTMLElement | null; const parentEl = document.querySelector('.modal'); scrollIntoView(itemEl); expect(scrollIntoViewIfNeeded).toHaveBeenCalledWith(itemEl, { @@ -55,7 +56,7 @@ describe('util/dom', () => { }); test('should not call scrollIntoViewIfNeeded when parent element is evaluated as null', () => { - const itemEl = document.querySelector('.input'); + const itemEl = document.querySelector('.input') as HTMLElement | null; scrollIntoView(itemEl); expect(scrollIntoViewIfNeeded).not.toHaveBeenCalled(); }); diff --git a/src/utils/__tests__/env.test.js b/src/utils/__tests__/env.test.ts similarity index 100% rename from src/utils/__tests__/env.test.js rename to src/utils/__tests__/env.test.ts diff --git a/src/utils/__tests__/error.test.js b/src/utils/__tests__/error.test.ts similarity index 100% rename from src/utils/__tests__/error.test.js rename to src/utils/__tests__/error.test.ts diff --git a/src/utils/__tests__/fields.test.js b/src/utils/__tests__/fields.test.ts similarity index 100% rename from src/utils/__tests__/fields.test.js rename to src/utils/__tests__/fields.test.ts diff --git a/src/utils/__tests__/file.test.js b/src/utils/__tests__/file.test.ts similarity index 92% rename from src/utils/__tests__/file.test.js rename to src/utils/__tests__/file.test.ts index 7bc97b4833..7768461838 100644 --- a/src/utils/__tests__/file.test.js +++ b/src/utils/__tests__/file.test.ts @@ -42,11 +42,14 @@ describe('util/file', () => { ['filename.txt', 'txt'], ['filename.backup.mp4', 'mp4'], ['filename..temp.pdf', 'pdf'], - [{ name: 'test.pdf' }, ''], ['invalidfilenamepdf', ''], ])('should return extension of file correctly', (filename, extension) => { expect(getFileExtension(filename)).toBe(extension); }); + + test('should return empty string when filename is not a string', () => { + expect(getFileExtension({ name: 'test.pdf' } as unknown as string)).toBe(''); + }); }); describe('isGSuiteExtension()', () => { diff --git a/src/utils/__tests__/flatten.test.js b/src/utils/__tests__/flatten.test.ts similarity index 75% rename from src/utils/__tests__/flatten.test.js rename to src/utils/__tests__/flatten.test.ts index afa9792c80..2d9f8d2cdb 100644 --- a/src/utils/__tests__/flatten.test.js +++ b/src/utils/__tests__/flatten.test.ts @@ -21,6 +21,17 @@ const file = new FileAPI({ cache }); const folder = new FolderAPI({ cache }); const weblink = new WebLinkAPI({ cache }); +const getThrownError = (callback: () => unknown): Error => { + try { + callback(); + } catch (error) { + expect(error).toBeInstanceOf(Error); + return error as Error; + } + + throw new Error('Expected callback to throw'); +}; + describe('util/flatten', () => { test('should flatten the list and create new cache entries', () => { const items = flatten(list, folder, file, weblink); @@ -45,16 +56,19 @@ describe('util/flatten', () => { test('should throw with a bad type', () => { const badList = [{ id: '1', type: 'foo' }]; - expect(flatten.bind(flatten, badList, folder, file, weblink)).toThrow(Error, /Unknown Type/); + const error = getThrownError(flatten.bind(flatten, badList, folder, file, weblink)); + expect(error.message).toMatch(/Unknown Type/); }); test('should throw with a bad item when no id', () => { const badList = [{ type: 'foo' }]; - expect(flatten.bind(flatten, badList, folder, file, weblink)).toThrow(Error, /Bad box item/); + const error = getThrownError(flatten.bind(flatten, badList, folder, file, weblink)); + expect(error.message).toMatch(/Bad box item/); }); test('should throw with a bad item when no type', () => { const badList = [{ id: 'foo' }]; - expect(flatten.bind(flatten, badList, folder, file, weblink)).toThrow(Error, /Bad box item/); + const error = getThrownError(flatten.bind(flatten, badList, folder, file, weblink)); + expect(error.message).toMatch(/Bad box item/); }); }); diff --git a/src/utils/__tests__/function.test.js b/src/utils/__tests__/function.test.ts similarity index 95% rename from src/utils/__tests__/function.test.js rename to src/utils/__tests__/function.test.ts index 86ed85853b..fa0d46871d 100644 --- a/src/utils/__tests__/function.test.js +++ b/src/utils/__tests__/function.test.ts @@ -49,7 +49,7 @@ describe('util/function', () => { setTimeout(() => { promise.catch(sandbox.mock()); - expect(inner.callCount).to.equal(2); + expect(inner.callCount).toBe(2); }, 100); }); @@ -62,7 +62,7 @@ describe('util/function', () => { setTimeout(() => { promise.catch(sandbox.mock()); - expect(inner.callCount).to.equal(1); + expect(inner.callCount).toBe(1); }, 100); }); }); diff --git a/src/utils/__tests__/fuzzySearch.test.js b/src/utils/__tests__/fuzzySearch.test.ts similarity index 100% rename from src/utils/__tests__/fuzzySearch.test.js rename to src/utils/__tests__/fuzzySearch.test.ts diff --git a/src/utils/__tests__/getFileSize.test.js b/src/utils/__tests__/getFileSize.test.ts similarity index 100% rename from src/utils/__tests__/getFileSize.test.js rename to src/utils/__tests__/getFileSize.test.ts diff --git a/src/utils/__tests__/iframe.test.js b/src/utils/__tests__/iframe.test.ts similarity index 100% rename from src/utils/__tests__/iframe.test.js rename to src/utils/__tests__/iframe.test.ts diff --git a/src/utils/__tests__/keys.test.js b/src/utils/__tests__/keys.test.ts similarity index 100% rename from src/utils/__tests__/keys.test.js rename to src/utils/__tests__/keys.test.ts diff --git a/src/utils/__tests__/parseCSV.test.js b/src/utils/__tests__/parseCSV.test.ts similarity index 100% rename from src/utils/__tests__/parseCSV.test.js rename to src/utils/__tests__/parseCSV.test.ts diff --git a/src/utils/__tests__/parseEmails.test.js b/src/utils/__tests__/parseEmails.test.ts similarity index 100% rename from src/utils/__tests__/parseEmails.test.js rename to src/utils/__tests__/parseEmails.test.ts diff --git a/src/utils/__tests__/relativeTime.test.js b/src/utils/__tests__/relativeTime.test.ts similarity index 100% rename from src/utils/__tests__/relativeTime.test.js rename to src/utils/__tests__/relativeTime.test.ts diff --git a/src/utils/__tests__/sorter.test.js b/src/utils/__tests__/sorter.test.ts similarity index 91% rename from src/utils/__tests__/sorter.test.js rename to src/utils/__tests__/sorter.test.ts index c21c3d31e6..e26a5255f8 100644 --- a/src/utils/__tests__/sorter.test.js +++ b/src/utils/__tests__/sorter.test.ts @@ -6,6 +6,17 @@ import { annotation as mockAnnotation } from '../../__mocks__/annotations'; let cache; let item; +const getThrownError = (callback: () => unknown): Error => { + try { + callback(); + } catch (error) { + expect(error).toBeInstanceOf(Error); + return error as Error; + } + + throw new Error('Expected callback to throw'); +}; + describe('util/sorter', () => { beforeEach(() => { item = { @@ -144,7 +155,7 @@ describe('util/sorter', () => { ]); }); - test('should sort with interacted date desc', () => { + test('should sort by interacted at with interacted date desc', () => { item.item_collection.entries = ['fo3', 'f1', 'f2', 'w1', 'w2', 'fo1', 'fo2', 'f3', 'w3']; item.item_collection.order = [{ by: 'name', direction: SORT_ASC }]; const sorted = sort(item, 'interacted_at', SORT_DESC, cache); @@ -217,7 +228,7 @@ describe('util/sorter', () => { expect(sorted.item_collection.entries).toEqual(['f7', 'f6']); }); - test('should sort with default name when name is missing', () => { + test('should sort by interacted at with default name when name is missing', () => { item.item_collection.entries = ['f7', 'f6']; item.item_collection.order = [{ by: 'name', direction: SORT_ASC }]; const sorted = sort(item, 'interacted_at', SORT_ASC, cache); @@ -246,23 +257,27 @@ describe('util/sorter', () => { test('should throw with a bad sortBy', () => { item.item_collection.entries = ['w1', 'w3', 'fo1', 'fo4', 'f1', 'w2', 'w4', 'f3', 'f2', 'fo2', 'fo3']; item.item_collection.order = [{ by: 'name', direction: SORT_DESC }]; - expect(sort.bind(sort, item, 'foobar', SORT_ASC, cache)).toThrow(Error, /sort field/); + const error = getThrownError(sort.bind(sort, item, 'foobar', SORT_ASC, cache)); + expect(error.message).toMatch(/sort field/); }); test('should throw with a bad type', () => { item.item_collection.entries = ['w1', 'w3', 'fo1', 'foo']; item.item_collection.order = [{ by: 'name', direction: SORT_DESC }]; - expect(sort.bind(sort, item, 'name', SORT_ASC, cache)).toThrow(Error, /sort comparator/); + const error = getThrownError(sort.bind(sort, item, 'name', SORT_ASC, cache)); + expect(error.message).toMatch(/sort comparator/); }); test('should throw with a bad item when no item_collection', () => { item.item_collection = null; - expect(sort.bind(sort, item, 'name', SORT_ASC, cache)).toThrow(Error, /Bad box item/); + const error = getThrownError(sort.bind(sort, item, 'name', SORT_ASC, cache)); + expect(error.message).toMatch(/Bad box item/); }); test('should throw with a bad item when no entries', () => { item.item_collection.entries = null; - expect(sort.bind(sort, item, 'name', SORT_ASC, cache)).toThrow(Error, /Bad box item/); + const error = getThrownError(sort.bind(sort, item, 'name', SORT_ASC, cache)); + expect(error.message).toMatch(/Bad box item/); }); describe('sortFeedItems()', () => { diff --git a/src/utils/__tests__/timestamp.test.js b/src/utils/__tests__/timestamp.test.ts similarity index 96% rename from src/utils/__tests__/timestamp.test.js rename to src/utils/__tests__/timestamp.test.ts index 85629e2134..f3b47c0a00 100644 --- a/src/utils/__tests__/timestamp.test.js +++ b/src/utils/__tests__/timestamp.test.ts @@ -61,10 +61,10 @@ describe('utils/timestamp', () => { }); test('should return 0 if the timestamp is not a number', () => { - expect(convertTimestampToSeconds('abc123def')).toBe(0); - expect(convertTimestampToSeconds('456xyz789')).toBe(0); - expect(convertTimestampToSeconds('')).toBe(0); - expect(convertTimestampToSeconds('abc')).toBe(0); + expect(convertTimestampToSeconds('abc123def' as unknown as number)).toBe(0); + expect(convertTimestampToSeconds('456xyz789' as unknown as number)).toBe(0); + expect(convertTimestampToSeconds('' as unknown as number)).toBe(0); + expect(convertTimestampToSeconds('abc' as unknown as number)).toBe(0); expect(convertTimestampToSeconds(undefined)).toBe(0); }); }); diff --git a/src/utils/__tests__/uploads.test.js b/src/utils/__tests__/uploads.test.ts similarity index 98% rename from src/utils/__tests__/uploads.test.js rename to src/utils/__tests__/uploads.test.ts index 74856320d3..02dd75d144 100644 --- a/src/utils/__tests__/uploads.test.js +++ b/src/utils/__tests__/uploads.test.ts @@ -332,7 +332,7 @@ describe('util/uploads', () => { name: 'hi', }; - expect(getFileId(file)).toBe('hi'); + expect(getFileId(file, '0')).toBe('hi'); }); test('should return file id correctly when file does contain API options', () => { @@ -346,7 +346,7 @@ describe('util/uploads', () => { }, }; - expect(getFileId(file)).toBe('hi_0_123123'); + expect(getFileId(file, '0')).toBe('hi_0_123123'); }); }); @@ -356,7 +356,7 @@ describe('util/uploads', () => { name: 'hi', }; - expect(getFileId(file)).toBe('hi'); + expect(getFileId(file, '0')).toBe('hi'); }); test('should return file id correctly when file does contain API options', () => { @@ -370,12 +370,12 @@ describe('util/uploads', () => { }, }; - expect(getFileId(file)).toBe('hi_0_123123'); + expect(getFileId(file, '0')).toBe('hi_0_123123'); }); }); describe('getDataTransferItemId()', () => { - const rootFolderId = 0; + const rootFolderId = '0'; const now = Date.now(); Date.now = jest.fn(() => now); diff --git a/src/utils/__tests__/validators.test.js b/src/utils/__tests__/validators.test.ts similarity index 99% rename from src/utils/__tests__/validators.test.js rename to src/utils/__tests__/validators.test.ts index eefd574b37..4fea7a73e5 100644 --- a/src/utils/__tests__/validators.test.js +++ b/src/utils/__tests__/validators.test.ts @@ -1,5 +1,3 @@ -// @flow - import { domainNameValidator, emailValidator, hostnameValidator, ipv4AddressValidator } from '../validators'; describe('util/validators', () => { diff --git a/src/utils/__tests__/webcrypto.test.js b/src/utils/__tests__/webcrypto.test.ts similarity index 54% rename from src/utils/__tests__/webcrypto.test.js rename to src/utils/__tests__/webcrypto.test.ts index 84d0a48093..dbac6284c5 100644 --- a/src/utils/__tests__/webcrypto.test.js +++ b/src/utils/__tests__/webcrypto.test.ts @@ -3,9 +3,17 @@ import { digest, getRandomValues } from '../webcrypto'; jest.mock('js-sha1'); +type WindowWithMsCrypto = Omit & { crypto?: Crypto; msCrypto?: Crypto }; +type Sha1WithArrayBuffer = typeof sha1 & { arrayBuffer: jest.Mock }; +type CryptoOperation = { + oncomplete?: (event: { target: { result: ArrayBuffer } }) => void; + onerror?: (reason?: unknown) => void; +}; + +const sha1Mock = sha1 as Sha1WithArrayBuffer; + describe('util/webcrypto', () => { beforeEach(() => { - // eslint-disable-next-line no-undef Object.defineProperty(globalThis, 'window', { value: { ...window, @@ -17,107 +25,108 @@ describe('util/webcrypto', () => { describe('getRandomValues()', () => { test('should call getRandomValues() to get an array of random values', () => { const getRandomValuesMock = jest.fn(); - window.crypto = { + (window as WindowWithMsCrypto).crypto = { getRandomValues: getRandomValuesMock, - }; + } as unknown as Crypto; - getRandomValues(); + getRandomValues(new Uint8Array()); expect(getRandomValuesMock).toHaveBeenCalled(); }); }); describe('digest()', () => { const algorithm = 'a'; - const buffer = new Uint8Array([1, 2]); + const { buffer } = new Uint8Array([1, 2]); const digestVal = 'd'; test('should return the return value of digest() when the crypto lib is not msCrypto', () => { const digestMock = jest.fn().mockReturnValueOnce(digestVal); - window.crypto = { + (window as WindowWithMsCrypto).crypto = { subtle: { digest: digestMock, }, - }; + } as unknown as Crypto; expect(digest(algorithm, buffer)).toBe(digestVal); expect(digestMock).toHaveBeenCalledWith(algorithm, buffer); }); describe('msCrypto', () => { - test('should return a promise which resolves properly when the crypto lib is msCrypto', () => { - sha1.arrayBuffer = jest.fn().mockImplementation(() => new ArrayBuffer()); - const cryptoOperation = {}; + test('should return a promise which resolves properly when the crypto lib is msCrypto', async () => { + sha1Mock.arrayBuffer = jest.fn().mockImplementation(() => new ArrayBuffer(0)); + const cryptoOperation: CryptoOperation = {}; const digestMock = jest.fn().mockReturnValueOnce(cryptoOperation); + const expectedDigest = new ArrayBuffer(0); - window.crypto = undefined; - window.msCrypto = { + (window as WindowWithMsCrypto).crypto = undefined; + (window as WindowWithMsCrypto).msCrypto = { subtle: { digest: digestMock, }, - }; + } as unknown as Crypto; - digest(algorithm, buffer); + const digestPromise = digest(algorithm, buffer); - cryptoOperation.oncomplete({ + expect(cryptoOperation.oncomplete).toEqual(expect.any(Function)); + cryptoOperation.oncomplete!({ target: { - result: 'digest', + result: expectedDigest, }, }); + await expect(digestPromise).resolves.toBe(expectedDigest); expect(digestMock).toHaveBeenCalledWith({ name: algorithm }, buffer); - expect(sha1.arrayBuffer).not.toHaveBeenCalled(); + expect(sha1Mock.arrayBuffer).not.toHaveBeenCalled(); }); - test('should return a promise which rejects properly when the crypto lib is msCrypto', () => { - const cryptoOperation = {}; + test('should return a promise which rejects properly when the crypto lib is msCrypto', async () => { + const cryptoOperation: CryptoOperation = {}; const digestMock = jest.fn().mockReturnValueOnce(cryptoOperation); - window.crypto = undefined; - window.msCrypto = { + (window as WindowWithMsCrypto).crypto = undefined; + (window as WindowWithMsCrypto).msCrypto = { subtle: { digest: digestMock, }, - }; + } as unknown as Crypto; const expectedError = new Error('ERROR'); + const digestPromise = digest(algorithm, buffer); - digest(algorithm, buffer).catch(error => { - expect(error).toBe(expectedError); - }); - - cryptoOperation.onerror(expectedError); - expect.assertions(1); + expect(cryptoOperation.onerror).toEqual(expect.any(Function)); + cryptoOperation.onerror!(expectedError); + await expect(digestPromise).rejects.toBe(expectedError); }); }); describe('js-sha1', () => { test('should use js-sha1 for calculating hash in IE-11 SHA-1 digest scenarios', async () => { // ie11 does not support sha-1, so we use a library - sha1.arrayBuffer = jest.fn().mockImplementation(() => new ArrayBuffer()); + sha1Mock.arrayBuffer = jest.fn().mockImplementation(() => new ArrayBuffer(0)); const digestMock = jest.fn().mockReturnValueOnce({}); - window.crypto = undefined; + (window as WindowWithMsCrypto).crypto = undefined; - window.msCrypto = { + (window as WindowWithMsCrypto).msCrypto = { subtle: { digest: digestMock, }, - }; + } as unknown as Crypto; const hash = await digest('SHA-1', buffer); expect(hash).toBeDefined(); expect(digestMock).not.toHaveBeenCalled(); - expect(sha1.arrayBuffer).toHaveBeenCalledWith(buffer); + expect(sha1Mock.arrayBuffer).toHaveBeenCalledWith(buffer); }); test('should return a promise which rejects properly when js-sha1 fails', () => { const expectedError = new Error('ERROR'); // ie11 does not support sha-1, so we use a library - sha1.arrayBuffer = jest.fn().mockRejectedValue(expectedError); + sha1Mock.arrayBuffer = jest.fn().mockRejectedValue(expectedError); const digestMock = jest.fn().mockReturnValueOnce({}); - window.crypto = undefined; + (window as WindowWithMsCrypto).crypto = undefined; - window.msCrypto = { + (window as WindowWithMsCrypto).msCrypto = { subtle: { digest: digestMock, }, - }; + } as unknown as Crypto; digest('SHA-1', buffer).catch(error => { expect(error).toBe(expectedError); diff --git a/src/utils/base64.js b/src/utils/base64.js.flow similarity index 100% rename from src/utils/base64.js rename to src/utils/base64.js.flow diff --git a/src/utils/base64.ts b/src/utils/base64.ts new file mode 100644 index 0000000000..22f9cf0d14 --- /dev/null +++ b/src/utils/base64.ts @@ -0,0 +1,18 @@ +/** + * Converts hex to Base 64. Adapted from + * https://stackoverflow.com/questions/23190056/hex-to-base64-converter-for-javascript. + * + * @param {string} str - Hex string to convert + */ +export default function hexToBase64(str: string): string { + return btoa( + String.fromCharCode.apply( + null, + str + .replace(/\r|\n/g, '') + .replace(/([\da-fA-F]{2}) ?/g, '0x$1 ') + .replace(/ +$/, '') + .split(' ') as unknown as number[], + ), + ); +} diff --git a/src/utils/comparator.js b/src/utils/comparator.js.flow similarity index 100% rename from src/utils/comparator.js rename to src/utils/comparator.js.flow diff --git a/src/utils/comparator.ts b/src/utils/comparator.ts new file mode 100644 index 0000000000..ab09a60861 --- /dev/null +++ b/src/utils/comparator.ts @@ -0,0 +1,73 @@ +import { + TYPE_FILE, + TYPE_FOLDER, + SORT_DESC, + FIELD_MODIFIED_AT, + FIELD_INTERACTED_AT, + FIELD_NAME, + FIELD_SIZE, +} from '../constants'; +import type { SortBy, SortDirection, ItemType, BoxItem } from '../common/types/core'; +import type APICache from './Cache'; + +/** + * Comparator function for sorting files and folders + * + * @param {string} sortBy field to sort by + * @param {string} sortDirection desc or asc + * @return {Function} comparator function + */ +export default function comparator( + sortBy: SortBy, + sortDirection: SortDirection, + cache: APICache, +): (a: string, b: string) => number { + const invert: number = sortDirection === SORT_DESC ? 1 : -1; + return (a: string, b: string): number => { + const itemA: BoxItem = cache.get(a); + const itemB: BoxItem = cache.get(b); + + const itemAType: ItemType = itemA.type || TYPE_FILE; + const itemBType: ItemType = itemB.type || TYPE_FILE; + const itemAName: string = itemA.name || ''; + const itemBName: string = itemB.name || ''; + const itemADate: number = Date.parse(itemA.modified_at || '0'); + const itemBDate: number = Date.parse(itemB.modified_at || '0'); + const itemAInteractedDate: number = Date.parse(itemA.interacted_at || itemA.modified_at || '0'); + const itemBInteractedDate: number = Date.parse(itemB.interacted_at || itemB.modified_at || '0'); + const itemASize: number = itemA.size || 0; + const itemBSize: number = itemB.size || 0; + + // If a and b are of the same type, then use sortBy + if (itemAType === itemBType) { + if (sortBy === FIELD_NAME) { + if (itemAName.toLowerCase() > itemBName.toLowerCase()) return -1 * invert; + if (itemAName.toLowerCase() < itemBName.toLowerCase()) return 1 * invert; + } else if (sortBy === FIELD_MODIFIED_AT) { + if (itemADate > itemBDate) return -1 * invert; + if (itemADate < itemBDate) return 1 * invert; + } else if (sortBy === FIELD_INTERACTED_AT) { + if (itemAInteractedDate > itemBInteractedDate) return -1 * invert; + if (itemAInteractedDate < itemBInteractedDate) return 1 * invert; + } else if (sortBy === FIELD_SIZE) { + if (itemASize > itemBSize) return -1 * invert; + if (itemASize < itemBSize) return 1 * invert; + } else { + // Should never reach here + throw new Error('Unsupported sort field!'); + } + + return 0; + } + + // If a and b are of different types, then use type to sort + // Folder > File > WebLink + if (itemAType === TYPE_FOLDER) return -1; + if (itemBType === TYPE_FOLDER) return 1; + if (itemAType === TYPE_FILE) return -1; + if (itemBType === TYPE_FILE) return 1; + + // Should never reach here + throw new Error('Error in sort comparator!'); + }; +} diff --git a/src/utils/createTheme.js b/src/utils/createTheme.js.flow similarity index 100% rename from src/utils/createTheme.js rename to src/utils/createTheme.js.flow diff --git a/src/utils/createTheme.ts b/src/utils/createTheme.ts new file mode 100644 index 0000000000..4cbab40a4e --- /dev/null +++ b/src/utils/createTheme.ts @@ -0,0 +1,193 @@ +import Color from 'color'; +import method from 'lodash/method'; +import merge from 'lodash/merge'; +import mapValues from 'lodash/mapValues'; + +import { + THEME_VERY_DARK, + THEME_DARK, + THEME_MID_DARK, + THEME_MIDTONE, + THEME_MID_LIGHT, + THEME_VERY_LIGHT, +} from '../constants'; +import defaultTheme from '../styles/theme'; +import * as vars from '../styles/variables'; + +// When converting from rgb/hsl to hex there is potential for +// flattening of the color space, so we add an offset factor to account for it. +const OFFSET_FACTOR = 0.05; +export const MIN_CONTRAST = 4.5; + +// The yiq coefficients in the color library are incorrect +// http://poynton.ca/notes/colour_and_gamma/ColorFAQ.html#RTFToC9 +function getYiq(color: string): number { + const rgb = Color(color).rgb().array(); + + return (rgb[0] * 2126 + rgb[1] * 7152 + rgb[2] * 722) / 10000; +} + +function adjustLightness(color: ReturnType, amount: number): ReturnType { + const lightness = color.lightness(); + return color.lightness(lightness + amount); +} + +// Given a colorKey, output an accessible Box color palette +function createTheme(colorKey: string) { + if (!colorKey) { + throw new Error('Color key is undefined'); + } + + const colorKeyObj = Color(colorKey); + const colorKeyYiq = getYiq(colorKey); + const colorKeyLightness = colorKeyObj.lightness(); + + const whiteTextContrast = colorKeyObj.contrast(Color(vars.white)); + const blackTextContrast = colorKeyObj.contrast(Color(vars.black)); + + // Take the greater contrasting foreground color + const foreground = whiteTextContrast > blackTextContrast ? vars.white : vars.black; + const foregroundObj = Color(foreground); + + // vDark dark midDark midtone midLight vLight + // |----|-----|-----------|----|----|-----------|----| + // 0 4 20 118 128 168 235 255 + const colorMap = { + [THEME_VERY_DARK]: { + yiqRange: [0, 4], + modifiers: { + active: 15, + gradient: 10, + hover: 10, + }, + }, + [THEME_DARK]: { + yiqRange: [4, 20], + modifiers: { + active: 8, + gradient: 5, + hover: 4, + }, + }, + [THEME_MID_DARK]: { + yiqRange: [20, 118], + modifiers: { + active: -8, + gradient: -5, + hover: 4, + }, + }, + [THEME_MIDTONE]: { + yiqRange: [118, 168], + modifiers: { + active: -7, + activeInverse: 9, + gradient: -7, + hover: 7, + hoverInverse: -4, + }, + }, + [THEME_MID_LIGHT]: { + yiqRange: [168, 235], + modifiers: { + active: 15, + gradient: -10, + hover: 10, + }, + }, + [THEME_VERY_LIGHT]: { + yiqRange: [235, 256], + lightnessThreshold: 90, + modifiers: { + active: -8, + gradient: -5, + hover: -4, + }, + }, + } as const; + + // Filter down the color map to the object that's in the proper YIQ range + const colorRange = Object.keys(colorMap).find( + key => + (colorKeyYiq >= colorMap[key].yiqRange[0] && colorKeyYiq < colorMap[key].yiqRange[1]) || + (colorMap[key].lightnessThreshold && colorKeyLightness >= colorMap[key].lightnessThreshold), + ); + const colorRangeConfig = { + ...colorMap[colorRange || THEME_MIDTONE], + } as const; + + // Modify the primary colorKey with the associated modifiers from the filtered map + const modifiedColors: Record> = {}; + + for (const [key, value] of Object.entries(colorRangeConfig.modifiers)) { + modifiedColors[key] = adjustLightness(colorKeyObj, value); + } + + // If the color is too extreme on either end of the spectrum we need to change our rules. + const exceedsLightThreshold = colorRange === THEME_VERY_LIGHT || colorRange === THEME_MID_LIGHT; + const exceedsDarkThreshold = colorRange === THEME_VERY_DARK || colorRange === THEME_DARK; + + // Light or dark isn't sufficient for determining how the secondary or accent colors should + // be calculated. In addition to that check, we will check the yiq value of the color to ensure + // the colorKey is not on the edges of the spectrum. + + const hoverContrast = modifiedColors.hover.contrast(foregroundObj); + // If contrast has reached 21, we have hit the end of the spectrum and want to invert. + const hover = + hoverContrast >= MIN_CONTRAST + OFFSET_FACTOR && hoverContrast !== 21 + ? modifiedColors.hover + : modifiedColors.hoverInverse || adjustLightness(colorKeyObj, -colorRangeConfig.modifiers.hover); + + const activeContrast = modifiedColors.active.contrast(foregroundObj); + const active = + activeContrast >= MIN_CONTRAST + OFFSET_FACTOR && activeContrast !== 21 + ? modifiedColors.active + : modifiedColors.activeInverse || adjustLightness(colorKeyObj, -colorRangeConfig.modifiers.active); + + let scrollShadowRgba = 'rgba(0, 0, 0, 0.12)'; + if (exceedsLightThreshold) { + scrollShadowRgba = 'rgba(0, 0, 0, 0.08)'; + } else if (exceedsDarkThreshold) { + scrollShadowRgba = 'rgba(0, 0, 0, 0.4)'; + } + + // Converting color objects to hex for return value + const colorKeyHex = colorKeyObj.hex(); + const hoverHex = hover.hex(); + const activeHex = active.hex(); + const gradientHex = modifiedColors.gradient.hex(); + + const colorValues = { + background: colorKeyHex, + backgroundHover: hoverHex, + backgroundActive: activeHex, + backgroundGradient: gradientHex, + foreground, + border: exceedsLightThreshold ? vars.bdlGray10 : colorKeyHex, + + // Button specific overrides. If the primary color is greater than the lightness threshold + // we will override the button styling to be a styling based on vars.bdlGray primary. + buttonForeground: exceedsLightThreshold ? vars.white : foreground, + buttonBackground: exceedsLightThreshold ? vars.bdlGray : colorKeyHex, + buttonBackgroundHover: exceedsLightThreshold ? vars.bdlGray80 : hoverHex, + buttonBackgroundActive: exceedsLightThreshold ? vars.bdlGray65 : activeHex, + buttonBorder: exceedsLightThreshold ? vars.bdlGray : colorKeyHex, + buttonBorderHover: exceedsLightThreshold ? vars.bdlGray80 : hoverHex, + buttonBorderActive: exceedsLightThreshold ? vars.bdlGray65 : activeHex, + + // ProgressBar overrides + progressBarBackground: exceedsLightThreshold ? vars.bdlGray50 : hoverHex, + + // Scroll effect overrides for scrollable themed elements + scrollShadowRgba, + } as const; + + const dynamicTheme = { + // To avoid a mixture of casing, we force all values to lower + primary: { ...mapValues(colorValues, method('toLowerCase')), _debug: { colorRange } }, + } as const; + + return merge({}, defaultTheme, dynamicTheme); +} + +export { createTheme }; diff --git a/src/utils/dom.js b/src/utils/dom.js.flow similarity index 100% rename from src/utils/dom.js rename to src/utils/dom.js.flow diff --git a/src/utils/dom.ts b/src/utils/dom.ts new file mode 100644 index 0000000000..bce8e0efc6 --- /dev/null +++ b/src/utils/dom.ts @@ -0,0 +1,148 @@ +import * as React from 'react'; +import scrollIntoViewIfNeeded from 'scroll-into-view-if-needed'; + +import { KEYS, OVERLAY_WRAPPER_CLASS } from '../constants'; +import './domPolyfill'; + +/** + * Checks if an html element is some type of input-able + * element or text area type where characters can be typed. + * + * @param {HTMLElement|null} element - the dom element to check + * @return {boolean} true if its one of the above elements + */ +export function isInputElement(element: HTMLElement | EventTarget | null): boolean { + if (!element || !(element instanceof HTMLElement)) { + return false; + } + + const tag = element.tagName.toLowerCase(); + return ( + tag === 'input' || + tag === 'select' || + tag === 'textarea' || + (tag === 'div' && !!element.getAttribute('contenteditable')) + ); +} + +/** + * Checks if an html element is some kind of element + * that the user would want to keep their focus on. + * + * @param {HTMLElement|null} element - the dom element to check + * @return {boolean} true if its one of the above elements + */ +export function isFocusableElement(element: HTMLElement | EventTarget | null): boolean { + if (!element || !(element instanceof HTMLElement)) { + return false; + } + + const tag = element.tagName.toLowerCase(); + + // Box React UI sensitive checks + const isCheckbox = + element.classList.contains('checkbox-pointer-target') || + (element.parentElement instanceof HTMLElement + ? element.parentElement.classList.contains('checkbox-label') + : false); + + const isButton = + element.classList.contains('btn-content') || + (element.parentElement instanceof HTMLElement && element.parentElement.classList.contains('btn')) || + (element.parentElement instanceof HTMLElement && element.parentElement.classList.contains('bdl-Button')) || + false; + + return isInputElement(element) || tag === 'button' || tag === 'a' || tag === 'option' || isCheckbox || isButton; +} + +/** + * Checks if a keyboard event is intended to activate an element. + * + * @param {SyntheticKeyboardEvent} event - The keyboard event + * @returns {boolean} true if the event is intended to activate the element + */ +export function isActivateKey(event: React.KeyboardEvent): boolean { + return event.key === KEYS.enter || event.key === KEYS.space; +} + +/** + * Checks if a mouse event is an unmodified left click. + * + * @param {SyntheticMouseEvent} event - The mouse event + * @returns {boolean} true if the event is an unmodified left click + */ +export function isLeftClick(event: React.MouseEvent): boolean { + return event.button === 0 && !event.altKey && !event.ctrlKey && !event.metaKey && !event.shiftKey; +} + +/** + * Focuses a DOM element if it exists. + * + * @param {HTMLElement} root - the root dom element to search + * @param {string} selector - the query selector + * @param {boolean|void} [focusRoot] - if root should be focused + */ +export function focus(root?: HTMLElement | null, selector?: string, focusRoot: boolean = true): void { + if (!root) { + return; + } + + if (!selector) { + root.focus(); + return; + } + + const element = root.querySelector(selector); + const focusableElement = element as (Element & { focus?: () => void }) | null; + if (focusableElement && typeof focusableElement.focus === 'function') { + focusableElement.focus(); + } else if (focusRoot) { + root.focus(); + } +} + +/** + * Scrolls the container / modal / wrapper instead of the body + * + * @param {HTMLElement} itemEl - the base dom element to search + * @param {Object} options - scroll into view options to override + */ +export function scrollIntoView(itemEl?: HTMLElement | null, options: object = {}): void { + // @NOTE: breaks encapsulation but alternative is unknown child ref + if (itemEl) { + const parentEl = itemEl.closest(`.body, .modal, .${OVERLAY_WRAPPER_CLASS}`); + scrollIntoViewIfNeeded(itemEl, { + scrollMode: 'if-needed', + boundary: parentEl, + ...options, + }); + } +} + +/** + * A React hook that tells you if an element (passed in as a ref) has content that overflows its container, + * i.e., if the text is wider than the box around it. + * + * @param {{ current: null | HTMLElement }} contentRef + */ +export function useIsContentOverflowed(contentRef: { + current: null | Pick; +}): boolean { + const [isContentOverflowed, setIsContentOverflowed] = React.useState(false); + + // This function should be set as the ref prop for the measured component. + // eslint-disable-next-line react-hooks/exhaustive-deps + React.useLayoutEffect(() => { + const { current } = contentRef; + if (!current) { + return; + } + const { offsetWidth, scrollWidth } = current; + const willOverflow = offsetWidth < scrollWidth; + if (willOverflow !== isContentOverflowed) { + setIsContentOverflowed(willOverflow); + } + }); + + return isContentOverflowed; +} diff --git a/src/utils/domPolyfill.js b/src/utils/domPolyfill.js.flow similarity index 100% rename from src/utils/domPolyfill.js rename to src/utils/domPolyfill.js.flow diff --git a/src/utils/domPolyfill.ts b/src/utils/domPolyfill.ts new file mode 100644 index 0000000000..fa7265d5f1 --- /dev/null +++ b/src/utils/domPolyfill.ts @@ -0,0 +1,30 @@ +/** + * Polyfills the .closest method for Element + * Currently being used for scrollIntoView + * For reference: https://github.com/zloirock/core-js/issues/317 + */ +window.Element.prototype.closest = function closest(this: Element, s: string): Element | null { + const elementProto = window.Element.prototype as Element & { + msMatchesSelector?: (selector: string) => boolean; + webkitMatchesSelector?: (selector: string) => boolean; + }; + if (!window.Element.prototype.matches) { + window.Element.prototype.matches = (elementProto.msMatchesSelector || + elementProto.webkitMatchesSelector) as typeof Element.prototype.matches; + } + + if (this.matches(s)) { + return this; + } + + let el: Element | Node | null = this.parentElement || this.parentNode; + while (el !== null && el.nodeType === 1) { + const element = el as Element; + if (element.matches(s)) { + return element; + } + el = element.parentElement || element.parentNode; + } + + return null; +}; diff --git a/src/utils/download.js b/src/utils/download.js.flow similarity index 90% rename from src/utils/download.js rename to src/utils/download.js.flow index b4052d9cfc..f011f66c3f 100644 --- a/src/utils/download.js +++ b/src/utils/download.js.flow @@ -8,8 +8,8 @@ * Function to download string as txt file * * @private - * @param {String} string - string to download - * @param {String} name - file name to use + * @param {string} string - string to download + * @param {string} name - file name to use * @return {void} */ function download(string: string, name: string) { @@ -46,7 +46,7 @@ function download(string: string, name: string) { * Function to copy string to the clipboard * * @private - * @param {String} string - string to copy + * @param {string} string - string to copy * @return {void} */ function copy(string: string) { diff --git a/src/utils/download.ts b/src/utils/download.ts new file mode 100644 index 0000000000..aac6ae3168 --- /dev/null +++ b/src/utils/download.ts @@ -0,0 +1,62 @@ +/** + * Function to download string as txt file + * + * @private + * @param {string} string - string to download + * @param {string} name - file name to use + */ +function download(string: string, name: string): void { + const blob = new Blob([string], { type: 'text/plain;charset=utf-8' }); + + // IE11 + const navigatorWithMsSave = window.navigator as Navigator & { + msSaveBlob?: (blob: Blob, defaultName?: string) => boolean; + }; + if (navigatorWithMsSave.msSaveBlob) { + navigatorWithMsSave.msSaveBlob(blob, name); + return; + } + + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + + a.style.display = 'none'; + a.href = url; + a.download = name; + if (document.body) { + document.body.appendChild(a); + } + + a.click(); + + setTimeout(() => { + if (document.body) { + document.body.removeChild(a); + } + + URL.revokeObjectURL(url); + }, 100); +} + +/** + * Function to copy string to the clipboard + * + * @private + * @param {string} string - string to copy + */ +function copy(string: string): void { + const textarea = document.createElement('textarea'); + const { body } = document; + + textarea.value = string; + textarea.style.display = 'hidden'; + + if (body) { + body.appendChild(textarea); + textarea.select(); + document.execCommand('copy'); + body.removeChild(textarea); + } +} + +export { download, copy }; diff --git a/src/utils/env.js b/src/utils/env.js.flow similarity index 100% rename from src/utils/env.js rename to src/utils/env.js.flow diff --git a/src/utils/env.ts b/src/utils/env.ts new file mode 100644 index 0000000000..024ab04cea --- /dev/null +++ b/src/utils/env.ts @@ -0,0 +1,4 @@ +/** Return true if we are currently running in a test or development environment. */ +export default function isDevEnvironment(): boolean { + return process?.env?.NODE_ENV === 'test' || process?.env?.NODE_ENV === 'dev'; +} diff --git a/src/utils/error.js b/src/utils/error.js.flow similarity index 100% rename from src/utils/error.js rename to src/utils/error.js.flow diff --git a/src/utils/error.ts b/src/utils/error.ts new file mode 100644 index 0000000000..d923ea1b99 --- /dev/null +++ b/src/utils/error.ts @@ -0,0 +1,51 @@ +import { + HTTP_STATUS_CODE_CONFLICT, + HTTP_STATUS_CODE_UNAUTHORIZED, + HTTP_STATUS_CODE_RATE_LIMIT, + HTTP_STATUS_CODE_INTERNAL_SERVER_ERROR, +} from '../constants'; + +function getBadItemError(): Error { + return new Error('Bad box item!'); +} + +function getBadPermissionsError(): Error { + return new Error('Insufficient Permissions!'); +} + +function getBadUserError(): Error { + return new Error('Bad box user!'); +} + +function getMissingItemTextOrStatus(): Error { + return new Error('Missing text or status!'); +} + +function isUserCorrectableError(status: number): boolean { + return ( + status === HTTP_STATUS_CODE_RATE_LIMIT || + status === HTTP_STATUS_CODE_UNAUTHORIZED || + status === HTTP_STATUS_CODE_CONFLICT || + status >= HTTP_STATUS_CODE_INTERNAL_SERVER_ERROR + ); +} + +function getAbortError(): Error { + class AbortError extends Error { + constructor(message: string) { + super(message); + this.name = 'AbortError'; + } + } + + return new AbortError('Aborted'); +} + +export { + getAbortError, + getBadItemError, + getBadPermissionsError, + getBadUserError, + getMissingItemTextOrStatus, + isUserCorrectableError, +}; diff --git a/src/utils/fields.js b/src/utils/fields.js.flow similarity index 100% rename from src/utils/fields.js rename to src/utils/fields.js.flow diff --git a/src/utils/fields.ts b/src/utils/fields.ts new file mode 100644 index 0000000000..bef2522961 --- /dev/null +++ b/src/utils/fields.ts @@ -0,0 +1,317 @@ +import has from 'lodash/has'; +import setProp from 'lodash/set'; +import { + FIELD_ID, + FIELD_NAME, + FIELD_TYPE, + FIELD_SIZE, + FIELD_PARENT, + FIELD_EXTENSION, + FIELD_PERMISSIONS, + FIELD_ITEM_COLLECTION, + FIELD_ITEM_EXPIRATION, + FIELD_ITEM_STATUS, + FIELD_PATH_COLLECTION, + FIELD_CONTENT_CREATED_AT, + FIELD_CONTENT_MODIFIED_AT, + FIELD_MODIFIED_AT, + FIELD_CREATED_AT, + FIELD_SHARED_LINK, + FIELD_ALLOWED_SHARED_LINK_ACCESS_LEVELS, + FIELD_HAS_COLLABORATIONS, + FIELD_IS_EXTERNALLY_OWNED, + FIELD_CREATED_BY, + FIELD_MODIFIED_BY, + FIELD_OWNED_BY, + FIELD_PROMOTED_BY, + FIELD_RESTORED_BY, + FIELD_PURGED_AT, + FIELD_TRASHED_BY, + FIELD_DESCRIPTION, + FIELD_REPRESENTATIONS, + FIELD_SHA1, + FIELD_UPLOADER_DISPLAY_NAME, + FIELD_WATERMARK_INFO, + FIELD_AUTHENTICATED_DOWNLOAD_URL, + FIELD_FILE_VERSION, + FIELD_IS_DOWNLOAD_AVAILABLE, + FIELD_VERSION_LIMIT, + FIELD_VERSION_NUMBER, + FIELD_METADATA_SKILLS, + FIELD_TASK_ASSIGNMENT_COLLECTION, + FIELD_IS_COMPLETED, + FIELD_MESSAGE, + FIELD_TAGGED_MESSAGE, + FIELD_DUE_AT, + FIELD_TRASHED_AT, + FIELD_ASSIGNED_TO, + FIELD_RESTORED_FROM, + FIELD_RESTORED_AT, + FIELD_STATUS, + FIELD_ACTIVITY_TEMPLATE, + FIELD_APP, + FIELD_OCCURRED_AT, + FIELD_RENDERED_TEXT, + FIELD_RETENTION, + FIELD_URL, + PLACEHOLDER_USER, + FIELD_METADATA_ARCHIVE, +} from '../constants'; + +// Minimum set of fields needed for folder requests +const FOLDER_FIELDS_TO_FETCH = [ + FIELD_ID, + FIELD_NAME, + FIELD_TYPE, + FIELD_SIZE, + FIELD_PARENT, + FIELD_EXTENSION, + FIELD_PERMISSIONS, + FIELD_PATH_COLLECTION, + FIELD_MODIFIED_AT, + FIELD_CREATED_AT, + FIELD_MODIFIED_BY, + FIELD_HAS_COLLABORATIONS, + FIELD_IS_EXTERNALLY_OWNED, + FIELD_ITEM_COLLECTION, + FIELD_AUTHENTICATED_DOWNLOAD_URL, + FIELD_IS_DOWNLOAD_AVAILABLE, + FIELD_REPRESENTATIONS, + FIELD_URL, +]; + +// Fields needed for the sidebar +const SIDEBAR_FIELDS_TO_FETCH = [ + FIELD_ID, + FIELD_NAME, + FIELD_SIZE, + FIELD_EXTENSION, + FIELD_FILE_VERSION, + FIELD_SHARED_LINK, + FIELD_PERMISSIONS, + FIELD_CONTENT_CREATED_AT, + FIELD_CONTENT_MODIFIED_AT, + FIELD_CREATED_AT, + FIELD_CREATED_BY, + FIELD_MODIFIED_AT, + FIELD_MODIFIED_BY, + FIELD_OWNED_BY, + FIELD_DESCRIPTION, + FIELD_METADATA_SKILLS, + FIELD_ITEM_EXPIRATION, + FIELD_VERSION_LIMIT, + FIELD_VERSION_NUMBER, + FIELD_IS_EXTERNALLY_OWNED, + FIELD_RESTORED_FROM, + FIELD_AUTHENTICATED_DOWNLOAD_URL, + FIELD_IS_DOWNLOAD_AVAILABLE, + FIELD_UPLOADER_DISPLAY_NAME, +]; + +// Fields needed for sidebar of file in archive +const SIDEBAR_FIELDS_TO_FETCH_ARCHIVE: Array = SIDEBAR_FIELDS_TO_FETCH.concat(FIELD_METADATA_ARCHIVE); + +// Fields needed for preview +const PREVIEW_FIELDS_TO_FETCH = [ + FIELD_ID, + FIELD_PERMISSIONS, + FIELD_SHARED_LINK, + FIELD_SHA1, + FIELD_FILE_VERSION, + FIELD_NAME, + FIELD_SIZE, + FIELD_EXTENSION, + FIELD_REPRESENTATIONS, + FIELD_WATERMARK_INFO, + FIELD_AUTHENTICATED_DOWNLOAD_URL, + FIELD_IS_DOWNLOAD_AVAILABLE, +]; + +// Fields needed to get versions for a file in activity feed +const FEED_FILE_VERSIONS_FIELDS_TO_FETCH = [ + FIELD_CREATED_AT, + FIELD_EXTENSION, + FIELD_IS_DOWNLOAD_AVAILABLE, + FIELD_MODIFIED_AT, + FIELD_MODIFIED_BY, + FIELD_NAME, + FIELD_RESTORED_AT, + FIELD_RESTORED_BY, + FIELD_SIZE, + FIELD_TRASHED_AT, + FIELD_TRASHED_BY, + FIELD_UPLOADER_DISPLAY_NAME, + FIELD_VERSION_NUMBER, +]; + +// Fields needed to get information on the current version of a file +const FILE_VERSION_FIELDS_TO_FETCH = [ + FIELD_FILE_VERSION, + FIELD_MODIFIED_AT, + FIELD_MODIFIED_BY, + FIELD_RESTORED_FROM, + FIELD_SIZE, + FIELD_PROMOTED_BY, + FIELD_UPLOADER_DISPLAY_NAME, + FIELD_VERSION_NUMBER, +]; + +// Fields needed to get information on the current version of a file for file in archive +const FILE_VERSION_FIELDS_TO_FETCH_ARCHIVE: Array = FILE_VERSION_FIELDS_TO_FETCH.concat(FIELD_METADATA_ARCHIVE); + +// Fields needed to get versions for a file +const FILE_VERSIONS_FIELDS_TO_FETCH = [ + FIELD_AUTHENTICATED_DOWNLOAD_URL, // Expensive field to fetch + FIELD_CREATED_AT, + FIELD_EXTENSION, + FIELD_IS_DOWNLOAD_AVAILABLE, + FIELD_MODIFIED_AT, + FIELD_MODIFIED_BY, + FIELD_NAME, + FIELD_PERMISSIONS, // Expensive field to fetch + FIELD_PROMOTED_BY, + FIELD_RESTORED_AT, + FIELD_RESTORED_BY, + FIELD_RETENTION, // Expensive field to fetch + FIELD_SIZE, + FIELD_TRASHED_AT, + FIELD_TRASHED_BY, + FIELD_UPLOADER_DISPLAY_NAME, + FIELD_VERSION_NUMBER, +]; + +// Fields needed to show shared link permissions +const FILE_SHARED_LINK_FIELDS_TO_FETCH = [FIELD_ALLOWED_SHARED_LINK_ACCESS_LEVELS, FIELD_SHARED_LINK]; + +// Fields needed to get tasks data +const TASKS_FIELDS_TO_FETCH = [ + FIELD_TASK_ASSIGNMENT_COLLECTION, + FIELD_IS_COMPLETED, + FIELD_CREATED_AT, + FIELD_CREATED_BY, + FIELD_DUE_AT, + FIELD_MESSAGE, +]; + +// Fields needed to get task assignments data +const TASK_ASSIGNMENTS_FIELDS_TO_FETCH = [FIELD_ASSIGNED_TO, FIELD_STATUS]; + +// Fields needed to get tasks data +const COMMENTS_FIELDS_TO_FETCH = [ + FIELD_TAGGED_MESSAGE, + FIELD_MESSAGE, + FIELD_CREATED_AT, + FIELD_CREATED_BY, + FIELD_MODIFIED_AT, + FIELD_PERMISSIONS, +]; + +// Fields that represent users +const USER_FIELDS = [FIELD_CREATED_BY, FIELD_MODIFIED_BY, FIELD_OWNED_BY, FIELD_ASSIGNED_TO]; + +// Fields required to fetch app activity +const APP_ACTIVITY_FIELDS_TO_FETCH = [ + FIELD_ACTIVITY_TEMPLATE, + FIELD_APP, + FIELD_CREATED_BY, + FIELD_OCCURRED_AT, + FIELD_RENDERED_TEXT, +]; + +// Fields needed for uploader +const UPLOADER_FIELDS_TO_FETCH = [ + FIELD_CONTENT_CREATED_AT, + FIELD_CONTENT_MODIFIED_AT, + FIELD_CREATED_AT, + FIELD_CREATED_BY, + FIELD_DESCRIPTION, + FIELD_ITEM_STATUS, + FIELD_MODIFIED_AT, + FIELD_MODIFIED_BY, + FIELD_OWNED_BY, + FIELD_PARENT, + FIELD_PATH_COLLECTION, + FIELD_PURGED_AT, + FIELD_SHARED_LINK, + FIELD_SIZE, + FIELD_TRASHED_AT, + FIELD_VERSION_NUMBER, +]; + +/** + * Finds properties missing in an object + * + * @param {Object} obj - some object + * @param {Array|void} [properties] - object properties to check + * @return {Array} comma seperated list of properties missing + */ +function findMissingProperties(obj?: Object, properties: Array = []): Array { + // If file doesn't exist or is an empty object, we should fetch all fields + if (!obj || typeof obj !== 'object' || Object.keys(obj).length === 0) { + return properties; + } + + return properties.filter((field: string) => !has(obj, field)); +} + +/** + * Fill properties missing in an object + * + * @param {Object} obj - some object + * @param {Array|void} [properties] - some properties to check + * @return {Object} new object with missing fields + */ +function fillMissingProperties(obj: Object = {}, properties?: Array): Object { + // If file doesn't exist or is an empty object, we should fetch all fields + if (!Array.isArray(properties) || properties.length === 0) { + return obj; + } + + const newObj = { ...obj }; + const missingProperties = findMissingProperties(obj, properties); + missingProperties.forEach((field: string) => { + // @Note: This will overwrite non object fields + // @Note: We don't know the type of the field + setProp(newObj, field, null); + }); + return newObj; +} + +/** + * Fill user properties that are null in an object + * + * @param {Object} obj - some object + * @return {Object} new object with user placeholder + */ +function fillUserPlaceholder(obj: Object): Object { + const newObj = { ...obj }; + + USER_FIELDS.forEach((field: string) => { + if (has(newObj, field) && newObj[field] === null) { + setProp(newObj, field, PLACEHOLDER_USER); + } + }); + + return newObj; +} + +export { + APP_ACTIVITY_FIELDS_TO_FETCH, + COMMENTS_FIELDS_TO_FETCH, + FEED_FILE_VERSIONS_FIELDS_TO_FETCH, + FILE_SHARED_LINK_FIELDS_TO_FETCH, + FILE_VERSION_FIELDS_TO_FETCH, + FILE_VERSION_FIELDS_TO_FETCH_ARCHIVE, + FILE_VERSIONS_FIELDS_TO_FETCH, + fillMissingProperties, + fillUserPlaceholder, + findMissingProperties, + FOLDER_FIELDS_TO_FETCH, + PREVIEW_FIELDS_TO_FETCH, + SIDEBAR_FIELDS_TO_FETCH, + SIDEBAR_FIELDS_TO_FETCH_ARCHIVE, + TASK_ASSIGNMENTS_FIELDS_TO_FETCH, + TASKS_FIELDS_TO_FETCH, + USER_FIELDS, + UPLOADER_FIELDS_TO_FETCH, +}; diff --git a/src/utils/file.js b/src/utils/file.js.flow similarity index 97% rename from src/utils/file.js rename to src/utils/file.js.flow index cc352d101e..db1e56d47f 100644 --- a/src/utils/file.js +++ b/src/utils/file.js.flow @@ -76,7 +76,7 @@ export function isGSuiteExtension(extension: string): boolean { * @param {string} filename a Box file * @return {string} typed id for file */ -export function getFileExtension(filename: string | void): string { +export function getFileExtension(filename?: string): string { if (typeof filename !== 'string') { return ''; } diff --git a/src/utils/file.ts b/src/utils/file.ts new file mode 100644 index 0000000000..7d4d9fcb52 --- /dev/null +++ b/src/utils/file.ts @@ -0,0 +1,58 @@ +import getProp from 'lodash/get'; +import { + TYPED_ID_FILE_PREFIX, + TYPED_ID_FOLDER_PREFIX, + FILE_EXTENSION_BOX_CANVAS, + FILE_EXTENSION_BOX_NOTE, + FILE_EXTENSION_GOOGLE_DOC, + FILE_EXTENSION_GOOGLE_SHEET, + FILE_EXTENSION_GOOGLE_SLIDE, + FILE_EXTENSION_GOOGLE_SLIDE_LEGACY, +} from '../constants'; +import type { BoxItem } from '../common/types/core'; + +const FILE_EXT_REGEX = /\.([0-9a-z]+)$/i; // Case insensitive regex to extract file extension without "." + +/** + * Returns typed id for file. Useful for when + * making file based XHRs where auth token + * can be per file as used by Preview. + */ +export function getTypedFileId(id: string): string { + return `${TYPED_ID_FILE_PREFIX}${id}`; +} + +/** Returns typed id for folder. */ +export function getTypedFolderId(id: string): string { + return `${TYPED_ID_FOLDER_PREFIX}${id}`; +} + +/** Determines if the file is a box note. */ +export function isBoxNote(file: BoxItem): boolean { + return file.extension === FILE_EXTENSION_BOX_NOTE; +} + +/** Determines if the file is box canvas. */ +export function isBoxCanvas(file: BoxItem): boolean { + return file.extension === FILE_EXTENSION_BOX_CANVAS; +} + +/** Determines whether a file extension is associated with a G Suite file. */ +export function isGSuiteExtension(extension: string): boolean { + return ( + extension === FILE_EXTENSION_GOOGLE_DOC || + extension === FILE_EXTENSION_GOOGLE_SHEET || + extension === FILE_EXTENSION_GOOGLE_SLIDE || + extension === FILE_EXTENSION_GOOGLE_SLIDE_LEGACY + ); +} + +/** Returns the extension from the file name. */ +export function getFileExtension(filename?: string): string { + if (typeof filename !== 'string') { + return ''; + } + + const result = FILE_EXT_REGEX.exec(filename); + return getProp(result, '[1]', ''); +} diff --git a/src/utils/flatten.js b/src/utils/flatten.js.flow similarity index 100% rename from src/utils/flatten.js rename to src/utils/flatten.js.flow diff --git a/src/utils/flatten.ts b/src/utils/flatten.ts new file mode 100644 index 0000000000..3e1378ae0c --- /dev/null +++ b/src/utils/flatten.ts @@ -0,0 +1,55 @@ +import { getBadItemError } from './error'; +import { TYPE_FOLDER, TYPE_FILE, TYPE_WEBLINK } from '../constants'; +import type { BoxItem } from '../common/types/core'; +import type FolderAPI from '../api/Folder'; +import type FileAPI from '../api/File'; +import type WebLinkAPI from '../api/WebLink'; +import type APICache from './Cache'; + +/** + * Takes an item list and flattens it by moving + * all item entries into the cache and replacing the list + * entries with references to those items in the cache. + * Web links are trated as files. + */ +export default function flatten( + list: BoxItem[], + folderAPI: FolderAPI, + fileAPI: FileAPI, + weblinkAPI: WebLinkAPI, +): string[] { + const items: string[] = []; + list.forEach((item: BoxItem) => { + const { id, type }: BoxItem = item; + if (!id || !type) { + throw getBadItemError(); + } + + let api; + switch (type) { + case TYPE_FOLDER: + api = folderAPI; + break; + case TYPE_FILE: + api = fileAPI; + break; + case TYPE_WEBLINK: + api = weblinkAPI; + break; + default: + throw new Error('Unknown Type!'); + } + + const cache: APICache = api.getCache(); + const key: string = api.getCacheKey(id); + + if (cache.has(key)) { + cache.merge(key, item); + } else { + cache.set(key, item); + } + + items.push(key); + }); + return items; +} diff --git a/src/utils/function.js b/src/utils/function.js.flow similarity index 100% rename from src/utils/function.js rename to src/utils/function.js.flow diff --git a/src/utils/function.ts b/src/utils/function.ts new file mode 100644 index 0000000000..60f2af0c12 --- /dev/null +++ b/src/utils/function.ts @@ -0,0 +1,53 @@ +/** + * Wrapper around the promises.create() method to allow a promise to retry + * multiple times. A third parameter (besides resolve and reject) is passed + * that allows the function to cancel retrying and immediately reject. + * + * @param {function} func - The function that performs the operation as a promise. + * @param {number} times - Maximum number of times the operation should be attempted. + * @param {number} [initialTimeout] - Optional timeout to retry the promise with after it fails, in milliseconds. + * Otherwise, the input `func` is invoked after 1 event loop. + * @param {number} [backoffFactor] - Optional exponential backoff factor to retry the promise with after it fails + * @return {Promise} Promise - proxies the promise of the passed function. + */ + +function retryNumOfTimes( + func: Function, + times: number, + initialTimeout: number = 0, + backoffFactor: number = 1, +): Promise { + let tries = 0; + let timeout = initialTimeout; + + return new Promise((resolve, hardReject) => { + function doTry() { + tries += 1; + + new Promise((tryResolve, tryReject) => { + func(tryResolve, tryReject, hardReject); + }) + .then(resolve) + .catch(reason => { + if (tries < times) { + timeout *= backoffFactor; + // eslint-disable-next-line @typescript-eslint/no-use-before-define + executeAfterTimeout(timeout); + return; + } + + hardReject(reason); + }); + } + + function executeAfterTimeout(time: number): void { + setTimeout(() => { + doTry(); + }, time); + } + + executeAfterTimeout(timeout); + }); +} + +export { retryNumOfTimes }; diff --git a/src/utils/fuzzySearch.js b/src/utils/fuzzySearch.js.flow similarity index 100% rename from src/utils/fuzzySearch.js rename to src/utils/fuzzySearch.js.flow diff --git a/src/utils/fuzzySearch.ts b/src/utils/fuzzySearch.ts new file mode 100644 index 0000000000..d0f52268b7 --- /dev/null +++ b/src/utils/fuzzySearch.ts @@ -0,0 +1,54 @@ +const fuzzySearch = ( + search: string, + content?: string | null, + minCharacters: number = 3, + maxGaps: number = 2, +): boolean => { + if (!content) { + return false; + } + const uniformContent = content.toLowerCase().replace(/\s/g, ''); + const uniformSearch = search.toLowerCase().replace(/\s/g, ''); + const contentLength = uniformContent.length; + const searchLength = uniformSearch.length; + if (searchLength < minCharacters || searchLength > contentLength) { + return false; + } + let matched = false; + let totalScore = 0; + for (let i = 0; i < contentLength; i += 1) { + if (contentLength - i < searchLength) { + break; + } + let searchIndex = 0; + let currentScore = 0; + let subScore = 0; + for (let j = i; j < contentLength; j += 1) { + if (uniformContent[j] === uniformSearch[searchIndex]) { + searchIndex += 1; + // For streaks of matched characters score should increase exponentially + currentScore += 1 + currentScore; + } else { + currentScore = 0; + } + subScore += currentScore; + } + if (searchIndex !== searchLength) { + break; + } + if (subScore > totalScore) { + totalScore = subScore; + } + } + if (totalScore > 0) { + const maxGroups = Math.min(maxGaps, searchLength); + // minScore is calculated as a near-worst-case score given an even distribution of gaps + // since the algorithm rewards streak of characters breaking them up evenly is the worst case + // minimum score should also be better than just each character individually + const minScore = Math.max(maxGroups * 2 ** Math.floor(searchLength / maxGroups - 1), searchLength + 1); + matched = totalScore >= minScore; + } + return matched; +}; + +export default fuzzySearch; diff --git a/src/utils/getFileSize.js b/src/utils/getFileSize.js.flow similarity index 100% rename from src/utils/getFileSize.js rename to src/utils/getFileSize.js.flow diff --git a/src/utils/getFileSize.ts b/src/utils/getFileSize.ts new file mode 100644 index 0000000000..4feec41877 --- /dev/null +++ b/src/utils/getFileSize.ts @@ -0,0 +1,31 @@ +import filesize from 'filesize'; + +const defaultDigitalUnits = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; +const bcp47TagToDigitalUnits: Record = { + fi: ['t', 'kt', 'Mt', 'Gt', 'Tt', 'Pt', 'Et', 'Zt', 'Yt'], + fr: ['o', 'Ko', 'Mo', 'Go', 'To', 'Po', 'Eo', 'Zo', 'Yo'], + ru: ['Б', 'КБ', 'МБ', 'ГБ', 'ТБ', 'ПБ', 'ЭБ', 'ЗБ', 'ЙБ'], +}; + +/** + * Formats a file size from number of bytes to a human-readable, localized string. + * @param {number} size Number of bytes + * @param {string} [locale] Optional locale, defaults to 'en' + * @returns {string} The size as a localized string + */ +const getFileSize = (size: number, locale: string = 'en'): string => { + const settings: { round: number; locale: string; symbols?: Record } = { round: 1, locale }; + + const localizedUnits = bcp47TagToDigitalUnits[locale]; + if (localizedUnits) { + // map default units to localized units, ex. { B: Б, KB: КБ, ... } + settings.symbols = defaultDigitalUnits.reduce>((symbols, unit, index) => { + symbols[unit] = localizedUnits[index]; + return symbols; + }, {}); + } + + return filesize(size, settings); +}; + +export default getFileSize; diff --git a/src/utils/hex.js b/src/utils/hex.js.flow similarity index 100% rename from src/utils/hex.js rename to src/utils/hex.js.flow diff --git a/src/utils/hex.ts b/src/utils/hex.ts new file mode 100644 index 0000000000..85f064e7d4 --- /dev/null +++ b/src/utils/hex.ts @@ -0,0 +1,14 @@ +/** + * Converts an array buffer to Hex + * + * @param {Uint8Array} arrayBuffer The buffer to convert + * @return {string} The hex converted value + */ +function bufferToHex(arrayBuffer: Uint8Array): string { + return Array.from(arrayBuffer, byte => + // eslint-disable-next-line no-bitwise + `0${(byte & 0xff).toString(16)}`.slice(-2), + ).join(''); +} + +export { bufferToHex }; diff --git a/src/utils/iframe.js b/src/utils/iframe.js.flow similarity index 100% rename from src/utils/iframe.js rename to src/utils/iframe.js.flow diff --git a/src/utils/iframe.ts b/src/utils/iframe.ts new file mode 100644 index 0000000000..919747f50a --- /dev/null +++ b/src/utils/iframe.ts @@ -0,0 +1,37 @@ +/** + * Creates an empty iframe or uses an existing one + * for the purposes of downloading or printing + * + * @private + */ +function createDownloadIframe(): HTMLIFrameElement { + let iframe: HTMLIFrameElement = document.querySelector('#boxdownloadiframe') as HTMLIFrameElement; + if (!iframe) { + // if no existing iframe create a new one + iframe = document.createElement('iframe'); + iframe.setAttribute('id', 'boxdownloadiframe'); + iframe.style.display = 'none'; + if (document.body) { + document.body.appendChild(iframe); + } + } + + // If the iframe previously failed to load contentDocument will be null + if (iframe.contentDocument) { + // Clean the iframe up + iframe.contentDocument.write(''); + } + return iframe; +} + +/** + * Opens url in an iframe + * Used for downloads + * + * @param {string} url - URL to open + */ +export default function openUrlInsideIframe(url: string): HTMLIFrameElement { + const iframe: HTMLIFrameElement = createDownloadIframe(); + iframe.src = url; + return iframe; +} diff --git a/src/utils/keys.js b/src/utils/keys.js.flow similarity index 100% rename from src/utils/keys.js rename to src/utils/keys.js.flow diff --git a/src/utils/keys.ts b/src/utils/keys.ts new file mode 100644 index 0000000000..ede90fb548 --- /dev/null +++ b/src/utils/keys.ts @@ -0,0 +1,85 @@ +/** + * Function to decode key events into keys. + * Works for both React synthetic and native events. + * + * Will output in the format Shift+I, Control+I... + * Will outpur Space for spacebar. + * Will return empty string for modifiers only. + * + * @public + * @return {string} Decoded keydown key or empty string + */ +// TODO: restore KeyboardEvent | React.KeyboardEvent once DOM types include Safari keyIdentifier +// and callers/tests pass real events. Structural type is the fields decode actually reads; +// the original union omitted keyIdentifier (needed $FlowFixMe) and rejected partial event objects. +function decode(event: { + key?: string; + keyIdentifier?: string; + ctrlKey?: boolean; + shiftKey?: boolean; + metaKey?: boolean; +}): string { + let modifier = ''; + + // KeyboardEvent.key is the new spec supported in Chrome, Firefox and IE. + // KeyboardEvent.keyIdentifier is the old spec supported in Safari. + // Priority is given to the new spec. + const { keyIdentifier } = event; + let key: string = event.key || keyIdentifier || ''; + + // Get the modifiers on their own + if (event.ctrlKey) { + modifier = 'Control'; + } else if (event.shiftKey) { + modifier = 'Shift'; + } else if (event.metaKey) { + modifier = 'Meta'; + } + + // The key and keyIdentifier specs also include modifiers. + // Since we are manually getting the modifiers above we do + // not want to trap them again here. + if (key === modifier) { + key = ''; + } + + // keyIdentifier spec returns UTF8 char codes + // Need to convert them back to ascii. + if (key.indexOf('U+') === 0) { + if (key === 'U+001B') { + key = 'Escape'; + } else { + key = String.fromCharCode(Number(key.replace('U+', '0x'))); + } + } + + // If nothing was pressed or we evaluated to nothing, just return + if (!key) { + return ''; + } + + // Special casing for space bar + if (key === ' ') { + key = 'Space'; + } + + // Edge bug which outputs "Esc" instead of "Escape" + // https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/5290772/ + if (key === 'Esc') { + key = 'Escape'; + } + + // keyIdentifier spec does not prefix the word Arrow. + // Newer key spec does it automatically. + if (key === 'Right' || key === 'Left' || key === 'Down' || key === 'Up') { + key = `Arrow${key}`; + } + + if (modifier) { + modifier += '+'; + } + + return modifier + key; +} + +export { decode }; diff --git a/src/utils/parseCSV.js b/src/utils/parseCSV.js.flow similarity index 100% rename from src/utils/parseCSV.js rename to src/utils/parseCSV.js.flow diff --git a/src/utils/parseCSV.ts b/src/utils/parseCSV.ts new file mode 100644 index 0000000000..31a659b3df --- /dev/null +++ b/src/utils/parseCSV.ts @@ -0,0 +1,43 @@ +/** + * Parse a comma separated values text and return an array of separated strings + * + * @param {string} text The input string + * @return {string[]} A list of separated strings + * + * @example + * parse('a, b, "c, d"') + * returns ["a", "b", "c, d"] + */ +function parseCSV(text?: string | null): string[] { + if (text === null || typeof text === 'undefined') { + // Input text is either null or undefined + return []; + } + + // Convert the comma separated text into array + // + // The logic of the regular expression is simple + // look ahead comma or carriage return and retrieve: + // 1. either strings that are surrounded by double quotes + // 2. or strings that do not contain comma and carriage return + const components = text.match(/(".*?"|[^",\r\n]+)(?=\s*[,\r\n]|\s*$)/g); + if (!components) { + // No match pattern is found + return []; + } + + return components.map(c => { + // Trim the leading and trailing spaces + c = c.trim(); + + // Remove double quote pairs from both ends + // example '"""abc"""' will be altered to 'abc' + while (c.length >= 2 && c.charAt(0) === '"' && c.charAt(c.length - 1) === '"') { + c = c.substr(1, c.length - 2); + } + + return c; + }); +} + +export default parseCSV; diff --git a/src/utils/parseEmails.js b/src/utils/parseEmails.js.flow similarity index 100% rename from src/utils/parseEmails.js rename to src/utils/parseEmails.js.flow diff --git a/src/utils/parseEmails.ts b/src/utils/parseEmails.ts new file mode 100644 index 0000000000..26609b8334 --- /dev/null +++ b/src/utils/parseEmails.ts @@ -0,0 +1,53 @@ +/** + * Parse a string containing email addresses and potential contact information + * or delimiters and return an array of email addresses + * + * @param {string} text The input string + * @return {string[]} A list of separated emails + * + * @example + * parseEmails('Foo Bar ; Test User ') + * returns ["fbar@example.com","test@example.com"] + */ +function parseEmails(text?: string | null): string[] { + if (text === null || typeof text === 'undefined') { + // Input text is either null or undefined + return []; + } + + const emails = text.match(/[^\s[<(]+@[^\s<>@,/\\]+\.[^\s<>,;)]+/gi); + + if (!emails) { + // No match pattern is found + return []; + } + + return emails.map(c => { + // Trim the leading and trailing spaces + c = c.trim(); + + // Remove double quote pairs from both ends + // example '"""abc"""' will be altered to 'abc' + while (c.length >= 2 && c.charAt(0) === '"' && c.charAt(c.length - 1) === '"') { + c = c.substr(1, c.length - 2); + } + + return c; + }); +} + +/** + * Check if an email belongs to an external collaborator. + * External collaborator icons will only be displayed in the USM if the current user owns + * the item and if the collaborator's email domain differs from the owner's email domain. + */ +export const checkIsExternalUser = ( + isCurrentUserOwner: boolean, + ownerEmailDomain: string | null, + emailToCheck?: string, +): boolean => { + if (!emailToCheck || !ownerEmailDomain || !isCurrentUserOwner) return false; + return emailToCheck.split('@')[1] !== ownerEmailDomain; +}; + +export default parseEmails; diff --git a/src/utils/performance.js b/src/utils/performance.js.flow similarity index 100% rename from src/utils/performance.js rename to src/utils/performance.js.flow diff --git a/src/utils/performance.ts b/src/utils/performance.ts new file mode 100644 index 0000000000..9e7c39069d --- /dev/null +++ b/src/utils/performance.ts @@ -0,0 +1,7 @@ +import getProp from 'lodash/get'; + +const isMarkSupported = typeof getProp(window, 'performance.mark') === 'function'; + +const mark = (markName: string) => isMarkSupported && window.performance.mark(markName); + +export { mark, isMarkSupported }; diff --git a/src/utils/relativeTime.js b/src/utils/relativeTime.js.flow similarity index 100% rename from src/utils/relativeTime.js rename to src/utils/relativeTime.js.flow diff --git a/src/utils/relativeTime.ts b/src/utils/relativeTime.ts new file mode 100644 index 0000000000..d149e916d1 --- /dev/null +++ b/src/utils/relativeTime.ts @@ -0,0 +1,30 @@ +// Helper function used to calculate relative time (for use with react-intl) + +const WEEK_IN_MS = 6.048e8; +const DAY_IN_MS = 8.64e7; +const HOUR_IN_MS = 3.6e6; +const MIN_IN_MS = 6e4; +const SEC_IN_MS = 1e3; +const YEAR_IN_MS = 3.154e10; + +const timeFromNow = (ms: number): { value: number; unit: Intl.RelativeTimeFormatUnit } => { + const diff = ms - Date.now(); + if (Math.abs(diff) >= YEAR_IN_MS) { + return { value: Math.trunc(diff / YEAR_IN_MS), unit: 'year' }; + } + if (Math.abs(diff) >= WEEK_IN_MS) { + return { value: Math.trunc(diff / WEEK_IN_MS), unit: 'week' }; + } + if (Math.abs(diff) >= DAY_IN_MS) { + return { value: Math.trunc(diff / DAY_IN_MS), unit: 'day' }; + } + if (Math.abs(diff) >= HOUR_IN_MS) { + return { value: Math.trunc(diff / HOUR_IN_MS), unit: 'hour' }; + } + if (Math.abs(diff) >= MIN_IN_MS) { + return { value: Math.trunc(diff / MIN_IN_MS), unit: 'minute' }; + } + return { value: Math.trunc(diff / SEC_IN_MS), unit: 'second' }; +}; + +export default timeFromNow; diff --git a/src/utils/sleep.js b/src/utils/sleep.js.flow similarity index 100% rename from src/utils/sleep.js rename to src/utils/sleep.js.flow diff --git a/src/utils/sleep.ts b/src/utils/sleep.ts new file mode 100644 index 0000000000..68a04c61f9 --- /dev/null +++ b/src/utils/sleep.ts @@ -0,0 +1,2 @@ +const sleep = (ms: number): Promise => new Promise(resolve => setTimeout(resolve, ms)); +export default sleep; diff --git a/src/utils/sorter.js b/src/utils/sorter.js.flow similarity index 100% rename from src/utils/sorter.js rename to src/utils/sorter.js.flow diff --git a/src/utils/sorter.ts b/src/utils/sorter.ts new file mode 100644 index 0000000000..1f6d3a7940 --- /dev/null +++ b/src/utils/sorter.ts @@ -0,0 +1,77 @@ +import comparator from './comparator'; +import { getBadItemError } from './error'; +import type { Annotations, AppActivityItems, Comments, FeedItems, Tasks, ThreadedComments } from '../common/types/feed'; +import type { + SortBy, + SortDirection, + Order, + FlattenedBoxItem, + FlattenedBoxItemCollection, + FileVersions, +} from '../common/types/core'; +import type APICache from './Cache'; + +function isSortingNeeded(order: Order[] | null | undefined, sortBy: SortBy, sortDirection: SortDirection): boolean { + return !Array.isArray(order) || !order.some(entry => entry.by === sortBy && entry.direction === sortDirection); +} + +/** + * Sorts items in place + * + * @param {Object} item box item object + * @param {string} sortBy sort by field + * @param {string} sortDirection the sort direction + * @param {Cache} cache item cache + */ +export default function sorter( + item: FlattenedBoxItem, + sortBy: SortBy, + sortDirection: SortDirection, + cache: APICache, +): FlattenedBoxItem { + const { item_collection }: FlattenedBoxItem = item; + if (!item_collection) { + throw getBadItemError(); + } + + const { entries, order }: FlattenedBoxItemCollection = item_collection; + if (!Array.isArray(entries)) { + throw getBadItemError(); + } + + if (isSortingNeeded(order, sortBy, sortDirection)) { + entries.sort(comparator(sortBy, sortDirection, cache)); + item_collection.order = [ + { + by: sortBy, + direction: sortDirection, + }, + ]; + } + + return item; +} + +/** + * Sort valid feed items, descending by created_at time. + * + * @param {Array} args - Arguments list of each item container + * type that is allowed in the feed. + */ +export function sortFeedItems( + ...args: Array< + Comments | ThreadedComments | Tasks | FileVersions | AppActivityItems | Annotations | null | undefined + > +): FeedItems { + const feedItems: FeedItems = args + .reduce((items, itemContainer) => { + if (itemContainer) { + return items.concat(itemContainer.entries); + } + + return items; + }, []) + .sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at)); + + return feedItems; +} diff --git a/src/utils/storybook.js b/src/utils/storybook.js.flow similarity index 100% rename from src/utils/storybook.js rename to src/utils/storybook.js.flow diff --git a/src/utils/storybook.ts b/src/utils/storybook.ts new file mode 100644 index 0000000000..66fcd396ca --- /dev/null +++ b/src/utils/storybook.ts @@ -0,0 +1,18 @@ +export const addRootElement = () => { + let appElement = document.getElementById('appElement'); + let rootElement = document.getElementById('rootElement'); + + if (document.body && rootElement === null) { + rootElement = document.createElement('div'); + rootElement.setAttribute('id', 'rootElement'); + document.body.appendChild(rootElement); + } + if (rootElement !== null && appElement === null) { + appElement = document.createElement('div'); + appElement.setAttribute('id', 'appElement'); + rootElement.appendChild(appElement); + } + return { appElement, rootElement }; +}; + +export const SLEEP_TIMEOUT = 500; diff --git a/src/utils/uploads.js b/src/utils/uploads.js.flow similarity index 100% rename from src/utils/uploads.js rename to src/utils/uploads.js.flow diff --git a/src/utils/uploads.ts b/src/utils/uploads.ts new file mode 100644 index 0000000000..8efaaa2345 --- /dev/null +++ b/src/utils/uploads.ts @@ -0,0 +1,303 @@ +import getProp from 'lodash/get'; + +import Browser from './Browser'; + +import type { + UploadFile, + UploadFileWithAPIOptions, + UploadDataTransferItemWithAPIOptions, + UploadItemAPIOptions, + FileSystemFileEntry, +} from '../common/types/upload'; + +const DEFAULT_API_OPTIONS: UploadItemAPIOptions = {}; + +/** Returns true if file contains API options. */ +function doesFileContainAPIOptions(file: UploadFile | UploadFileWithAPIOptions): boolean { + return !!(file.options && file.file); +} + +/** Returns true if item contains API options. */ +function doesDataTransferItemContainAPIOptions(item: DataTransferItem | UploadDataTransferItemWithAPIOptions): boolean { + return !!(item.options && item.item); +} + +/** Converts UploadFile or UploadFileWithAPIOptions to UploadFile. */ +function getFile(file: UploadFile | UploadFileWithAPIOptions): UploadFile { + if (doesFileContainAPIOptions(file)) { + return (file as UploadFileWithAPIOptions).file; + } + + return file as UploadFile; +} + +/** Converts DataTransferItem or UploadDataTransferItemWithAPIOptions to DataTransferItem. */ +function getDataTransferItem(item: DataTransferItem | UploadDataTransferItemWithAPIOptions): DataTransferItem { + if (doesDataTransferItemContainAPIOptions(item)) { + return (item as UploadDataTransferItemWithAPIOptions).item; + } + + return item as DataTransferItem; +} + +/** Get API Options from file. */ +function getFileAPIOptions(file: UploadFile | UploadFileWithAPIOptions): UploadItemAPIOptions { + if (doesFileContainAPIOptions(file)) { + return (file as UploadFileWithAPIOptions).options || DEFAULT_API_OPTIONS; + } + + return DEFAULT_API_OPTIONS; +} + +/** Get API Options from item. */ +function getDataTransferItemAPIOptions( + item: DataTransferItem | UploadDataTransferItemWithAPIOptions, +): UploadItemAPIOptions { + if (doesDataTransferItemContainAPIOptions(item)) { + return (item as UploadDataTransferItemWithAPIOptions).options || DEFAULT_API_OPTIONS; + } + + return DEFAULT_API_OPTIONS; +} + +/** + * Returns true if the given object is a Date instance encoding a valid date + * (i.e. new Date('this is not a timestamp') should return false). + * + * Code adapted from + * http://stackoverflow.com/questions/1353684/detecting-an-invalid-date-date-instance-in-javascript + */ +function isValidDateObject(date: Date): boolean { + return Object.prototype.toString.call(date) === '[object Date]' && !Number.isNaN(date.getTime()); +} + +/** Remove milliseconds from date time string. */ +function toISOStringNoMS(date: Date): string { + return date.toISOString().replace(/\.[0-9]{3}/, ''); +} + +/** + * Returns the file's last modified date as an ISO string with no MS component (e.g. + * '2017-04-18T17:14:27Z'), or null if no such date can be extracted from the file object. + * (Nothing on the Internet guarantees that the file object has this info.) + */ +function getFileLastModifiedAsISONoMSIfPossible(file: UploadFile): string | null | undefined { + // The compatibility chart at https://developer.mozilla.org/en-US/docs/Web/API/File/lastModified#Browser_compatibility + // is not up to date as of 12-13-2018. Edge & ie11 do not support lastModified, but support lastModifiedDate. + const lastModified = file.lastModified || file.lastModifiedDate; + if (lastModified) { + let lastModifiedDate: Date | null = null; + + if (typeof lastModified === 'number') { + // Only non-negative timestamps are valid. In rare cases, the timestamp may be erroneously set to a negative value + // https://issues.chromium.org/issues/393149335 + if (lastModified < 0) { + return null; + } + lastModifiedDate = new Date(lastModified); // Try number first + } else if (typeof lastModified === 'string' || lastModified instanceof Date) { + lastModifiedDate = new Date(lastModified); + } + + if (lastModifiedDate && isValidDateObject(lastModifiedDate)) { + const isoString = toISOStringNoMS(lastModifiedDate); + return isoString; + } + } + + return null; +} + +/** + * If maybeJson is valid JSON string, return the result of calling JSON.parse + * on it. Otherwise, return null. + */ +function tryParseJson(maybeJson: string): unknown { + try { + return JSON.parse(maybeJson); + } catch (e) { + return null; + } +} + +/** + * Get bounded exponential backoff retry delay + * + * @param {number} initialRetryDelay + * @param {number} maxRetryDelay + * @param {number} retryNum - Current retry number (first retry will have value of 0). + */ +function getBoundedExpBackoffRetryDelay(initialRetryDelay: number, maxRetryDelay: number, retryNum: number): number { + const delay = initialRetryDelay * retryNum ** 2; + return delay > maxRetryDelay ? maxRetryDelay : delay; +} + +/** Get entry from dataTransferItem. */ +function getEntryFromDataTransferItem(item: DataTransferItem): FileSystemFileEntry { + const itemWithEntry = item as DataTransferItem & { + mozGetAsEntry?: () => FileSystemFileEntry; + getAsEntry?: () => FileSystemFileEntry; + }; + const entry = item.webkitGetAsEntry || itemWithEntry.mozGetAsEntry || itemWithEntry.getAsEntry; + + return entry.call(item); +} + +/** Check if a dataTransferItem is a folder. */ +function isDataTransferItemAFolder(itemData: UploadDataTransferItemWithAPIOptions | DataTransferItem): boolean { + const item = getDataTransferItem(itemData); + const entry = getEntryFromDataTransferItem(item as DataTransferItem); + if (!entry) { + return false; + } + + return entry.isDirectory; +} + +/** + * Check if a dataTransfer item is a macOS "package file" + * @see https://en.wikipedia.org/wiki/Package_(macOS) + */ +function isDataTransferItemAPackage(itemData: UploadDataTransferItemWithAPIOptions | DataTransferItem): boolean { + const item = getDataTransferItem(itemData); + const isDirectory = isDataTransferItemAFolder(item); + + return isDirectory && item.type === 'application/zip' && item.kind === 'file'; +} + +/** Get file from FileSystemFileEntry. */ +function getFileFromEntry(entry: FileSystemFileEntry): Promise { + return new Promise(resolve => { + entry.file(file => { + resolve(file); + }); + }); +} + +/** + * Get file from DataTransferItem or UploadDataTransferItemWithAPIOptions + */ +async function getFileFromDataTransferItem( + itemData: UploadDataTransferItemWithAPIOptions | DataTransferItem, +): Promise { + const item = getDataTransferItem(itemData); + const entry = getEntryFromDataTransferItem(item as DataTransferItem); + if (!entry) { + return null; + } + + const file = await getFileFromEntry(entry); + + if (doesDataTransferItemContainAPIOptions(itemData)) { + return { + file: file as UploadFile, + options: getDataTransferItemAPIOptions(itemData), + }; + } + + return file; +} + +/** + * Get file from DataTransferItem or UploadDataTransferItemWithAPIOptions + * Uses `entry`'s `getAsFile` method for retrieving package information as a single file. + * @see https://en.wikipedia.org/wiki/Package_(macOS) + */ +function getPackageFileFromDataTransferItem( + itemData: UploadDataTransferItemWithAPIOptions | DataTransferItem, +): UploadFile | UploadFileWithAPIOptions | null { + const item = getDataTransferItem(itemData); + const entry = getEntryFromDataTransferItem(item as DataTransferItem); + if (!entry) { + return null; + } + + const itemFile = item.getAsFile(); + + if (doesDataTransferItemContainAPIOptions(itemData)) { + return { + file: itemFile as UploadFile, + options: getDataTransferItemAPIOptions(itemData), + }; + } + + return itemFile; +} + +/** + * Generates file id based on file properties + * + * When folderId or uploadInitTimestamp is missing from file options, file name is returned as file id. + * Otherwise, fileName_folderId_uploadInitTimestamp is used as file id. + */ +function getFileId(file: UploadFileWithAPIOptions | UploadFile, rootFolderId: string): string { + if (!doesFileContainAPIOptions(file)) { + return (file as UploadFile).name; + } + + const fileWithOptions = file as UploadFileWithAPIOptions; + const folderId = getProp(fileWithOptions, 'options.folderId', rootFolderId); + const uploadInitTimestamp = getProp(fileWithOptions, 'options.uploadInitTimestamp', Date.now()); + const fileName = fileWithOptions.file.webkitRelativePath || fileWithOptions.file.name; + + return `${fileName}_${folderId}_${uploadInitTimestamp}`; +} + +/** + * Generates item id based on item properties + * + * When item options including folderId or uploadInitTimestamp are missing, item name is returned as item id. + * Otherwise, item properties are used as item id. + * E.g., folder1_0_123124124 + */ +function getDataTransferItemId( + itemData: DataTransferItem | UploadDataTransferItemWithAPIOptions, + rootFolderId: string, +): string { + const item = getDataTransferItem(itemData); + const { name } = getEntryFromDataTransferItem(item); + if (!doesDataTransferItemContainAPIOptions(itemData)) { + return name; + } + + const { folderId = rootFolderId, uploadInitTimestamp = Date.now() } = getDataTransferItemAPIOptions(itemData); + + return `${name}_${folderId}_${uploadInitTimestamp}`; +} + +/** + * Multiput uploads require the use of crypto, which is only supported in secure contexts. + * Multiput uploads is not supported on mobile iOS Safari. + */ +function isMultiputSupported(): boolean { + const cryptoObj = window.crypto || (window as Window & { msCrypto?: Crypto }).msCrypto; + + if (Browser.isMobileSafari()) { + return false; + } + + return window.location.protocol === 'https:' && !!cryptoObj && !!cryptoObj.subtle; +} + +export { + DEFAULT_API_OPTIONS, + doesDataTransferItemContainAPIOptions, + doesFileContainAPIOptions, + getBoundedExpBackoffRetryDelay, + getDataTransferItem, + getDataTransferItemAPIOptions, + getDataTransferItemId, + getEntryFromDataTransferItem, + getFile, + getFileAPIOptions, + getFileFromDataTransferItem, + getPackageFileFromDataTransferItem, + getFileFromEntry, + getFileId, + getFileLastModifiedAsISONoMSIfPossible, + isDataTransferItemAFolder, + isDataTransferItemAPackage, + isMultiputSupported, + toISOStringNoMS, + tryParseJson, +}; diff --git a/src/utils/uploadsSHA1Worker.js b/src/utils/uploadsSHA1Worker.js.flow similarity index 100% rename from src/utils/uploadsSHA1Worker.js rename to src/utils/uploadsSHA1Worker.js.flow diff --git a/src/utils/uploadsSHA1Worker.ts b/src/utils/uploadsSHA1Worker.ts new file mode 100644 index 0000000000..d25e47e80a --- /dev/null +++ b/src/utils/uploadsSHA1Worker.ts @@ -0,0 +1,240 @@ +/* eslint-disable */ +// @ts-nocheck +/* + * Rusha, a JavaScript implementation of the Secure Hash Algorithm, SHA-1, + * as defined in FIPS PUB 180-1, tuned for high performance with large inputs. + * (http://github.com/srijs/rusha) + * + * Inspired by Paul Johnstons implementation (http://pajhome.org.uk/crypt/md5). + * + * Copyright (c) 2013 Sam Rijs (http://awesam.de). + * Released under the terms of the MIT license as follows: + * + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ + +// The low-level RushCore module provides the heart of Rusha, +// a high-speed sha1 implementation working on an Int32Array heap. +// At first glance, the implementation seems complicated, however +// with the SHA1 spec at hand, it is obvious this almost a textbook +// implementation that has a few functions hand-inlined and a few loops +// hand-unrolled. +function RushaCore(stdlib, foreign, heap) { + 'use asm'; + var H = new stdlib.Int32Array(heap); + function hash(k, x) { + // k in bytes + k = k | 0; + x = x | 0; + var i = 0, + j = 0, + y0 = 0, + z0 = 0, + y1 = 0, + z1 = 0, + y2 = 0, + z2 = 0, + y3 = 0, + z3 = 0, + y4 = 0, + z4 = 0, + t0 = 0, + t1 = 0; + y0 = H[(x + 320) >> 2] | 0; + y1 = H[(x + 324) >> 2] | 0; + y2 = H[(x + 328) >> 2] | 0; + y3 = H[(x + 332) >> 2] | 0; + y4 = H[(x + 336) >> 2] | 0; + for (i = 0; (i | 0) < (k | 0); i = (i + 64) | 0) { + z0 = y0; + z1 = y1; + z2 = y2; + z3 = y3; + z4 = y4; + for (j = 0; (j | 0) < 64; j = (j + 4) | 0) { + t1 = H[(i + j) >> 2] | 0; + t0 = + (((((y0 << 5) | (y0 >>> 27)) + ((y1 & y2) | (~y1 & y3))) | 0) + + ((((t1 + y4) | 0) + 1518500249) | 0)) | + 0; + y4 = y3; + y3 = y2; + y2 = (y1 << 30) | (y1 >>> 2); + y1 = y0; + y0 = t0; + H[(k + j) >> 2] = t1; + } + for (j = (k + 64) | 0; (j | 0) < ((k + 80) | 0); j = (j + 4) | 0) { + t1 = + ((H[(j - 12) >> 2] ^ H[(j - 32) >> 2] ^ H[(j - 56) >> 2] ^ H[(j - 64) >> 2]) << 1) | + ((H[(j - 12) >> 2] ^ H[(j - 32) >> 2] ^ H[(j - 56) >> 2] ^ H[(j - 64) >> 2]) >>> 31); + t0 = + (((((y0 << 5) | (y0 >>> 27)) + ((y1 & y2) | (~y1 & y3))) | 0) + + ((((t1 + y4) | 0) + 1518500249) | 0)) | + 0; + y4 = y3; + y3 = y2; + y2 = (y1 << 30) | (y1 >>> 2); + y1 = y0; + y0 = t0; + H[j >> 2] = t1; + } + for (j = (k + 80) | 0; (j | 0) < ((k + 160) | 0); j = (j + 4) | 0) { + t1 = + ((H[(j - 12) >> 2] ^ H[(j - 32) >> 2] ^ H[(j - 56) >> 2] ^ H[(j - 64) >> 2]) << 1) | + ((H[(j - 12) >> 2] ^ H[(j - 32) >> 2] ^ H[(j - 56) >> 2] ^ H[(j - 64) >> 2]) >>> 31); + t0 = (((((y0 << 5) | (y0 >>> 27)) + (y1 ^ y2 ^ y3)) | 0) + ((((t1 + y4) | 0) + 1859775393) | 0)) | 0; + y4 = y3; + y3 = y2; + y2 = (y1 << 30) | (y1 >>> 2); + y1 = y0; + y0 = t0; + H[j >> 2] = t1; + } + for (j = (k + 160) | 0; (j | 0) < ((k + 240) | 0); j = (j + 4) | 0) { + t1 = + ((H[(j - 12) >> 2] ^ H[(j - 32) >> 2] ^ H[(j - 56) >> 2] ^ H[(j - 64) >> 2]) << 1) | + ((H[(j - 12) >> 2] ^ H[(j - 32) >> 2] ^ H[(j - 56) >> 2] ^ H[(j - 64) >> 2]) >>> 31); + t0 = + (((((y0 << 5) | (y0 >>> 27)) + ((y1 & y2) | (y1 & y3) | (y2 & y3))) | 0) + + ((((t1 + y4) | 0) - 1894007588) | 0)) | + 0; + y4 = y3; + y3 = y2; + y2 = (y1 << 30) | (y1 >>> 2); + y1 = y0; + y0 = t0; + H[j >> 2] = t1; + } + for (j = (k + 240) | 0; (j | 0) < ((k + 320) | 0); j = (j + 4) | 0) { + t1 = + ((H[(j - 12) >> 2] ^ H[(j - 32) >> 2] ^ H[(j - 56) >> 2] ^ H[(j - 64) >> 2]) << 1) | + ((H[(j - 12) >> 2] ^ H[(j - 32) >> 2] ^ H[(j - 56) >> 2] ^ H[(j - 64) >> 2]) >>> 31); + t0 = (((((y0 << 5) | (y0 >>> 27)) + (y1 ^ y2 ^ y3)) | 0) + ((((t1 + y4) | 0) - 899497514) | 0)) | 0; + y4 = y3; + y3 = y2; + y2 = (y1 << 30) | (y1 >>> 2); + y1 = y0; + y0 = t0; + H[j >> 2] = t1; + } + y0 = (y0 + z0) | 0; + y1 = (y1 + z1) | 0; + y2 = (y2 + z2) | 0; + y3 = (y3 + z3) | 0; + y4 = (y4 + z4) | 0; + } + H[(x + 320) >> 2] = y0; + H[(x + 324) >> 2] = y1; + H[(x + 328) >> 2] = y2; + H[(x + 332) >> 2] = y3; + H[(x + 336) >> 2] = y4; + } + return { hash: hash }; +} +// @NOTE: This function shouldn't be processed by UglifyJsPlugin, related to https://github.com/mishoo/UglifyJS2/issues/2011 +const RushaString = `function Rusha(e){for(var r=function(e){if("string"==typeof e)return"string";if(e instanceof Array)return"array";if("undefined"!=typeof global&&global.Buffer&&global.Buffer.isBuffer(e))return"buffer";if(e instanceof ArrayBuffer)return"arraybuffer";if(e.buffer instanceof ArrayBuffer)return"view";if(e instanceof Blob)return"blob";throw new Error("Unsupported data type.")},n={fill:0},t=function(e){for(e+=9;e%64>0;e+=1);return e},a=function(e,r,n,t,a){var f,s=this,i=a%4,h=(t+i)%4,u=t-h;switch(i){case 0:e[a]=s[n+3];case 1:e[a+1-(i<<1)|0]=s[n+2];case 2:e[a+2-(i<<1)|0]=s[n+1];case 3:e[a+3-(i<<1)|0]=s[n]}if(!(t>2|0]=s[n+f]<<24|s[n+f+1]<<16|s[n+f+2]<<8|s[n+f+3];switch(h){case 3:e[a+u+1|0]=s[n+u+2];case 2:e[a+u+2|0]=s[n+u+1];case 1:e[a+u+3|0]=s[n+u]}}},f=function(e){switch(r(e)){case"string":return function(e,r,n,t,a){var f,s=this,i=a%4,h=(t+i)%4,u=t-h;switch(i){case 0:e[a]=s.charCodeAt(n+3);case 1:e[a+1-(i<<1)|0]=s.charCodeAt(n+2);case 2:e[a+2-(i<<1)|0]=s.charCodeAt(n+1);case 3:e[a+3-(i<<1)|0]=s.charCodeAt(n)}if(!(t>2]=s.charCodeAt(n+f)<<24|s.charCodeAt(n+f+1)<<16|s.charCodeAt(n+f+2)<<8|s.charCodeAt(n+f+3);switch(h){case 3:e[a+u+1|0]=s.charCodeAt(n+u+2);case 2:e[a+u+2|0]=s.charCodeAt(n+u+1);case 1:e[a+u+3|0]=s.charCodeAt(n+u)}}}.bind(e);case"array":case"buffer":return a.bind(e);case"arraybuffer":return a.bind(new Uint8Array(e));case"view":return a.bind(new Uint8Array(e.buffer,e.byteOffset,e.byteLength));case"blob":return function(e,r,n,t,a){var f,s=a%4,i=(t+s)%4,h=t-i,u=new Uint8Array(reader.readAsArrayBuffer(this.slice(n,n+t)));switch(s){case 0:e[a]=u[3];case 1:e[a+1-(s<<1)|0]=u[2];case 2:e[a+2-(s<<1)|0]=u[1];case 3:e[a+3-(s<<1)|0]=u[0]}if(!(t>2|0]=u[f]<<24|u[f+1]<<16|u[f+2]<<8|u[f+3];switch(i){case 3:e[a+h+1|0]=u[h+2];case 2:e[a+h+2|0]=u[h+1];case 1:e[a+h+3|0]=u[h]}}}.bind(e)}},s=new Array(256),i=0;i<256;i++)s[i]=(i<16?"0":"")+i.toString(16);var h=function(e){for(var r=new Uint8Array(e),n=new Array(e.byteLength),t=0;t0)throw new Error("Chunk size must be a multiple of 128 bit");n.offset=0,n.maxChunkLen=e,n.padMaxChunkLen=t(e),n.heap=new ArrayBuffer(function(e){var r;if(e<=65536)return 65536;if(e<16777216)for(r=1;r>2);return function(e,r){var n=new Uint8Array(e.buffer),t=r%4,a=r-t;switch(t){case 0:n[a+3]=0;case 1:n[a+2]=0;case 2:n[a+1]=0;case 3:n[a+0]=0}for(var f=1+(r>>2);f>2]|=128<<24-(f%4<<3),a[14+(2+(f>>2)&-16)]=s/(1<<29)|0,a[15+(2+(f>>2)&-16)]=s<<3,i},o=function(e,r,t,a){f(e)(n.h8,n.h32,r,t,a||0)},d=function(e,r,t,a,f){var s=t;o(e,r,t),f&&(s=c(t,a)),n.core.hash(s,n.padMaxChunkLen)},y=function(e,r){var n=new Int32Array(e,r+320,5),t=new Int32Array(5),a=new DataView(t.buffer);return a.setInt32(0,n[0],!1),a.setInt32(4,n[1],!1),a.setInt32(8,n[2],!1),a.setInt32(12,n[3],!1),a.setInt32(16,n[4],!1),t},w=this.rawDigest=function(e){var r=e.byteLength||e.length||e.size||0;u(n.heap,n.padMaxChunkLen);var t=0,a=n.maxChunkLen;for(t=0;r>t+a;t+=a)d(e,t,a,r,!1);return d(e,t,r-t,r,!0),y(n.heap,n.padMaxChunkLen)};this.digest=this.digestFromString=this.digestFromBuffer=this.digestFromArrayBuffer=function(e){return h(w(e).buffer)},this.resetState=function(){return u(n.heap,n.padMaxChunkLen),this},this.append=function(e){var r,t=0,a=e.byteLength||e.length||e.size||0,f=n.offset%n.maxChunkLen;for(n.offset+=a;t { + /** + * The contents of this function are what execute when the worker is loaded. It + * defines SHA-1 logic, and registers a handler for receiving messages from the window + * that created the worker. + * @returns {void} + */ + function workerBase() { + const fileSha1 = new Rusha(); + fileSha1.resetState(); + let expectedOffset = 0; + + // self inside a worker refers to a DedicatedWorkerGlobalScope + // https://developer.mozilla.org/en-US/docs/Web/API/DedicatedWorkerGlobalScope + self.onmessage = event => { + const { data } = event; + const { part, fileSize, partContents } = data; + + const startTimestamp = Date.now(); + + try { + // Validate that we are receiving parts in order + if (part.offset !== expectedOffset) { + throw new Error('Out of order parts given to worker'); + } + fileSha1.append(partContents); + + // We send the partContents back to the main thread because, at least in Chrome v62, we see + // that this ArrayBuffer is not garbage collected as promptly when left in the web worker + // context + self.postMessage( + { + type: 'partDone', + part: data.part, + duration: Date.now() - startTimestamp, + partContents, + }, + [partContents], + ); + expectedOffset += part.size; + if (part.offset + part.size === fileSize) { + const hash = fileSha1.end(); + self.postMessage({ type: 'done', sha1: hash }); + } + } catch (err) { + const message = { + type: 'error', + name: err.name, + message: err.message, + part, + } as const; + self.postMessage(message); + } + }; + } + /* eslint-enable */ + + const workerCodeBlob = new Blob( + [ + // RushaCore loses its function name when uglified + `const RushaCore = ${RushaCore.toString()}`, + ';\n', + RushaString, + ';\n', + 'var setupWorker = ', + workerBase.toString(), + ';\n', + 'setupWorker();', // This explicit reassigned name is necessary because workerBase + // gets renamed during JS minification. + ], + { type: 'text/javascript' }, + ); + const workerUrl = (window.URL || window.webkitURL).createObjectURL(workerCodeBlob); + const worker = new Worker(workerUrl); + worker.oldTerminate = worker.terminate; + worker.terminate = () => { + (window.URL || window.webkitURL).revokeObjectURL(workerUrl); + worker.oldTerminate(); + }; + + return worker; +}; + +export default createWorker; diff --git a/src/utils/url.js b/src/utils/url.js.flow similarity index 100% rename from src/utils/url.js rename to src/utils/url.js.flow diff --git a/src/utils/url.ts b/src/utils/url.ts new file mode 100644 index 0000000000..2dbea3fa80 --- /dev/null +++ b/src/utils/url.ts @@ -0,0 +1,34 @@ +import Uri from 'jsuri'; + +/** + * Update URL query parameters + * + * @param {string} url - the url that contains the potential query parameter string + * @param {Object} queryParams + */ +function updateQueryParameters(url: string, queryParams: Record): string { + if (!queryParams) { + return url; + } + + const uri = new Uri(url); + + Object.keys(queryParams).forEach(key => { + const value = queryParams[key]; + + if (!value) { + return; + } + + if (uri.hasQueryParam(key)) { + uri.replaceQueryParam(key, value); + return; + } + + uri.addQueryParam(key, value); + }); + + return uri.toString(); +} + +export { updateQueryParameters }; diff --git a/src/utils/validators.js b/src/utils/validators.js.flow similarity index 100% rename from src/utils/validators.js rename to src/utils/validators.js.flow diff --git a/src/utils/validators.ts b/src/utils/validators.ts new file mode 100644 index 0000000000..c5c35799a0 --- /dev/null +++ b/src/utils/validators.ts @@ -0,0 +1,46 @@ +import Address from '@hapi/address'; +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])$/; + return regex.test(value); +} + +function ipv4AddressValidator(value: string): boolean { + // @see https://www.oreilly.com/library/view/regular-expressions-cookbook/9780596802837/ch07s16.html + const regex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; + return regex.test(value); +} + +function domainNameValidator(value: string): boolean { + return Address.domain.isValid(value); +} + +// @sideway/address ^4 has exhaustive TLDs, but upgrading requires a TextEncoder polyfill (such as fast-text-encoder) for IE11 support. +// We can freely upgrade and remove manual TLD supplementation after IE11 EOL. + +// Diff of '@hapi/address/lib/tlds' and: +// https://data.iana.org/TLD/tlds-alpha-by-domain.txt +// Version 2022020100, Last Updated Tue Feb 1 07:07:01 2022 UTC, +const tldSupplements = [ + 'AMAZON', + 'CPA', + 'LLP', + 'MUSIC', + 'SPA', + 'XN--4DBRK0CE', + 'XN--CCKWCXETD', + 'XN--JLQ480N2RG', + 'XN--MGBCPQ6GPA1A', + 'XN--Q7CE6A', + 'ZW', +]; + +const emailValidator = (value: string): boolean => + Address.email.isValid(value, { + tlds: { allow: new Set([...tldsHapi, ...tldSupplements.map(tld => tld.toLowerCase())]) }, + }); + +export { domainNameValidator, emailValidator, hostnameValidator, ipv4AddressValidator }; diff --git a/src/utils/webcrypto.js b/src/utils/webcrypto.js.flow similarity index 100% rename from src/utils/webcrypto.js rename to src/utils/webcrypto.js.flow diff --git a/src/utils/webcrypto.ts b/src/utils/webcrypto.ts new file mode 100644 index 0000000000..fc4cbd99ae --- /dev/null +++ b/src/utils/webcrypto.ts @@ -0,0 +1,63 @@ +import sha1 from 'js-sha1'; + +type WindowWithMsCrypto = Window & { msCrypto?: Crypto }; + +/** Returns the correct crypto library based on browser implementation. */ +function getCrypto(): Crypto { + return window.crypto || (window as WindowWithMsCrypto).msCrypto; +} + +/** + * Returns a Promise of a digest generated from the + * hash function and text given as parameters + * + * @param {string} algorithm - the hash algorithm to use + * @param {ArrayBuffer} buffer - the buffer to digest + * @return {Promise} Promise - resolves with an ArrayBuffer containing the digest result + */ +function digest(algorithm: string, buffer: ArrayBuffer): Promise { + const cryptoRef = getCrypto(); + + if (cryptoRef !== (window as WindowWithMsCrypto).msCrypto) { + return cryptoRef.subtle.digest(algorithm, buffer); + } + + // IE11 implements an early version of the SubtleCrypto interface which doesn't use Promises + // See http://web-developer-articles.blogspot.com/2015/05/web-cryptography-api.html + return new Promise((resolve, reject) => { + // Microsoft has dropped support for SHA-1 and so SHA-1 needs to be calculated differently + if (algorithm === 'SHA-1') { + try { + const hashBuffer = (sha1 as unknown as { arrayBuffer: (buf: ArrayBuffer) => ArrayBuffer }).arrayBuffer( + buffer, + ); + resolve(hashBuffer); + } catch (e) { + reject(e); + } + } else { + const cryptoOperation = cryptoRef.subtle.digest({ name: algorithm }, buffer) as unknown as { + oncomplete: (event: { target: { result: ArrayBuffer } }) => void; + onerror: (reason?: unknown) => void; + }; + + cryptoOperation.oncomplete = event => { + resolve(event.target.result); + }; + cryptoOperation.onerror = reject; + } + }); +} + +/** + * Given a buffer/byteArray fills it with random values and returns the same array + */ +function getRandomValues(buffer: Uint8Array): Uint8Array { + const cryptoRef = getCrypto(); + const copy = new Uint8Array(buffer); + cryptoRef.getRandomValues(copy); + + return copy; +} + +export { getCrypto, digest, getRandomValues };