Skip to content

Commit

Permalink
feat(config): add dynamic config options at build
Browse files Browse the repository at this point in the history
* feat(config): add env variables for build options for dynamic and allow-list based config sources
  • Loading branch information
dbudzins authored Jul 11, 2022
1 parent 4cf9e1a commit 6520ad0
Show file tree
Hide file tree
Showing 18 changed files with 129 additions and 65 deletions.
1 change: 1 addition & 0 deletions .commitlintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ module.exports = {
'e2e',
'signing',
'entitlement',
'config',
],
],
},
Expand Down
4 changes: 4 additions & 0 deletions .env
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
APP_CONFIG_DEFAULT_SOURCE=/config.json
APP_CONFIG_ALLOWED_SOURCES=gnnuzabk
APP_UNSAFE_ALLOW_DYNAMIC_CONFIG=
APP_CONFIG_API_HOST=https://cdn.jwplayer.com
4 changes: 4 additions & 0 deletions .env.development
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
APP_CONFIG_DEFAULT_SOURCE=/config.json
APP_CONFIG_ALLOWED_SOURCES=gnnuzabk
APP_UNSAFE_ALLOW_DYNAMIC_CONFIG=1
APP_CONFIG_API_HOST=https://cdn.jwplayer.com
4 changes: 4 additions & 0 deletions .env.test
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
APP_CONFIG_DEFAULT_SOURCE=test--blender
APP_CONFIG_ALLOWED_SOURCES=
APP_UNSAFE_ALLOW_DYNAMIC_CONFIG=1
APP_CONFIG_API_HOST=https://cdn.jwplayer.com
2 changes: 1 addition & 1 deletion .github/workflows/firebase-live.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ jobs:
steps:
- uses: actions/checkout@v2
- name: Build
run: yarn && yarn build
run: yarn && APP_UNSAFE_ALLOW_DYNAMIC_CONFIG=1 APP_INCLUDE_TEST_CONFIGS=1 yarn build
- uses: FirebaseExtended/action-hosting-deploy@v0
with:
repoToken: "${{ secrets.GITHUB_TOKEN }}"
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/firebase-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ jobs:
steps:
- uses: actions/checkout@v2
- name: Build
run: yarn && yarn build
run: yarn && APP_UNSAFE_ALLOW_DYNAMIC_CONFIG=1 APP_INCLUDE_TEST_CONFIGS=1 yarn build
- uses: FirebaseExtended/action-hosting-deploy@v0
with:
repoToken: "${{ secrets.GITHUB_TOKEN }}"
Expand Down
47 changes: 18 additions & 29 deletions docs/configuration.md
Original file line number Diff line number Diff line change
@@ -1,45 +1,34 @@
# Configuration

JW OTT Webapp uses a JSON configuration file to store all configuration parameters. This file can be located at the following location: `./public/config.json`.
JW OTT Webapp uses a JSON configuration file to store all configuration parameters. This file can be located at the following location: [`./public/config.json`](public/config.json).

## Dynamic Configuration
## Dynamic Configuration File Sources

In the `public/index.html` file, a small script is added to allow switching configurations based on a URL search parameter.
Using environment variables for build parameters, you can adjust what config file the application loads at startup and which, if any, it will allow to be set using the `c=<config source>` query param. The location can be specified using either the 8-character ID of the config from the JW Player dashboard (i.e. `gnnuzabk`), in which case the file will be loaded from the JW Player App Config delivery endpoint, or a relative (i.e. `/config.json`) or absolute (i.e. `https://cdn.jwplayer.com/apps/configs/gnnuzabk.json`) path, in which case the file will be loaded using fetch to make a 'get' request.

You can append `?c=config-id` to the URL and use a different configuration. However, this may not be desirable for production builds since there will only be a single configuration.
As mentioned above, if you have 1 or more allowed sources (see [`APP_CONFIG_ALLOWED_SOURCES`](#configuration-file-source-build-params) below), you can switch between them using the `c` query parameter when you first navigate to the web app. The parameter is automatically evaluated, loaded, and stored in local storage so that the query string can be cleaned from the URL and remain somewhat hidden from end users.

To disable the dynamic configuration mechanism, remove the following part from the `public/index.html` file.
Note: to clear the value from local storage and return to the default, you can navigate to the site with the query parameter but leaving the value blank (i.e. `https://<your domain>?c=`)

```html
<script>
var urlSearchParams = new URLSearchParams(window.location.search);
var configId =
urlSearchParams.get('c') ||
window.localStorage.getItem('jwapp.config');
You can also tell the application to allow any config source location (see [`APP_UNSAFE_ALLOW_DYNAMIC_CONFIG`](#configuration-file-source-build-params) below), but this is a potential vulnerability, since any user could then pass in any valid config file, completely changing the content of the application on your domain. It is recommended to limit this 'unsafe' option to dev, testing, demo environments, etc.

if (configId) {
window.localStorage.setItem('jwapp.config', configId);
### Configuration File Source Build Params

window.configLocation =
'https://' + configId + '.jwpapp.com/config.json';
window.configId = configId;
} else {
window.configLocation = './config.json';
}
</script>
```
**APP_CONFIG_DEFAULT_SOURCE**

The ID or url path for the config that the web app will initially load with. Be careful to ensure that this config is always available or your app will fail to load.

## Dynamic Configuration
---

By default, the `config.json` is served along with the static JW OTT Webapp build. It is possible to use an API to serve the configuration instead. This allows you to update the `menu` or `content` configuration options on-the-fly.
**APP_CONFIG_ALLOWED_SOURCES**

The easiest way to do this, is to override the `window.configLocation` like so:
A space separated list of 8-character IDs and/or url paths for config files that can be set using the `c=<config source>` query param. You can mix ID's and paths as long as they are space separated. You do not need to add the value of `APP_CONFIG_DEFAULT_SOURCE` to this property.

```html
<script>
window.configLocation = 'https://api.jw-ott-webapp.com/config';
</script>
```
---

**APP_UNSAFE_ALLOW_DYNAMIC_CONFIG** - boolean flag which if true, enables any config ID or path to be specified with the `c=<config source>` query param

**Warning** - Generally the `APP_UNSAFE_ALLOW_DYNAMIC_CONFIG` option should only be used for dev and test, because it opens up your application so that anyone can specify their own config to run on your domain

## Available Configuration Parameters

Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
},
"scripts": {
"prepare": "husky install",
"start": "vite",
"start": "APP_INCLUDE_TEST_CONFIGS=1 vite",
"build": "vite build",
"test": "TZ=UTC vitest run",
"test-watch": "TZ=UTC vitest",
Expand Down Expand Up @@ -126,4 +126,4 @@
"glob-parent": "^5.1.2",
"codeceptjs/**/ansi-regex": "^4.1.1"
}
}
}
Binary file modified public/images/logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 3 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ import { initializeAccount } from '#src/stores/AccountController';
import { initializeFavorites } from '#src/stores/FavoritesController';
import { logDev } from '#src/utils/common';
import { PersonalShelf } from '#src/enum/PersonalShelf';

import '#src/i18n/config';
import '#src/styles/main.scss';
import { clearStoredConfig, getConfig } from '#src/utils/configOverride';

interface State {
error: Error | null;
Expand Down Expand Up @@ -51,6 +51,7 @@ class App extends Component {
configErrorHandler = (error: Error) => {
this.setState({ error });
logDev('Error while loading the config.json:', error);
clearStoredConfig();
};

configValidationCompletedHandler = async (config: Config) => {
Expand All @@ -62,7 +63,7 @@ class App extends Component {
<I18nextProvider i18n={getI18n()}>
<QueryProvider>
<ConfigProvider
configLocation={window.configLocation || '/config.json'}
configLocation={getConfig()}
onLoading={this.configLoadingHandler}
onValidationError={this.configErrorHandler}
onValidationCompleted={this.configValidationCompletedHandler}
Expand Down
3 changes: 0 additions & 3 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,6 @@ import 'wicg-inert';

import registerServiceWorker from './registerServiceWorker';
import App from './App';
import { overrideConfig } from './utils/configOverride';

overrideConfig();

ReactDOM.render(<App />, document.getElementById('root'));

Expand Down
12 changes: 9 additions & 3 deletions src/providers/ConfigProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@ import merge from 'lodash.merge';

import { calculateContrastColor } from '../utils/common';
import loadConfig, { validateConfig } from '../services/config.service';
import type { AccessModel, Config, Styling } from '../../types/Config';
import LoadingOverlay from '../components/LoadingOverlay/LoadingOverlay';
import { addScript } from '../utils/dom';
import { useConfigStore } from '../stores/ConfigStore';

import type { AccessModel, Config, Styling } from '#types/Config';

const defaultConfig: Config = {
id: '',
siteName: '',
Expand Down Expand Up @@ -35,7 +36,7 @@ export const ConfigContext = createContext<Config>(defaultConfig);

export type ProviderProps = {
children: ReactNode;
configLocation: string;
configLocation?: string;
onLoading: (isLoading: boolean) => void;
onValidationError: (error: Error) => void;
onValidationCompleted: (config: Config) => void;
Expand All @@ -46,7 +47,12 @@ const ConfigProvider: FunctionComponent<ProviderProps> = ({ children, configLoca
const [loading, setLoading] = useState<boolean>(true);

useEffect(() => {
const loadAndValidateConfig = async (configLocation: string) => {
const loadAndValidateConfig = async (configLocation?: string) => {
if (!configLocation) {
onValidationError(new Error('Config not defined'));
return;
}

onLoading(true);
setLoading(true);
const config = await loadConfig(configLocation).catch((error) => {
Expand Down
93 changes: 74 additions & 19 deletions src/utils/configOverride.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,85 @@
import { IS_DEV_BUILD } from './common';
import { IS_DEV_BUILD } from '#src/utils/common';

// In production, use local storage so the override persists indefinitely without the query string
// In dev mode, use session storage so the override persists until the tab is closed and then resets
const storage = IS_DEV_BUILD ? window.sessionStorage : window.localStorage;
const CONFIG_HOST = import.meta.env.APP_CONFIG_API_HOST;
const INCLUDE_TEST_CONFIGS = import.meta.env.APP_INCLUDE_TEST_CONFIGS;

const configFileQueryKey = 'c';
const configFileStorageKey = 'config-file-override';
export const configFileQueryKey = 'c';

function getStoredConfigOverride() {
return window.sessionStorage.getItem(configFileStorageKey);
const DEFAULT_SOURCE = import.meta.env.APP_CONFIG_DEFAULT_SOURCE?.toLowerCase();
const ALLOWED_SOURCES = import.meta.env.APP_CONFIG_ALLOWED_SOURCES?.split(' ').map((source) => source.toLowerCase()) || [];
const UNSAFE_ALLOW_DYNAMIC_CONFIG = import.meta.env.APP_UNSAFE_ALLOW_DYNAMIC_CONFIG;

export function getConfig() {
return formatSourceLocation(getConfigOverride() || DEFAULT_SOURCE);
}

export function overrideConfig() {
// This code is only used for (integration) testing and will be optimized away in production builds
if (IS_DEV_BUILD) {
const url = new URL(window.location.href);
function getConfigOverride() {
const url = new URL(window.location.href);

if (url.searchParams.has(configFileQueryKey)) {
const configQuery = url.searchParams.get(configFileQueryKey)?.toLowerCase();

const configFile = url.searchParams.get(configFileQueryKey) || getStoredConfigOverride();
// Strip the config file query param from the URL since it's stored locally,
// Strip the config file query param from the URL and history since it's stored locally,
// and then the url stays clean and the user will be less likely to play with the param
if (url.searchParams.has(configFileQueryKey)) {
url.searchParams.delete(configFileQueryKey);
url.searchParams.delete(configFileQueryKey);
window.history.replaceState(null, '', url.toString());

window.history.replaceState(null, '', url.toString());
// If the query param exists but the value is empty, clear the storage and allow fallback to the default config
if (!configQuery) {
clearStoredConfig();
return undefined;
}

// Use session storage to cache any config location override set from the url parameter so it can be restored
// on subsequent navigation if the query string gets lost, but it doesn't persist if you close the tab
if (configFile) {
window.sessionStorage.setItem(configFileStorageKey, configFile);
// If it's valid, store it and return it
if (isValidConfigSource(configQuery)) {
storage.setItem(configFileStorageKey, configQuery);
return configQuery;
}

window.configLocation = configFile ? `/test-data/config.${configFile}.json` : '/config.json';
// Yes this falls through to look up the stored value if the query string is invalid and that's OK
}

const storedSource = storage.getItem(configFileStorageKey)?.toLowerCase();

// Make sure the stored value is still valid before returning it
if (storedSource && isValidConfigSource(storedSource)) {
return storedSource;
}

return undefined;
}

function isValidConfigSource(source: string) {
// Dynamic values are valid as long as they are defined
if (UNSAFE_ALLOW_DYNAMIC_CONFIG) {
return !!source;
}

if (INCLUDE_TEST_CONFIGS && source.startsWith('test--')) {
return true;
}

return ALLOWED_SOURCES.indexOf(source) >= 0;
}

function formatSourceLocation(source?: string) {
if (!source) {
return undefined;
}

if (source.match(/^[a-z,\d]{8}$/)) {
return `${CONFIG_HOST}/apps/configs/${source}.json`;
}

if (INCLUDE_TEST_CONFIGS && source.startsWith('test--')) {
return `/test-data/config.${source}.json`;
}

return source;
}

/***
Expand All @@ -37,7 +88,7 @@ export function overrideConfig() {
* @param href The URL to append to as a string (i.e. window.location.href)
*/
export function addConfigParamToUrl(href: string) {
const config = getStoredConfigOverride();
const config = getConfigOverride();
const url = new URL(href);

url.searchParams.delete(configFileQueryKey);
Expand All @@ -48,3 +99,7 @@ export function addConfigParamToUrl(href: string) {

return url.toString();
}

export function clearStoredConfig() {
storage.removeItem(configFileStorageKey);
}
File renamed without changes.
2 changes: 1 addition & 1 deletion test-e2e/tests/home_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import constants from '../utils/constants';
Feature('home').retry(3);

Before(({ I }) => {
I.useConfig('blender');
I.useConfig('test--blender');
});

Scenario('Home screen loads', async ({ I }) => {
Expand Down
2 changes: 1 addition & 1 deletion test-e2e/utils/steps_file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const loaderElement = '[class*=_loadingOverlay]';
const stepsObj = {
useConfig: function (
this: CodeceptJS.I,
config: 'test--subscription' | 'test--accounts' | 'test--no-cleeng' | 'test--watchlists' | 'blender',
config: 'test--subscription' | 'test--accounts' | 'test--no-cleeng' | 'test--watchlists' | 'test--blender',
baseUrl: string = constants.baseUrl,
) {
const url = new URL(baseUrl);
Expand Down
7 changes: 5 additions & 2 deletions types/env.d.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
/// <reference types="vite/client" />

interface ImportMetaEnv {
readonly VITE_APP_TITLE: string;
readonly APP_GITHUB_PUBLIC_BASE_URL: string;
readonly APP_TITLE: string | undefined;
readonly APP_GITHUB_PUBLIC_BASE_URL: string | undefined;
readonly APP_CONFIG_DEFAULT_SOURCE: string | undefined;
readonly APP_CONFIG_ALLOWED_SOURCES: string | undefined;
readonly APP_UNSAFE_ALLOW_DYNAMIC_CONFIG: string | undefined;
}

interface ImportMeta {
Expand Down
2 changes: 1 addition & 1 deletion vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export default ({ mode }: { mode: 'production' | 'development' | 'test' | undefi
];

// These files are only needed in dev / test, don't include in prod builds
if (mode === 'development' || mode === 'test') {
if (process.env.APP_INCLUDE_TEST_CONFIGS) {
plugins.push(
viteStaticCopy({
targets: [
Expand Down

0 comments on commit 6520ad0

Please sign in to comment.