diff --git a/.docker/vhost.conf b/.docker/vhost.conf index e60c1a1f..e29516e9 100644 --- a/.docker/vhost.conf +++ b/.docker/vhost.conf @@ -7,5 +7,7 @@ server { proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header Host $http_host; proxy_pass http://node:3000; + add_header Cache-Control "public, max-age=604800"; + expires 7d; } } diff --git a/CHANGELOG.md b/CHANGELOG.md index df0e420a..ca40573b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ All notable changes to this project will be documented in this file. +## [2.0.2] - 2024-04-25 + +- [#123](https://github.com/os2display/display-client/pull/123) + - Ensured real ip is logged in nginx. +- [#124](https://github.com/os2display/display-client/pull/124) + - Changed to apply max-age 7d to all files and added cache busting to config.json and release.json. + - Added "loginCheckTimeout", "configFetchInterval", "refreshTokenTimeout", "releaseTimestampIntervalTimeout" to config.json. + - Simplified config.json. +- [#122](https://github.com/os2display/display-client/pull/122) + - Added max-age and expires 1 hour to config.json and release.json. +- [#121](https://github.com/os2display/display-client/pull/120) + - Add releaseVersion, releaseTimestamp and screenId searchParams when starting app. + ## [2.0.1] - 2024-04-10 - [#120](https://github.com/os2display/display-client/pull/120) diff --git a/README.md b/README.md index 294e818e..6c31f978 100644 --- a/README.md +++ b/README.md @@ -5,30 +5,30 @@ See [https://github.com/os2display/display-docs/blob/main/client.md](https://git ## Config The client can be configured by creating `public/config.json` with relevant values. -See `public/example_config.json` for values. - -```json -{ - "apiEndpoint": "", - "authenticationEndpoint": "/v2/authentication/screen", - "authenticationRefreshTokenEndpoint": "/v2/authentication/token/refresh", - "dataStrategy": { - "type": "pull", - "config": { - "interval": 30000, - "endpoint": "" - } - }, - "colorScheme": { - "type": "library", - "lat": 56.0, - "lng": 10.0 - }, - "schedulingInterval": 60000, - "debug": false -} -``` -All endpoint should be configured with out a trailing slash. The endpoints `apiEndpoint` and `dataStrategy.config.endpoint` can be +See `public/example_config.json` for example values. + +Values explained: + +* apiEndpoint - The endpoint where the API is located. +* loginCheckTimeout - How often (milliseconds) should the screen check for +status when it is not logged in, and waiting for being activated in the +administration. +* configFetchInterval - How often (milliseconds) should a fresh +config.json be fetched. +* refreshTokenTimeout - How often (milliseconds) should it be checked +whether the token needs to be refreshed? +* releaseTimestampIntervalTimeout - How often (milliseconds) should the +code check if a new release has been deployed, and reload if true? +* dataStrategy.config.interval - How often (milliseconds) should data be fetched +for the logged in screen? +* colorScheme.lat - Where is the screen located? Used for darkmode. +* colorScheme.lng - Where is the screen located? Used for darkmode. +* schedulingInterval - How often (milliseconds) should scheduling for the +screen be checked. +* debug - Should the screen be in debug mode? If true, the cursor will be +invisible. + +All endpoint should be configured without a trailing slash. The endpoints `apiEndpoint` can be left empty if the api is hosted from the root of the same domain as the client. E.g. if the api is at https://example.org and the client is at https://example.org/client diff --git a/infrastructure/itkdev/etc/confd/templates/config.tmpl b/infrastructure/itkdev/etc/confd/templates/config.tmpl index 5fc0eabd..a0bf386d 100644 --- a/infrastructure/itkdev/etc/confd/templates/config.tmpl +++ b/infrastructure/itkdev/etc/confd/templates/config.tmpl @@ -1,12 +1,13 @@ { "apiEndpoint": "{{ getenv "APP_API_ENDPOINT" "" }}", - "authenticationEndpoint": "{{ getenv "APP_API_AUTHENTICATION_ENDPOINT" "/v2/authentication/token" }}", - "authenticationRefreshTokenEndpoint": "{{ getenv "APP_API_AUTHENTICATION_REFRESH_ENDPOINT" "/v2/authentication/token/refresh" }}", + "loginCheckTimeout": {{ getenv "APP_LOGIN_CHECK_TIMEOUT" "20000" }}, + "configFetchInterval": {{ getenv "APP_CONFIG_FETCH_INTERVAL" "600000" }}, + "refreshTokenTimeout": {{ getenv "APP_REFRESH_TOKEN_TIMEOUT" "60000" }}, + "releaseTimestampIntervalTimeout": {{ getenv "APP_RELEASE_TIMESTAMP_INTERVAL_TIMEOUT" "600000" }}, "dataStrategy": { "type": "pull", "config": { - "interval": {{ getenv "APP_DATA_PULL_INTERVAL" "30000" }}, - "endpoint": "{{ getenv "APP_API_PATH" "" }}" + "interval": {{ getenv "APP_DATA_PULL_INTERVAL" "30000" }} } }, "colorScheme": { diff --git a/infrastructure/itkdev/etc/confd/templates/default.conf.tmpl b/infrastructure/itkdev/etc/confd/templates/default.conf.tmpl index 59defbd6..787669db 100644 --- a/infrastructure/itkdev/etc/confd/templates/default.conf.tmpl +++ b/infrastructure/itkdev/etc/confd/templates/default.conf.tmpl @@ -7,6 +7,8 @@ server { rewrite ^{{ getenv "APP_SCREEN_CLIENT_PATH" "/" }}(.*) /$1 break; index index.html; autoindex off; + add_header Cache-Control "public, max-age=604800"; + expires 7d; try_files $uri $uri/ =404; } diff --git a/infrastructure/itkdev/etc/confd/templates/nginx.conf.tmpl b/infrastructure/itkdev/etc/confd/templates/nginx.conf.tmpl index a3961034..7b978c7c 100644 --- a/infrastructure/itkdev/etc/confd/templates/nginx.conf.tmpl +++ b/infrastructure/itkdev/etc/confd/templates/nginx.conf.tmpl @@ -34,7 +34,11 @@ http { include /etc/nginx/mime.types; default_type application/octet-stream; - log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + set_real_ip_from 172.16.0.0/8; + real_ip_recursive on; + real_ip_header X-Forwarded-For; + + log_format main '$http_x_real_ip - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent" "$http_x_forwarded_for"'; diff --git a/infrastructure/os2display/Readme.md b/infrastructure/os2display/Readme.md index eff0fb47..090ae9bb 100644 --- a/infrastructure/os2display/Readme.md +++ b/infrastructure/os2display/Readme.md @@ -1,3 +1,26 @@ # OS2display image build This folder contains the infrastructure files for building the `os2display/display-client` image. + +## Environment variables that can be set + +### config.json + +* APP_API_ENDPOINT - The endpoint where the API can be called. +* APP_LOGIN_CHECK_TIMEOUT - How often (milliseconds) should the screen check for +status when it is not logged in, and waiting for being activated in the +administration. +* APP_CONFIG_FETCH_INTERVAL - How often (milliseconds) should a fresh +config.json be fetched. +* APP_REFRESH_TOKEN_TIMEOUT - How often (milliseconds) should it be checked +whether the token needs to be refreshed? +* APP_RELEASE_TIMESTAMP_INTERVAL_TIMEOUT - How often (milliseconds) should the +code check if a new release has been deployed, and reload if true? +* APP_DATA_PULL_INTERVAL - How often (milliseconds) should data be fetched for +the logged in screen? +* APP_CLIENT_LATITUDE - Where is the screen located? Used for darkmode. +* APP_CLIENT_LONGITUDE - Where is the screen located? Used for darkmode. +* APP_SCHEDULING_INTERVAL - How often (milliseconds) should scheduling for the +screen be checked. +* APP_DEBUG - Should the screen be in debug mode? If true, the cursor will be +invisible. diff --git a/infrastructure/os2display/etc/confd/templates/config.tmpl b/infrastructure/os2display/etc/confd/templates/config.tmpl index 69747181..5c11314f 100644 --- a/infrastructure/os2display/etc/confd/templates/config.tmpl +++ b/infrastructure/os2display/etc/confd/templates/config.tmpl @@ -1,12 +1,13 @@ { "apiEndpoint": "{{ getenv "APP_API_ENDPOINT" "/" }}", - "authenticationEndpoint": "{{ getenv "APP_API_AUTHENTICATION_ENDPOINT" "/v2/authentication/token" }}", - "authenticationRefreshTokenEndpoint": "{{ getenv "APP_API_AUTHENTICATION_REFRESH_ENDPOINT" "/v2/authentication/token/refresh" }}", + "loginCheckTimeout": {{ getenv "APP_LOGIN_CHECK_TIMEOUT" "20000" }}, + "configFetchInterval": {{ getenv "APP_CONFIG_FETCH_INTERVAL" "600000" }}, + "refreshTokenTimeout": {{ getenv "APP_REFRESH_TOKEN_TIMEOUT" "60000" }}, + "releaseTimestampIntervalTimeout": {{ getenv "APP_RELEASE_TIMESTAMP_INTERVAL_TIMEOUT" "600000" }}, "dataStrategy": { "type": "pull", "config": { - "interval": {{ getenv "APP_DATA_PULL_INTERVAL" "30000" }}, - "endpoint": "{{ getenv "APP_API_PATH" "/" }}" + "interval": {{ getenv "APP_DATA_PULL_INTERVAL" "30000" }} } }, "colorScheme": { diff --git a/infrastructure/os2display/etc/confd/templates/default.conf.tmpl b/infrastructure/os2display/etc/confd/templates/default.conf.tmpl index 59defbd6..787669db 100644 --- a/infrastructure/os2display/etc/confd/templates/default.conf.tmpl +++ b/infrastructure/os2display/etc/confd/templates/default.conf.tmpl @@ -7,6 +7,8 @@ server { rewrite ^{{ getenv "APP_SCREEN_CLIENT_PATH" "/" }}(.*) /$1 break; index index.html; autoindex off; + add_header Cache-Control "public, max-age=604800"; + expires 7d; try_files $uri $uri/ =404; } diff --git a/infrastructure/os2display/etc/confd/templates/nginx.conf.tmpl b/infrastructure/os2display/etc/confd/templates/nginx.conf.tmpl index a3961034..7b978c7c 100644 --- a/infrastructure/os2display/etc/confd/templates/nginx.conf.tmpl +++ b/infrastructure/os2display/etc/confd/templates/nginx.conf.tmpl @@ -34,7 +34,11 @@ http { include /etc/nginx/mime.types; default_type application/octet-stream; - log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + set_real_ip_from 172.16.0.0/8; + real_ip_recursive on; + real_ip_header X-Forwarded-For; + + log_format main '$http_x_real_ip - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent" "$http_x_forwarded_for"'; diff --git a/public/example_config.json b/public/example_config.json index 93041281..c5bf1609 100644 --- a/public/example_config.json +++ b/public/example_config.json @@ -1,12 +1,13 @@ { - "apiEndpoint": "", - "authenticationEndpoint": "/v2/authentication/screen", - "authenticationRefreshTokenEndpoint": "/v2/authentication/token/refresh", + "apiEndpoint": "https://os2display.example.org", + "loginCheckTimeout": 20000, + "configFetchInterval": 90000, + "refreshTokenTimeout": 900000, + "releaseTimestampIntervalTimeout": 600000, "dataStrategy": { "type": "pull", "config": { - "interval": 30000, - "endpoint": "" + "interval": 30000 } }, "colorScheme": { @@ -15,5 +16,11 @@ "lng": 10.0 }, "schedulingInterval": 60000, - "debug": false + "debug": false, + "logging": [ + { + "transport": "console", + "level": "info" + } + ] } diff --git a/src/app.js b/src/app.js index a7ae800f..f978ec1f 100644 --- a/src/app.js +++ b/src/app.js @@ -8,6 +8,7 @@ import Logger from './logger/logger'; import './app.scss'; import localStorageKeys from './local-storage-keys'; import fallback from './assets/fallback.png'; +import idFromPath from './id-from-path'; /** * App component. @@ -20,9 +21,9 @@ function App() { localStorageKeys.FALLBACK_IMAGE ); - const loginCheckTimeout = 15 * 1000; - const refreshTimeout = 60 * 1000; - const releaseTimestampIntervalTimeout = 1000 * 60 * 5; + const loginCheckTimeoutDefault = 20 * 1000; + const refreshTokenTimeoutDefault = 60 * 1000 * 15; + const releaseTimestampIntervalTimeoutDefault = 1000 * 60 * 10; const [running, setRunning] = useState(false); const [screen, setScreen] = useState(''); @@ -96,7 +97,7 @@ function App() { Logger.log('info', 'Refreshing token.'); ConfigLoader.loadConfig().then((config) => { - fetch(config.authenticationRefreshTokenEndpoint, { + fetch(`${config.apiEndpoint}/v2/authentication/token/refresh`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -167,8 +168,13 @@ function App() { }) ); - // Start refresh token interval. - refreshTokenIntervalRef.current = setInterval(checkToken, refreshTimeout); + ConfigLoader.loadConfig().then((config) => { + // Start refresh token interval. + refreshTokenIntervalRef.current = setInterval( + checkToken, + config.refreshTokenTimeout ?? refreshTokenTimeoutDefault + ); + }); }; const checkLogin = () => { @@ -181,7 +187,7 @@ function App() { startContent(localScreenId); } else { ConfigLoader.loadConfig().then((config) => { - fetch(config.authenticationEndpoint, { + fetch(`${config.apiEndpoint}/v2/authentication/screen`, { method: 'POST', mode: 'cors', credentials: 'include', @@ -224,7 +230,10 @@ function App() { clearTimeout(timeoutRef.current); } - timeoutRef.current = setTimeout(checkLogin, loginCheckTimeout); + timeoutRef.current = setTimeout( + checkLogin, + config.loginCheckTimeout ?? loginCheckTimeoutDefault + ); } }) .catch(() => { @@ -232,7 +241,10 @@ function App() { clearTimeout(timeoutRef.current); } - timeoutRef.current = setTimeout(checkLogin, loginCheckTimeout); + timeoutRef.current = setTimeout( + checkLogin, + config.loginCheckTimeout ?? loginCheckTimeoutDefault + ); }); }); } @@ -302,6 +314,24 @@ function App() { }; useEffect(() => { + const currentUrl = new URL(window.location.href); + + // Make sure have releaseVersion and releaseTimestamp set in url parameters. + if ( + !currentUrl.searchParams.has('releaseVersion') || + !currentUrl.searchParams.has('releaseTimestamp') + ) { + ReleaseLoader.loadConfig().then((release) => { + currentUrl.searchParams.set( + 'releaseTimestamp', + release.releaseTimestamp + ); + currentUrl.searchParams.set('releaseVersion', release.releaseVersion); + + window.history.replaceState(null, '', currentUrl); + }); + } + document.addEventListener('screen', screenHandler); document.addEventListener('reauthenticate', reauthenticateHandler); document.addEventListener('contentEmpty', contentEmpty); @@ -312,10 +342,13 @@ function App() { checkForUpdates(); - releaseTimestampIntervalRef.current = setInterval( - checkForUpdates, - releaseTimestampIntervalTimeout - ); + ConfigLoader.loadConfig().then((config) => { + releaseTimestampIntervalRef.current = setInterval( + checkForUpdates, + config.releaseTimestampIntervalTimeout ?? + releaseTimestampIntervalTimeoutDefault + ); + }); return function cleanup() { Logger.log('info', 'Unmounting App.'); @@ -341,6 +374,15 @@ function App() { }, []); useEffect(() => { + // Append screenId to current url for easier debugging. If errors are logged in the API's standard http log this + // makes it easy to see what screen client has made the http call by putting the screen id in the referer http + // header. + if (screen && screen['@id']) { + const url = new URL(window.location.href); + url.searchParams.set('screenId', idFromPath(screen['@id'])); + window.history.replaceState(null, '', url); + } + ConfigLoader.loadConfig().then((config) => { const token = localStorage.getItem(localStorageKeys.API_TOKEN); const tenantKey = localStorage.getItem(localStorageKeys.TENANT_KEY); diff --git a/src/app.spec.js b/src/app.spec.js index 4005f2a6..8ce11942 100644 --- a/src/app.spec.js +++ b/src/app.spec.js @@ -10,9 +10,16 @@ describe('Client tests', () => { fixture: 'awaiting-bind-key-response.json', }).as('bindKey'); + cy.intercept('GET', '**/config.json', { + statusCode: 200, + fixture: 'config.json', + }).as('config'); + cy.visit('/'); - cy.wait('@bindKey'); + // After this point we assume the config file is served from the browser + // cache, since it the nginx setup has a 1h caching set for config.json. + cy.wait(['@bindKey']); cy.get('.bind-key').should('exist'); cy.get('.bind-key') @@ -55,7 +62,6 @@ describe('Client tests', () => { cy.wait([ '@bindKey', - '@config', '@screen', '@groups', '@campaigns', @@ -124,7 +130,7 @@ describe('Client tests', () => { cy.wait([ '@bindKey', - '@config', + // '@config', '@screen', '@groups', '@campaigns', @@ -180,7 +186,6 @@ describe('Client tests', () => { cy.visit('/'); cy.wait([ '@bindKey', - '@config', '@screen', '@groups', '@campaigns', @@ -201,7 +206,7 @@ describe('Client tests', () => { }).as('bindKey'); cy.intercept('GET', '**/config.json', { - statusCode: 201, + statusCode: 200, fixture: 'config.json', }).as('config'); @@ -247,7 +252,6 @@ describe('Client tests', () => { cy.visit('/'); cy.wait([ '@bindKey', - '@config', '@screen', '@screen-groups', '@screen-campaigns', @@ -273,7 +277,7 @@ describe('Client tests', () => { }).as('bindKey'); cy.intercept('GET', '**/config.json', { - statusCode: 201, + statusCode: 200, fixture: 'config.json', }).as('config'); @@ -333,7 +337,6 @@ describe('Client tests', () => { cy.visit('/'); cy.wait([ '@bindKey', - '@config', '@screen', '@groups', '@campaigns', @@ -357,7 +360,7 @@ describe('Client tests', () => { }).as('bindKey'); cy.intercept('GET', '**/config.json', { - statusCode: 201, + statusCode: 200, fixture: 'config.json', }).as('config'); @@ -417,7 +420,6 @@ describe('Client tests', () => { cy.visit('/'); cy.wait([ '@bindKey', - '@config', '@screen', '@groups', '@campaigns', @@ -441,7 +443,7 @@ describe('Client tests', () => { }).as('bindKey'); cy.intercept('GET', '**/config.json', { - statusCode: 201, + statusCode: 200, fixture: 'config.json', }).as('config'); @@ -493,7 +495,11 @@ describe('Client tests', () => { }).as('layout'); cy.visit('/'); - cy.wait(['@bindKey', '@config', '@screen', '@layout']); + cy.wait([ + '@bindKey', + '@screen', + '@layout', + ]); cy.get('.region') .eq(0) @@ -512,7 +518,7 @@ describe('Client tests', () => { }).as('bindKey'); cy.intercept('GET', '**/config.json', { - statusCode: 201, + statusCode: 200, fixture: 'config.json', }).as('config'); @@ -577,7 +583,6 @@ describe('Client tests', () => { cy.visit('/'); cy.wait([ '@bindKey', - '@config', '@screen', '@groups', '@campaigns', diff --git a/src/config-loader.js b/src/config-loader.js index 20bed5f3..33396df9 100644 --- a/src/config-loader.js +++ b/src/config-loader.js @@ -1,5 +1,5 @@ -// Only fetch new config if more than 5 minutes have passed. -const configFetchInterval = 5 * 60 * 1000; +// Only fetch new config if more than 15 minutes have passed. +const configFetchIntervalDefault = 15 * 60 * 1000; // Defaults. let configData = null; @@ -18,10 +18,14 @@ const ConfigLoader = { activePromise = new Promise((resolve) => { const nowTimestamp = new Date().getTime(); - if (latestFetchTimestamp + configFetchInterval >= nowTimestamp) { + if ( + latestFetchTimestamp + + (configData?.configFetchInterval ?? configFetchIntervalDefault) >= + nowTimestamp + ) { resolve(configData); } else { - fetch('/client/config.json') + fetch(`/client/config.json?ts=${nowTimestamp}`) .then((response) => response.json()) .then((data) => { latestFetchTimestamp = nowTimestamp; @@ -39,16 +43,17 @@ const ConfigLoader = { // Default config. resolve({ - authenticationEndpoint: '/api/authentication/screen', - authenticationRefreshTokenEndpoint: - '/api/authentication/token/refresh', + apiEndpoint: '/api', dataStrategy: { type: 'pull', config: { interval: 30000, - endpoint: '/api', }, }, + loginCheckTimeout: 20000, + configFetchInterval: 900000, + refreshTokenTimeout: 15000, + releaseTimestampIntervalTimeout: 600000, colorScheme: { type: 'library', lat: 56.0, diff --git a/src/data-sync/data-sync.js b/src/data-sync/data-sync.js index 6a50fef0..f74d9e98 100644 --- a/src/data-sync/data-sync.js +++ b/src/data-sync/data-sync.js @@ -15,7 +15,7 @@ class DataSync { constructor(config) { this.start = this.start.bind(this); - this.config = config.config; + this.config = config; this.strategy = null; switch (config.type) { diff --git a/src/id-from-path.js b/src/id-from-path.js index e81ec63c..a00d6a25 100644 --- a/src/id-from-path.js +++ b/src/id-from-path.js @@ -1,6 +1,6 @@ /** * @param {object} string - The url to cut id from. - * @returns {boolean} The id or false. + * @returns {string|boolean} The id or false. */ function idFromPath(string) { if (typeof string === 'string') { diff --git a/src/release-loader.js b/src/release-loader.js index 6b49f81f..155499f9 100644 --- a/src/release-loader.js +++ b/src/release-loader.js @@ -3,7 +3,8 @@ */ export default class ReleaseLoader { static async loadConfig() { - return fetch('/client/release.json') + const nowTimestamp = new Date().getTime(); + return fetch(`/client/release.json?ts=${nowTimestamp}`) .then((response) => response.json()) .catch((err) => { /* eslint-disable-next-line no-console */ diff --git a/src/schedule.spec.js b/src/schedule.spec.js index d169e493..3206c0e2 100644 --- a/src/schedule.spec.js +++ b/src/schedule.spec.js @@ -76,7 +76,6 @@ describe('Schedule tests', () => { cy.visit('/'); cy.wait([ '@bindKey', - '@config', '@screen', '@groups', '@campaigns', @@ -161,7 +160,6 @@ describe('Schedule tests', () => { cy.visit('/'); cy.wait([ '@bindKey', - '@config', '@screen', '@groups', '@campaigns', @@ -246,7 +244,6 @@ describe('Schedule tests', () => { cy.visit('/'); cy.wait([ '@bindKey', - '@config', '@screen', '@groups', '@campaigns', @@ -331,7 +328,6 @@ describe('Schedule tests', () => { cy.visit('/'); cy.wait([ '@bindKey', - '@config', '@screen', '@groups', '@campaigns', diff --git a/src/service/contentService.js b/src/service/contentService.js index 0d8ffb10..3e87227a 100644 --- a/src/service/contentService.js +++ b/src/service/contentService.js @@ -44,13 +44,15 @@ class ContentService { Logger.log('info', 'Starting data synchronization'); ConfigLoader.loadConfig().then((config) => { - const dataStrategy = { ...config.dataStrategy }; + const dataStrategyConfig = { ...config.dataStrategy.config }; if (screenPath) { - dataStrategy.config.entryPoint = screenPath; + dataStrategyConfig.entryPoint = screenPath; } - this.dataSync = new DataSync(dataStrategy); + dataStrategyConfig.endpoint = config.apiEndpoint; + + this.dataSync = new DataSync(dataStrategyConfig); this.dataSync.start(); }); }