diff --git a/__tests__/server/middleware/pwa/config.spec.js b/__tests__/server/middleware/pwa/config.spec.js index f3f073d32..d32d60de5 100644 --- a/__tests__/server/middleware/pwa/config.spec.js +++ b/__tests__/server/middleware/pwa/config.spec.js @@ -31,7 +31,7 @@ describe('pwa configuration', () => { const serviceWorkerRecoveryScript = Buffer.from('[service-worker-noop-script]'); const serviceWorkerEscapeHatchScript = Buffer.from('self.unregister();'); - beforeAll(() => { + beforeEach(() => { process.env.ONE_SERVICE_WORKER = true; }); @@ -131,8 +131,6 @@ describe('pwa configuration', () => { serviceWorkerScriptUrl: false, webManifestUrl: false, }); - - process.env.ONE_SERVICE_WORKER = true; }); test('disabling PWA configuration', () => { @@ -184,29 +182,6 @@ describe('pwa configuration', () => { }); }); - test('using a function for the web manifest', () => { - configurePWA({ - serviceWorker: true, - webManifest: () => ({ - name: 'One App Test', - }), - }); - - expect(getServerPWAConfig()).toMatchObject({ - webManifest: true, - serviceWorker: true, - serviceWorkerRecoveryMode: false, - serviceWorkerScope: '/', - }); - expect(getClientPWAConfig()).toMatchObject({ - serviceWorker: true, - serviceWorkerRecoveryMode: false, - serviceWorkerScope: '/', - serviceWorkerScriptUrl: '/_/pwa/service-worker.js', - webManifestUrl: '/_/pwa/manifest.webmanifest', - }); - }); - test('opting out of the web manifest', () => { configurePWA({ serviceWorker: true, diff --git a/__tests__/server/middleware/pwa/validation.spec.js b/__tests__/server/middleware/pwa/validation.spec.js deleted file mode 100644 index 3e783aa30..000000000 --- a/__tests__/server/middleware/pwa/validation.spec.js +++ /dev/null @@ -1,190 +0,0 @@ -/* - * Copyright 2020 American Express Travel Related Services Company, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express - * or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -import { - validatePWAConfig, -} from '../../../../src/server/middleware/pwa/validation'; - -describe('validation', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - beforeAll(() => { - jest.spyOn(console, 'warn'); - jest.spyOn(console, 'error'); - console.warn.mockImplementation(); - console.error.mockImplementation(); - }); - - test('invalid configuration object informs the user', () => { - expect(validatePWAConfig(null)).toEqual(null); - expect(validatePWAConfig([])).toEqual(null); - expect(console.error).toHaveBeenCalledTimes(2); - expect(console.error).toHaveBeenCalledWith('invalid config given to service worker (expected "object")'); - }); - - test('invalid configuration keys informs the user and ignores them', () => { - expect(validatePWAConfig({ - random: 'key', - foo: 'bar', - })).toEqual({}); - expect(console.warn).toHaveBeenCalledTimes(2); - expect(console.warn).toHaveBeenCalledWith('supplied configuration key "random" is not a valid property - ignoring'); - expect(console.warn).toHaveBeenCalledWith('supplied configuration key "foo" is not a valid property - ignoring'); - }); - - test('invalid configuration values for keys informs the user and ignores them', () => { - expect(validatePWAConfig({ - serviceWorker: 'true', - scope: 42, - webManifest: [], - })).toEqual({}); - expect(console.warn).toHaveBeenCalledTimes(3); - expect(console.warn).toHaveBeenCalledWith('Invalid value type given for configuration key "serviceWorker" (expected "Boolean") - ignoring'); - expect(console.warn).toHaveBeenCalledWith('Invalid value type given for configuration key "scope" (expected "String") - ignoring'); - expect(console.warn).toHaveBeenCalledWith('Invalid value type given for configuration key "webManifest" (expected "WebManifest") - ignoring'); - }); - - test('valid keys emits no warnings or errors and returns valid configuration', () => { - const validConfig = { - serviceWorker: true, - scope: '/', - webManifest: { name: 'my-app' }, - }; - - expect(validatePWAConfig(validConfig)).toEqual(validConfig); - expect(console.warn).not.toHaveBeenCalled(); - expect(console.error).not.toHaveBeenCalled(); - }); - - describe('web manifest validation', () => { - test('web app manifest has valid keys emits no warnings or errors and returns valid configuration', () => { - const validConfig = { - serviceWorker: true, - scope: '/', - webManifest: { - name: 'One App Test', - }, - }; - expect(validatePWAConfig(validConfig)).toEqual(validConfig); - expect(console.warn).not.toHaveBeenCalled(); - expect(console.error).not.toHaveBeenCalled(); - }); - - test('expects name to be required', () => { - const validConfig = { - serviceWorker: true, - scope: '/', - webManifest: { - short_name: 'One App Test', - }, - }; - expect(validatePWAConfig(validConfig)).toEqual(validConfig); - expect(console.warn).not.toHaveBeenCalled(); - expect(console.error).toHaveBeenCalledTimes(1); - }); - - test('ignores unrecognized keys given', () => { - const validConfig = { - serviceWorker: true, - scope: '/', - webManifest: { - name: 'One App Test', - my_name: 'One App Test', - }, - }; - expect(validatePWAConfig(validConfig)).toEqual({ ...validConfig, webManifest: { name: 'One App Test' } }); - expect(console.warn).toHaveBeenCalledTimes(1); - expect(console.error).not.toHaveBeenCalled(); - }); - - test('warns and ignores when invalid enumerable keys are used', () => { - const validConfig = { - serviceWorker: true, - scope: '/', - webManifest: { - name: 'One App Test', - display: 'big', - orientation: 'up', - direction: 'backwards', - }, - }; - expect(validatePWAConfig(validConfig)).toEqual({ ...validConfig, webManifest: { name: 'One App Test' } }); - expect(console.warn).toHaveBeenCalledTimes(3); - expect(console.error).not.toHaveBeenCalled(); - }); - - test('warns and ignores when invalid shapes for array are used', () => { - const validConfig = { - serviceWorker: true, - scope: '/', - webManifest: { - name: 'One App Test', - icons: [{ - size: '72x72', - }, { - purpose: 'none', - }], - screenshots: [{ - href: 'https://screenshots.example.com/screenshot/latest', - }], - related_applications: [{ - store: 'new pwa store', - }], - }, - }; - expect(validatePWAConfig(validConfig)).toEqual({ ...validConfig, webManifest: { name: 'One App Test' } }); - expect(console.warn).toHaveBeenCalledTimes(3); - expect(console.error).not.toHaveBeenCalled(); - }); - - test('includes a valid web manifest when passed in', () => { - const validConfig = { - serviceWorker: true, - scope: '/', - webManifest: { - lang: 'en-US', - dir: 'auto', - display: 'standalone', - orientation: 'portrait', - short_name: 'Test', - name: 'One App Test', - categories: ['testing', 'example'], - icons: [{ - src: 'https://example.com/pwa-icon.png', - type: 'img/png', - sizes: '72x72', - purpose: 'badge', - }], - screenshots: [{ - src: 'https://example.com/pwa-screenshot.png', - type: 'img/png', - sizes: '1024x768', - }], - related_applications: [{ - platform: 'new pwa store', - url: 'https://platform.example.com/pwa', - id: 'aiojfoahfaf', - }], - }, - }; - expect(validatePWAConfig(validConfig)).toEqual(validConfig); - expect(console.warn).not.toHaveBeenCalled(); - expect(console.error).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/__tests__/server/utils/validation/index.spec.js b/__tests__/server/utils/validation/index.spec.js new file mode 100644 index 000000000..e3f074d20 --- /dev/null +++ b/__tests__/server/utils/validation/index.spec.js @@ -0,0 +1,72 @@ +/* + * Copyright 2020 American Express Travel Related Services Company, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +import { validatePWAConfig } from '../../../../src/server/utils/validation'; + +describe(validatePWAConfig.name, () => { + test('valid pwa config using a function', () => { + const clientUrl = 'https://example.com/api'; + expect( + validatePWAConfig({ + serviceWorker: true, + scope: '/', + webManifest: (config) => ({ name: 'One App Test', start_url: config.startUrl }), + }, { + clientStateConfig: { + startUrl: clientUrl, + }, + }) + ).toEqual({ + serviceWorker: true, + scope: '/', + webManifest: { name: 'One App Test', start_url: clientUrl }, + }); + }); + + test('invalid pwa config', () => { + expect( + () => validatePWAConfig({ + serviceWorker: 'true', + escapeHatch: 0, + recoveryMode: [], + scope: '\\', + webManifest: { + short_name: /One App Test/, + start_url: '[start_url', + scope: 0, + display: 'sideways', + categories: ['pwa', 1234], + icons: [{ + src: false, + }], + }, + }) + ).toThrow( + new Error([ + '"recoveryMode" must be a boolean', + '"escapeHatch" must be a boolean', + '"scope" must be a relative or absolute URL', + '"webManifest.name" is required', + '"webManifest.short_name" must be a string', + '"webManifest.scope" must be a string', + '"webManifest.start_url" must be a relative or absolute URL', + '"webManifest.categories[1]" must be a string', + '"webManifest.display" must be one of [fullscreen, standalone, minimal-ui, browser]', + '"webManifest.icons[0].src" must be a string', + ].join('. ')) + ); + }); +}); diff --git a/package-lock.json b/package-lock.json index 47aadc8fb..4734ac146 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3957,6 +3957,37 @@ "integrity": "sha512-SXY8bCQ1qacJ8AUTUxjabY8G6OjSmMPLN9MBCzGaKOjpPNX6z8zbXTbk9oU3GHZLtcxweWLCi2n49IRS4iQlwg==", "dev": true }, + "@hapi/address": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@hapi/address/-/address-4.1.0.tgz", + "integrity": "sha512-SkszZf13HVgGmChdHo/PxchnSaCJ6cetVqLzyciudzZRT0jcOouIF/Q93mgjw8cce+D+4F4C1Z/WrfFN+O3VHQ==", + "requires": { + "@hapi/hoek": "^9.0.0" + } + }, + "@hapi/formula": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@hapi/formula/-/formula-2.0.0.tgz", + "integrity": "sha512-V87P8fv7PI0LH7LiVi8Lkf3x+KCO7pQozXRssAHNXXL9L1K+uyu4XypLXwxqVDKgyQai6qj3/KteNlrqDx4W5A==" + }, + "@hapi/hoek": { + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.0.4.tgz", + "integrity": "sha512-EwaJS7RjoXUZ2cXXKZZxZqieGtc7RbvQhUy8FwDoMQtxWVi14tFjeFCYPZAM1mBCpOpiBpyaZbb9NeHc7eGKgw==" + }, + "@hapi/pinpoint": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@hapi/pinpoint/-/pinpoint-2.0.0.tgz", + "integrity": "sha512-vzXR5MY7n4XeIvLpfl3HtE3coZYO4raKXW766R6DZw/6aLqR26iuZ109K7a0NtF2Db0jxqh7xz2AxkUwpUFybw==" + }, + "@hapi/topo": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.0.0.tgz", + "integrity": "sha512-tFJlT47db0kMqVm3H4nQYgn6Pwg10GTZHb1pwmSiv1K4ks6drQOtfEF5ZnPjkvC+y4/bUPHK+bc87QvLcL+WMw==", + "requires": { + "@hapi/hoek": "^9.0.0" + } + }, "@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -17883,6 +17914,18 @@ } } }, + "joi": { + "version": "17.2.0", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.2.0.tgz", + "integrity": "sha512-9ZC8pMSitNlenuwKARENBGVvvGYHNlwWe5rexo2WxyogaxCB5dNHAgFA1BJQ6nsJrt/jz1p5vSqDT6W6kciDDw==", + "requires": { + "@hapi/address": "^4.1.0", + "@hapi/formula": "^2.0.0", + "@hapi/hoek": "^9.0.0", + "@hapi/pinpoint": "^2.0.0", + "@hapi/topo": "^5.0.0" + } + }, "js-cookie": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-2.2.1.tgz", diff --git a/package.json b/package.json index 803640097..327f086eb 100644 --- a/package.json +++ b/package.json @@ -95,6 +95,7 @@ "if-env": "^1.0.4", "immutable": "^4.0.0-rc.12", "ip": "^1.1.5", + "joi": "^17.2.0", "lean-intl": "^4.2.2", "make-promises-safe": "^5.1.0", "matcher": "^3.0.0", diff --git a/src/server/middleware/pwa/config.js b/src/server/middleware/pwa/config.js index e585f633c..535eb98c0 100644 --- a/src/server/middleware/pwa/config.js +++ b/src/server/middleware/pwa/config.js @@ -17,10 +17,6 @@ import fs from 'fs'; import path from 'path'; -import { getClientStateConfig } from '../../utils/stateConfig'; - -import { validatePWAConfig } from './validation'; - const defaultPWAConfig = { webManifest: false, serviceWorker: false, @@ -35,6 +31,7 @@ let pwaConfig = { ...defaultPWAConfig }; function resetPWAConfig() { pwaConfig = { ...defaultPWAConfig }; + return pwaConfig; } function setPWAConfig(newConfiguration) { @@ -84,16 +81,6 @@ function createWebManifestConfig(config, serviceWorkerConfig) { }; } -function validateConfig(config) { - if (!config) return {}; - - const object = { ...config }; - - if (typeof config.webManifest === 'function') object.webManifest = config.webManifest(getClientStateConfig()); - - return validatePWAConfig(object); -} - export function getWebAppManifestConfig() { return { webManifest: webAppManifest, webManifestEnabled: pwaConfig.webManifest }; } @@ -116,7 +103,7 @@ export function getClientPWAConfig() { }; } -export function configurePWA(config) { +export function configurePWA(config = {}) { // feature flag will not allow pwa/service-worker to be configured // it will default to a disabled state regardless if `appConfig.pwa` was provided if (process.env.ONE_SERVICE_WORKER !== 'true') { @@ -128,15 +115,14 @@ export function configurePWA(config) { // if there was a previous configuration present, we want to gracefully // remove any remaining instances. We currently handle this client side // and would only need to reset the configuration when we want to decouple. - resetPWAConfig(); + // eslint-disable-next-line no-param-reassign + config = resetPWAConfig(); } - const validatedConfig = validateConfig(config); - - const serviceWorkerConfig = createServiceWorkerConfig(validatedConfig); + const serviceWorkerConfig = createServiceWorkerConfig(config); const { webManifestObject, webManifest, - } = createWebManifestConfig(validatedConfig, serviceWorkerConfig); + } = createWebManifestConfig(config, serviceWorkerConfig); webAppManifest = webManifestObject ? Buffer.from(JSON.stringify(webManifestObject)) : null; diff --git a/src/server/middleware/pwa/validation.js b/src/server/middleware/pwa/validation.js deleted file mode 100644 index 29aff4bc3..000000000 --- a/src/server/middleware/pwa/validation.js +++ /dev/null @@ -1,198 +0,0 @@ -/* - * Copyright 2020 American Express Travel Related Services Company, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express - * or implied. See the License for the specific language governing - * permissions and limitations under the License. - */ - -function isString(valueToTest) { - return typeof valueToTest === 'string'; -} - -function isBoolean(valueToTest) { - return typeof valueToTest === 'boolean'; -} - -function isPlainObject(valueToTest) { - return !!valueToTest && typeof valueToTest === 'object' && Array.isArray(valueToTest) === false; -} - -function createIsRequired(isType) { - return function isRequired(valueToTest) { - return !!valueToTest && isType(valueToTest); - }; -} - -function createIsEnum(enumerableValues, isType) { - return function isEnum(valueToTest) { - return isType(valueToTest) && enumerableValues.includes(valueToTest); - }; -} - -function createIsArrayOf(isType) { - return function isArrayOf(valuesToTest) { - return Array.isArray(valuesToTest) && valuesToTest.map(isType).filter(Boolean); - }; -} - -function createIsShape(objectShape) { - return function isShape(objectValueToTest) { - return Object.keys(objectValueToTest) - .map((keyToTest) => { - if (keyToTest in objectShape === false) return false; - const testValueType = objectShape[keyToTest]; - const valueOfKey = objectValueToTest[keyToTest]; - return testValueType(valueOfKey) && [keyToTest, valueOfKey]; - }) - .filter(Boolean) - .reduce((map, [configKey, value]) => ({ ...map, [configKey]: value }), null); - }; -} - -function isWebManifest(manifestToValidate) { - // we can accept either of these values if the user wishes to opt out - if ([false, null].includes(manifestToValidate)) return null; - // if not a plain object at this point we mark the manifest as invalid - if (!isPlainObject(manifestToValidate)) { - return false; - } - - const webAppManifestKeys = new Map([ - ['background_color', isString], - ['categories', createIsArrayOf(isString)], - ['description', isString], - ['dir', createIsEnum([ - 'auto', - 'ltr', - 'rtl', - ], isString)], - ['display', createIsEnum([ - 'fullscreen', - 'standalone', - 'minimal-ui', - 'browser', - ], isString)], - ['iarc_rating_id', isBoolean], - ['icons', createIsArrayOf( - createIsShape({ - src: isString, - sizes: isString, - type: isString, - purpose: createIsEnum([ - 'any', - 'maskable', - 'badge', - ], isString), - }) - )], - ['lang', isString], - ['name', createIsRequired(isString)], - ['orientation', createIsEnum([ - 'any', - 'natural', - 'landscape', - 'landscape-primary', - 'landscape-secondary', - 'portrait', - 'portrait-primary', - 'portrait-secondary', - ], isString)], - ['prefer_related_applications', isBoolean], - ['related_applications', createIsArrayOf( - createIsShape({ - platform: isString, - url: isString, - id: isString, - }) - )], - ['scope', isString], - ['screenshots', createIsArrayOf( - createIsShape({ - src: isString, - sizes: isString, - type: isString, - }) - )], - ['short_name', isString], - ['start_url', isString], - ['theme_color', isString], - ]); - // we manually add required properties (eg name) - return [...new Set(Object.keys(manifestToValidate).concat('name'))] - .map((keyToTest) => { - if (!webAppManifestKeys.has(keyToTest)) { - // warn that it's not a supported key - console.warn(`The key "${keyToTest}" is not supported by the web app manifest - ignoring`); - return null; - } - const testValueType = webAppManifestKeys.get(keyToTest); - const valueOfKey = manifestToValidate[keyToTest]; - const testResult = testValueType(valueOfKey); - if (['icons', 'related_applications', 'screenshots'].includes(keyToTest)) { - if (testResult.length > 0) return [keyToTest, testResult]; - console.warn(`The key "${keyToTest}" did not have a valid values - ignoring`); - return null; - } - if (!testResult) { - // for all of our mandatory keys - if (!valueOfKey && ['name'].includes(keyToTest)) { - console.error(`The key "${keyToTest}" is required to be present, please set a value`); - } else { - // otherwise warn that the value used is incorrect - console.warn(`The key "${keyToTest}" does not have a valid value - ignoring`); - } - return null; - } - return [keyToTest, valueOfKey]; - }) - .filter(Boolean) - .reduce((map, [configKey, value]) => ({ ...map, [configKey]: value }), null); -} - -// eslint-disable-next-line import/prefer-default-export -export function validatePWAConfig(configToValidate) { - if (!isPlainObject(configToValidate)) { - console.error('invalid config given to service worker (expected "object")'); - return null; - } - - const validKeys = new Map([ - ['serviceWorker', isBoolean], - ['recoveryMode', isBoolean], - ['escapeHatch', isBoolean], - ['scope', isString], - ['webManifest', isWebManifest], - ]); - - return Object.keys(configToValidate) - .map((configKeyToValidate) => { - if (!validKeys.has(configKeyToValidate)) { - console.warn(`supplied configuration key "${configKeyToValidate}" is not a valid property - ignoring`); - return null; - } - - const configValueToValidate = configToValidate[configKeyToValidate]; - const testValueType = validKeys.get(configKeyToValidate); - const testResults = testValueType(configValueToValidate); - - if (!testResults) { - console.warn( - `Invalid value type given for configuration key "${configKeyToValidate}" (expected "${testValueType.name.replace('is', '')}") - ignoring` - ); - return null; - } - - return [configKeyToValidate, configKeyToValidate === 'webManifest' ? testResults : configValueToValidate]; - }) - .filter(Boolean) - .reduce((map, [configKey, value]) => ({ ...map, [configKey]: value }), {}); -} diff --git a/src/server/utils/onModuleLoad.js b/src/server/utils/onModuleLoad.js index 80eb189d4..3d0bb265f 100644 --- a/src/server/utils/onModuleLoad.js +++ b/src/server/utils/onModuleLoad.js @@ -25,6 +25,7 @@ import { setConfigureRequestLog } from './logging/serverMiddleware'; import { setCreateSsrFetch } from './createSsrFetch'; import { setEventLoopDelayThreshold } from './createCircuitBreaker'; import { configurePWA } from '../middleware/pwa'; +import { validatePWAConfig } from './validation'; // Trim build hash const { buildVersion } = readJsonFile('../../../.build-meta.json'); @@ -117,8 +118,11 @@ export default function onModuleLoad({ setConfigureRequestLog(configureRequestLog); setCreateSsrFetch(createSsrFetch); setEventLoopDelayThreshold(eventLoopDelayThreshold); + configurePWA(validatePWAConfig(pwa, { + clientStateConfig: getClientStateConfig(), + })); + logModuleLoad(moduleName, metaData.version); - configurePWA(pwa); return; } diff --git a/src/server/utils/validation/extensions.js b/src/server/utils/validation/extensions.js new file mode 100644 index 000000000..8b99973af --- /dev/null +++ b/src/server/utils/validation/extensions.js @@ -0,0 +1,31 @@ +/* + * Copyright 2020 American Express Travel Related Services Company, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +import Joi from 'joi'; + +import { webManifestSchema } from './webManifest'; + +// eslint-disable-next-line import/prefer-default-export +export const webManifestExtension = Joi.extend({ + type: 'manifest', + base: webManifestSchema, + coerce(value, helpers) { + if (typeof value === 'function') { + return { value: value(helpers.prefs.context.clientStateConfig) }; + } + return { value }; + }, +}); diff --git a/src/server/utils/validation/index.js b/src/server/utils/validation/index.js new file mode 100644 index 000000000..24be4aa70 --- /dev/null +++ b/src/server/utils/validation/index.js @@ -0,0 +1,30 @@ +/* + * Copyright 2020 American Express Travel Related Services Company, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +import { pwaSchema } from './pwa'; + +export function validateSchema(schema, validationTarget, options) { + const { error, value } = schema.validate( + validationTarget, + { ...options, abortEarly: false } + ); + if (error) throw error; + return value; +} + +export function validatePWAConfig(pwaConfig, context) { + return validateSchema(pwaSchema, pwaConfig, { context }); +} diff --git a/src/server/utils/validation/pwa.js b/src/server/utils/validation/pwa.js new file mode 100644 index 000000000..cb97cc436 --- /dev/null +++ b/src/server/utils/validation/pwa.js @@ -0,0 +1,29 @@ +/* + * Copyright 2020 American Express Travel Related Services Company, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +import Joi from 'joi'; + +import { urlSchema } from './shared'; +import { webManifestExtension } from './extensions'; + +// eslint-disable-next-line import/prefer-default-export +export const pwaSchema = Joi.object().keys({ + serviceWorker: Joi.boolean(), + recoveryMode: Joi.boolean(), + escapeHatch: Joi.boolean(), + scope: urlSchema, + webManifest: webManifestExtension.manifest(), +}); diff --git a/src/server/utils/validation/shared.js b/src/server/utils/validation/shared.js new file mode 100644 index 000000000..c40b386c1 --- /dev/null +++ b/src/server/utils/validation/shared.js @@ -0,0 +1,29 @@ +/* + * Copyright 2020 American Express Travel Related Services Company, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +import Joi from 'joi'; + +export const urlSchema = Joi.string().uri({ + allowRelative: true, +}).messages({ + 'string.base': '{{#label}} must be a string', + 'string.uri': '{{#label}} must be a relative or absolute URL', +}); + +export const colorSchema = Joi.alternatives( + Joi.string(), + Joi.string().hex() +); diff --git a/src/server/utils/validation/webManifest.js b/src/server/utils/validation/webManifest.js new file mode 100644 index 000000000..2586b6d53 --- /dev/null +++ b/src/server/utils/validation/webManifest.js @@ -0,0 +1,76 @@ +/* + * Copyright 2020 American Express Travel Related Services Company, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +import Joi from 'joi'; + +import { urlSchema, colorSchema } from './shared'; + +// eslint-disable-next-line import/prefer-default-export +export const webManifestSchema = Joi.object().keys({ + name: Joi.string().required(), + short_name: Joi.string(), + description: Joi.string(), + lang: Joi.string(), + scope: urlSchema, + start_url: urlSchema, + theme_color: colorSchema, + background_color: colorSchema, + categories: Joi.array().items(Joi.string()), + iarc_rating_id: Joi.boolean(), + prefer_related_applications: Joi.boolean(), + related_applications: Joi.array().items(Joi.object().keys({ + platform: Joi.string(), + url: Joi.string().uri(), + id: Joi.string(), + })), + orientation: Joi.string().valid( + 'any', + 'natural', + 'landscape', + 'landscape-primary', + 'landscape-secondary', + 'portrait', + 'portrait-primary', + 'portrait-secondary' + ), + display: Joi.string() + .valid( + 'fullscreen', + 'standalone', + 'minimal-ui', + 'browser' + ), + dir: Joi.string().valid( + 'auto', + 'ltr', + 'rtl' + ), + icons: Joi.array().items(Joi.object().keys({ + src: Joi.string().uri(), + sizes: Joi.string(), + type: Joi.string(), + purpose: Joi.string().valid( + 'any', + 'maskable', + 'badge' + ), + })), + screenshots: Joi.array().items(Joi.object().keys({ + src: Joi.string().uri(), + sizes: Joi.string(), + type: Joi.string(), + })), +}).allow(null);