Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,25 @@ Development is being coordinated by the OCL Mapper Squad, consisting of team mem
1. `docker-compose -f docker-compose.yml up -d`
2. Visit http://localhost:4004

### Announcement banner
To show a banner above the app, for example for planned maintenance, set the optional `ANNOUNCEMENT` environment variable to JSON:

```json
{
"id": "maintenance-2026-11",
"text": {"en": "Planned maintenance on Saturday, 02:00-04:00 UTC.", "es": "Mantenimiento programado el sábado, de 02:00 a 04:00 UTC."},
"links": [{"label": {"en": "Details", "es": "Detalles"}, "url": "https://status.example.org/"}],
"until": "2026-11-09T00:00:00Z"
}
```

- `id`: dismissing the banner hides it in that browser until `id` changes.
- `text` and each link's `label`: plain text for each language code (`en`, `es`, `pt-BR`, `zh`). `en` is required, and shows for any language that has no entry.
- `links`: `https:` URLs, shown after the text. Use `[]` for none.
- `until` (optional): an ISO 8601 time with `Z` or an offset, such as `2026-11-09T00:00:00Z`. The banner stops showing after it, without a restart.

`start-prod.sh` writes the setting into `env-config.js` when the container starts, so a change shows after a restart. When it's unset, or isn't valid (bad JSON, a missing field, a link that isn't `https:`), no banner shows. With `npm start`, put `var ANNOUNCEMENT = <the JSON as a JavaScript string>;` in `public/env-config.js` instead (`node -p 'JSON.stringify(process.argv[1])' '<the JSON>'` prints one), and don't commit it.


### Eslint
```
Expand Down
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ services:
- ERRBIT_URL
- ERRBIT_KEY
- HOTJAR_ID
- ANNOUNCEMENT
- BRIDGE_MATCH_URL
- AI_ASSISTANT_API_URL
- SCISPACY_LOINC_API_URL
Expand Down
168 changes: 168 additions & 0 deletions src/common/__tests__/announcement.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
/* global globalThis */
import test from 'node:test'
import assert from 'node:assert/strict'
import { execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import vm from 'node:vm'

const { getAnnouncement, isDismissed, msUntilExpiry, rememberDismissal } = await import('../announcement.js')

const SETTING = {
id: 'maintenance-2026-11',
text: {en: 'Maintenance on Saturday.', es: 'Saturday maintenance, in Spanish.', 'pt-BR': 'Saturday maintenance, in Portuguese.'},
links: [
{label: {en: 'Details', es: 'Details, in Spanish'}, url: 'https://status.example.org/maintenance'},
{label: {en: 'Contact us'}, url: 'https://example.org/contact'},
],
until: '2026-11-09T00:00:00Z',
}
const BEFORE = Date.parse('2026-11-01T12:00:00Z')

const announce = (setting, language = 'en', now = BEFORE) => getAnnouncement(JSON.stringify(setting), language, now)

test('no setting: no banner', () => {
assert.equal(getAnnouncement(undefined, 'en', BEFORE), null)
assert.equal(getAnnouncement('', 'en', BEFORE), null)
assert.equal(getAnnouncement(' ', 'en', BEFORE), null)
})

test('a valid setting shows its text and links', () => {
assert.deepEqual(announce(SETTING), {
id: 'maintenance-2026-11',
text: 'Maintenance on Saturday.',
links: [
{label: 'Details', url: 'https://status.example.org/maintenance'},
{label: 'Contact us', url: 'https://example.org/contact'},
],
until: Date.parse('2026-11-09T00:00:00Z'),
})
})

test('until is optional, and links may be empty', () => {
assert.deepEqual(announce({id: 'a', text: {en: 'Hello'}, links: []}), {id: 'a', text: 'Hello', links: [], until: null})
})

test('text is returned as it was given, for the banner to show as plain text', () => {
assert.equal(announce({id: 'a', text: {en: '<b onmouseover="alert(1)">Hi</b>'}, links: []}).text, '<b onmouseover="alert(1)">Hi</b>')
})

test('invalid JSON: no banner', () => {
assert.equal(getAnnouncement('{"id": "a", "text": {"en": "Hi"}', 'en', BEFORE), null)
assert.equal(getAnnouncement('not json', 'en', BEFORE), null)
})

test('JSON that is not an object: no banner', () => {
['null', '[]', '"Hello"', '42', 'true'].forEach(raw => assert.equal(getAnnouncement(raw, 'en', BEFORE), null, raw))
})

test('a missing or empty id: no banner', () => {
assert.equal(announce({...SETTING, id: undefined}), null)
assert.equal(announce({...SETTING, id: ' '}), null)
assert.equal(announce({...SETTING, id: 7}), null)
})

test('missing or malformed text: no banner', () => {
assert.equal(announce({...SETTING, text: undefined}), null)
assert.equal(announce({...SETTING, text: 'Maintenance on Saturday.'}), null)
assert.equal(announce({...SETTING, text: {es: 'Saturday maintenance, in Spanish.'}}), null)
assert.equal(announce({...SETTING, text: {en: ''}}), null)
assert.equal(announce({...SETTING, text: {en: 'Hi', es: 3}}), null)
})

test('missing or malformed links: no banner', () => {
assert.equal(announce({...SETTING, links: undefined}), null)
assert.equal(announce({...SETTING, links: null}), null)
assert.equal(announce({...SETTING, links: {label: {en: 'Details'}, url: 'https://example.org'}}), null)
assert.equal(announce({...SETTING, links: ['https://example.org']}), null)
assert.equal(announce({...SETTING, links: [{url: 'https://example.org'}]}), null)
assert.equal(announce({...SETTING, links: [{label: {es: 'Details, in Spanish'}, url: 'https://example.org'}]}), null)
assert.equal(announce({...SETTING, links: [{label: {en: 'Details'}}]}), null)
})

test('a link that is not https: no banner', () => {
['http://example.org', 'javascript:alert(1)', 'data:text/html,<script>alert(1)</script>', '/blog/post', '//example.org', 'example.org'].forEach(url => {
assert.equal(announce({...SETTING, links: [{label: {en: 'Details'}, url}]}), null, url)
})
})

test('hidden after until', () => {
assert.equal(announce(SETTING, 'en', Date.parse('2026-11-09T00:00:00Z')), null)
assert.equal(announce(SETTING, 'en', Date.parse('2026-12-01T00:00:00Z')), null)
assert.equal(announce(SETTING, 'en', Date.parse('2026-11-08T23:59:59Z')).id, 'maintenance-2026-11')
})

test('the banner is checked again when its until arrives, or within a minute', () => {
assert.equal(msUntilExpiry(announce(SETTING), Date.parse('2026-11-08T23:59:30Z')), 30 * 1000)
assert.equal(msUntilExpiry(announce(SETTING), BEFORE), 60 * 1000)
assert.equal(msUntilExpiry(announce({...SETTING, until: undefined}), BEFORE), null)
})

test('checking again at each delay hides a banner at its until', () => {
const setting = {...SETTING, until: '2026-11-01T12:10:00Z'}
let now = BEFORE, checks = 0
for(let shown = announce(setting, 'en', now); shown; shown = announce(setting, 'en', now)) {
const delay = msUntilExpiry(shown, now)
assert.ok(delay > 0 && delay <= 60 * 1000, `a delay of at most a minute: ${delay}`)
now += delay
assert.ok(++checks <= 10)
}
assert.equal(now, Date.parse('2026-11-01T12:10:00Z'))
})

test('an until that is not a date: no banner', () => {
assert.equal(announce({...SETTING, until: 'next week'}), null)
assert.equal(announce({...SETTING, until: 1762646400000}), null)
})

test('shows the user\'s language, or its base language, or en', () => {
assert.equal(announce(SETTING, 'es').text, 'Saturday maintenance, in Spanish.')
assert.deepEqual(announce(SETTING, 'es').links.map(link => link.label), ['Details, in Spanish', 'Contact us'])
assert.equal(announce(SETTING, 'pt-BR').text, 'Saturday maintenance, in Portuguese.')
assert.equal(announce(SETTING, 'es-MX').text, 'Saturday maintenance, in Spanish.')
assert.equal(announce(SETTING, 'zh').text, 'Maintenance on Saturday.')
assert.equal(announce(SETTING, undefined).text, 'Maintenance on Saturday.')
assert.equal(announce(SETTING, 'constructor').text, 'Maintenance on Saturday.')
})

test('a dismissed announcement stays dismissed, and a new id shows again', () => {
const items = {}
globalThis.localStorage = {getItem: key => key in items ? items[key] : null, setItem: (key, value) => { items[key] = String(value) }}
assert.equal(isDismissed('maintenance-2026-11'), false)
rememberDismissal('maintenance-2026-11')
assert.equal(isDismissed('maintenance-2026-11'), true)
assert.equal(isDismissed('maintenance-2026-12'), false)
})

test('without storage, the banner shows and dismissing it does not throw', () => {
globalThis.localStorage = {getItem: () => { throw new Error('blocked') }, setItem: () => { throw new Error('blocked') }}
assert.equal(isDismissed('maintenance-2026-11'), false)
assert.doesNotThrow(() => rememberDismissal('maintenance-2026-11'))
})

// start-prod.sh writes the setting into env-config.js with its js_string function.
const START_PROD = readFileSync(new URL('../../../start-prod.sh', import.meta.url), 'utf8')
const JS_STRING = START_PROD.match(/^js_string\(\) \{\n[\s\S]*?^\}$/m)[0]
const envConfigLine = value => execFileSync('bash', ['-c', `${JS_STRING}\necho "var ANNOUNCEMENT = $(js_string "$1");"`, 'bash', value], {encoding: 'utf8'})

test('start-prod.sh writes the setting as one string literal that reads back unchanged', () => {
[
JSON.stringify(SETTING),
'{"id": "a", "text": {"en": "Say \\"hi\\"\\n to C:\\\\ and 中文 ✓"}}',
'line one\nline two\r\nline three',
'a\\', '\\"', 'tab\there', '\u2028\u2029', '</script><script>alert(1)</script>',
].forEach(value => {
const context = {}
vm.runInNewContext(envConfigLine(value), context)
assert.deepEqual(Object.keys(context), ['ANNOUNCEMENT'], value)
assert.equal(context.ANNOUNCEMENT, value)
})
})

test('a setting cannot end the string and run script in env-config.js', () => {
['"; globalThis.ran = true; "', '\\"; globalThis.ran = true; //', '"\nglobalThis.ran = true\n"', '"\rglobalThis.ran = true\r"'].forEach(value => {
const context = {}
vm.runInNewContext(envConfigLine(value), context)
assert.equal(context.ran, undefined, value)
assert.equal(context.ANNOUNCEMENT, value)
})
})
84 changes: 84 additions & 0 deletions src/common/announcement.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// The deployment's announcement banner, from its optional ANNOUNCEMENT setting
// (README, "Announcement banner"). start-prod.sh writes the setting into
// env-config.js as a string, and it's parsed and checked here, so a malformed
// setting shows no banner instead of breaking the page. text and each link
// label need an "en" entry, the fallback for other languages.

const DISMISSED_KEY = 'announcementDismissed'

// The longest wait between checks of until, so a timer that a sleeping laptop
// or a clock change delays can't keep an ended banner up for long.
const MAX_DELAY = 60 * 1000

const isObject = value => Boolean(value) && typeof value === 'object' && !Array.isArray(value)

const has = (object, key) => Object.prototype.hasOwnProperty.call(object, key)

// The entry for language, then for its base language (es for es-MX), then en.
const localize = (strings, language) => {
if(!isObject(strings) || !Object.values(strings).every(value => typeof value === 'string') || !has(strings, 'en') || !strings.en.trim())
return null
const code = [language, language?.split('-')[0]].find(tag => tag && has(strings, tag) && strings[tag].trim())
return strings[code || 'en']
}

const httpsURL = url => {
try {
const parsed = new URL(url)
return parsed.protocol === 'https:' ? parsed.href : null
} catch {
return null
}
}

// What the banner shows in language at now: {id, text, links: [{label, url}],
// until} (until in ms, or null), or null when the setting is unset, malformed
// or past its until.
export const getAnnouncement = (raw, language, now) => {
let setting
try {
setting = JSON.parse(raw)
} catch {
return null
}
if(!isObject(setting) || typeof setting.id !== 'string' || !setting.id.trim())
return null

const text = localize(setting.text, language)
if(!text || !Array.isArray(setting.links))
return null

const links = setting.links.map(link => ({label: localize(link?.label, language), url: httpsURL(link?.url)}))
if(links.some(link => !link.label || !link.url))
return null

let until = null
if(setting.until !== undefined) {
until = typeof setting.until === 'string' ? Date.parse(setting.until) : NaN
if(Number.isNaN(until) || now >= until)
return null
}

return {id: setting.id, text, links, until}
}

// How long until a shown banner must be checked again, so that it hides at its
// until while the page stays open: null when it has no until.
export const msUntilExpiry = (announcement, now) =>
announcement.until ? Math.min(announcement.until - now, MAX_DELAY) : null

export const isDismissed = id => {
try {
return localStorage.getItem(DISMISSED_KEY) === id
} catch {
return false
}
}

export const rememberDismissal = id => {
try {
localStorage.setItem(DISMISSED_KEY, id)
} catch {
// storage unavailable (private mode, blocked cookies); the banner simply reappears
}
}
2 changes: 0 additions & 2 deletions src/common/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,6 @@ export const ALL = '*';
export const URL_REGISTRY_DOC_LINK = 'https://docs.openconceptlab.org/en/latest/oclapi/apireference/urlregistry.html'
export const MATCHING_ALGORITHMS_DOC_LINK = 'https://docs.openconceptlab.org/en/latest/oclmap/matching-algorithms.html'
export const MAPPER_DOCS_LINK = 'https://docs.openconceptlab.org/en/latest/oclmap/overview.html'
export const MAPPER_ANNOUNCEMENT_LETTER_URL = 'https://openconceptlab.org/blog/letter-to-the-ocl-community?utm_source=oclmap&utm_medium=banner&utm_campaign=subscription_dates'
export const MAPPER_ANNOUNCEMENT_PRICING_URL = 'https://openconceptlab.org/pricing?utm_source=oclmap&utm_medium=banner&utm_campaign=subscription_dates'
export const TERMS_OF_USE_URL = 'https://openconceptlab.org/terms-of-use'
export const PRIVACY_POLICY_URL = 'https://openconceptlab.org/privacy-policy'
export const COMMUNITY_SITE_URL = 'https://openconceptlab.org'
Loading
Loading