diff --git a/extensions/cornerstone/src/Viewport/Overlays/CustomizableViewportOverlay.tsx b/extensions/cornerstone/src/Viewport/Overlays/CustomizableViewportOverlay.tsx index cadc7f650e6..f2f0ee1ffa3 100644 --- a/extensions/cornerstone/src/Viewport/Overlays/CustomizableViewportOverlay.tsx +++ b/extensions/cornerstone/src/Viewport/Overlays/CustomizableViewportOverlay.tsx @@ -14,7 +14,7 @@ import './CustomizableViewportOverlay.css'; import { useViewportRendering } from '../../hooks'; const EPSILON = 1e-4; -const { formatPN } = utils; +const { formatPN, formatValue } = utils; type ViewportData = StackViewportData | VolumeViewportData; @@ -184,7 +184,12 @@ function CustomizableViewportOverlay({ } else { const renderItem = customizationService.transform(item); - if (typeof renderItem.contentF === 'function') { + if ( + renderItem && + typeof renderItem === 'object' && + 'contentF' in renderItem && + typeof renderItem.contentF === 'function' + ) { return renderItem.contentF(overlayItemProps); } } @@ -357,7 +362,8 @@ function OverlayItem(props) { const { instance, customization = {} } = props; const { color, attribute, title, label, background } = customization; const value = customization.contentF?.(props, customization) ?? instance?.[attribute]; - if (value === undefined || value === null) { + const displayValue = formatValue(value); + if (displayValue === null || displayValue === '') { return null; } return ( @@ -367,7 +373,7 @@ function OverlayItem(props) { title={title} > {label ? {label} : null} - {value} + {displayValue} ); } diff --git a/extensions/default/src/ViewerLayout/ViewerHeader.tsx b/extensions/default/src/ViewerLayout/ViewerHeader.tsx index eb3b7789d17..8c8d3b50bbe 100644 --- a/extensions/default/src/ViewerLayout/ViewerHeader.tsx +++ b/extensions/default/src/ViewerLayout/ViewerHeader.tsx @@ -28,7 +28,7 @@ function ViewerHeader({ appConfig }: withAppTypes<{ appConfig: AppTypes.Config } if (dataSourceIdx !== -1 && existingDataSource) { searchQuery.append('datasources', pathname.substring(dataSourceIdx + 1)); } - preserveQueryParameters(searchQuery); + preserveQueryParameters(searchQuery, customizationService); navigate({ pathname: '/', diff --git a/extensions/default/src/customizations/overlayItemCustomization.tsx b/extensions/default/src/customizations/overlayItemCustomization.tsx index 6e72827e931..5d52e8e5ca2 100644 --- a/extensions/default/src/customizations/overlayItemCustomization.tsx +++ b/extensions/default/src/customizations/overlayItemCustomization.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { utils } from '@ohif/core'; export default { 'ohif.overlayItem': function (props) { @@ -13,7 +14,8 @@ export default { : this.contentF && typeof this.contentF === 'function' ? this.contentF(props) : null; - if (!value) { + const displayValue = utils.formatValue(value); + if (!displayValue) { return null; } @@ -24,7 +26,7 @@ export default { title={this.title || ''} > {this.label && {this.label}} - {value} + {displayValue} ); }, diff --git a/platform/app/public/customizations/veterinary.js b/platform/app/public/customizations/veterinary.js new file mode 100644 index 00000000000..ee7ecf4985a --- /dev/null +++ b/platform/app/public/customizations/veterinary.js @@ -0,0 +1,7 @@ +/** + * Example chaining module: ensures `veterinaryOverlay` is loaded and applied + * first when using `?customization=veterinary` alone. + */ +export default { + requires: ['veterinaryOverlay'], +}; diff --git a/platform/app/public/customizations/veterinaryOverlay.js b/platform/app/public/customizations/veterinaryOverlay.js new file mode 100644 index 00000000000..c4d792544cf --- /dev/null +++ b/platform/app/public/customizations/veterinaryOverlay.js @@ -0,0 +1,58 @@ +/** + * Example URL-loaded customization module: veterinaryOverlay + * + * Demonstrates a runtime-loaded customization that overrides the default + * viewport overlay with a veterinary-style demographics layout. Loaded via + * `?customization=veterinaryOverlay` (see CustomizationService URL handling). + * + * Uses the same default-export shape as cornerstone overlay samples + * (`global` at top level) and `inheritsFrom: 'ohif.overlayItem'` on each + * row, matching `extensions/cornerstone/.../viewportOverlayCustomization.tsx`. + */ +export default { + global: { + 'viewportOverlay.topLeft': { + $set: [ + { + id: 'PatientName', + inheritsFrom: 'ohif.overlayItem', + attribute: 'PatientName', + label: 'Patient', + title: 'Patient name', + }, + { + id: 'PatientID', + inheritsFrom: 'ohif.overlayItem', + attribute: 'PatientID', + label: 'ID', + title: 'Patient ID', + }, + { + id: 'StudyDate', + inheritsFrom: 'ohif.overlayItem', + attribute: 'StudyDate', + label: 'Date', + title: 'Study date', + }, + ], + }, + 'viewportOverlay.topRight': { + $set: [ + { + id: 'PatientSpecies', + inheritsFrom: 'ohif.overlayItem', + attribute: 'PatientSpecies', + label: 'Species', + title: 'Patient species', + }, + { + id: 'PatientBreed', + inheritsFrom: 'ohif.overlayItem', + attribute: 'PatientBreed', + label: 'Breed', + title: 'Patient breed', + }, + ], + }, + }, +}; diff --git a/platform/app/src/App.tsx b/platform/app/src/App.tsx index 18393f6439d..0f13807c639 100644 --- a/platform/app/src/App.tsx +++ b/platform/app/src/App.tsx @@ -110,7 +110,6 @@ function App({ cineService, userAuthenticationService, uiNotificationService, - customizationService, } = servicesManager.services; const providers = [ @@ -142,8 +141,8 @@ function App({ let authRoutes = null; - // Should there be a generic call to init on the extension manager? - customizationService.init(extensionManager); + // customizationService.init(extensionManager) runs in appInit after extensions register; + // do not call init again here — repeated init would duplicate-merge unless guarded (see CustomizationService.init). // Use config to create routes const appRoutes = createRoutes({ diff --git a/platform/app/src/appInit.js b/platform/app/src/appInit.js index 717e7f172f1..fd4971c1318 100644 --- a/platform/app/src/appInit.js +++ b/platform/app/src/appInit.js @@ -28,7 +28,7 @@ import loadModules, { loadModule as peerImport } from './pluginImports'; import { publicUrl } from './utils/publicUrl'; /** - * @param {object|func} appConfigOrFunc - application configuration, or a function that returns application configuration + * @param {object|function} appConfigOrFunc - application configuration, or a function that returns application configuration * @param {object[]} defaultExtensions - array of extension objects */ async function appInit(appConfigOrFunc, defaultExtensions, defaultModes) { @@ -93,6 +93,11 @@ async function appInit(appConfigOrFunc, defaultExtensions, defaultModes) { const loadedExtensions = await loadModules([...defaultExtensions, ...appConfig.extensions]); await extensionManager.registerExtensions(loadedExtensions, appConfig.dataSources); + const { customizationService } = servicesManager.services; + // Merge extension default/global modules first; then URL ?customization= globals layer on top. + customizationService.init(extensionManager); + await customizationService.applyWindowUrlCustomizations(); + // TODO: We no longer use `utils.addServer` // TODO: We no longer init webWorkers at app level // TODO: We no longer init the user Manager diff --git a/platform/app/src/routes/WorkList/WorkList.tsx b/platform/app/src/routes/WorkList/WorkList.tsx index 9c97426ea6f..56ab1252b5c 100644 --- a/platform/app/src/routes/WorkList/WorkList.tsx +++ b/platform/app/src/routes/WorkList/WorkList.tsx @@ -200,11 +200,13 @@ function WorkList({ } }); - preserveQueryStrings(queryString); + preserveQueryStrings(queryString, customizationService); const search = qs.stringify(queryString, { skipNull: true, skipEmptyString: true, + // preserveQueryStrings stores preserved keys as arrays; default indices format breaks plain keys like configUrl + arrayFormat: 'repeat', }); navigate({ pathname: '/', @@ -417,7 +419,7 @@ function WorkList({ query.append('configUrl', filterValues.configUrl); } query.append('StudyInstanceUIDs', studyInstanceUid); - preserveQueryParameters(query); + preserveQueryParameters(query, customizationService); return ( mode.displayName && ( diff --git a/platform/app/src/utils/preserveQueryParameters.test.ts b/platform/app/src/utils/preserveQueryParameters.test.ts new file mode 100644 index 00000000000..011cb08fbbc --- /dev/null +++ b/platform/app/src/utils/preserveQueryParameters.test.ts @@ -0,0 +1,104 @@ +import qs from 'qs'; + +import { preserveQueryParameters, preserveQueryStrings } from './preserveQueryParameters'; + +describe('preserveQueryParameters', () => { + it('preserves base keys as query arrays', () => { + const current = new URLSearchParams(); + current.append('configUrl', 'foo.js'); + const out = new URLSearchParams(); + preserveQueryParameters(out, undefined, current); + expect(out.getAll('configUrl')).toEqual(['foo.js']); + }); + + it('preserves all repeated values for the customization key', () => { + const current = new URLSearchParams(); + current.append('customization', 'a'); + current.append('customization', 'b'); + const out = new URLSearchParams(); + preserveQueryParameters(out, undefined, current); + expect(out.getAll('customization')).toEqual(['a', 'b']); + }); + + it('does not preserve unrelated keys', () => { + const current = new URLSearchParams(); + current.append('foo', 'bar'); + const out = new URLSearchParams(); + preserveQueryParameters(out, undefined, current); + expect(out.get('foo')).toBeNull(); + }); + + it('uses customization service values for multi-key preservation', () => { + const customizationService = { + getValue: jest.fn().mockReturnValue(['customization', 'customizationAlt']), + }; + const current = new URLSearchParams(); + current.append('customizationAlt', 'c'); + const out = new URLSearchParams(); + preserveQueryParameters(out, customizationService, current); + expect(out.getAll('customizationAlt')).toEqual(['c']); + expect(customizationService.getValue).toHaveBeenCalled(); + }); +}); + +describe('preserveQueryStrings', () => { + it('keeps all preserved keys as arrays', () => { + const current = new URLSearchParams(); + current.append('configUrl', 'foo.js'); + current.append('customization', 'a'); + current.append('customization', 'b'); + + const out: Record = {}; + preserveQueryStrings(out, undefined, current); + expect(out.configUrl).toEqual(['foo.js']); + expect(out.customization).toEqual(['a', 'b']); + }); + + it('keeps a single customization value as an array', () => { + const current = new URLSearchParams(); + current.append('customization', 'only'); + const out: Record = {}; + preserveQueryStrings(out, undefined, current); + expect(out.customization).toEqual(['only']); + }); + + it('uses customization service values for query string preservation', () => { + const customizationService = { + getValue: jest.fn().mockReturnValue(['customization', 'customizationAlt']), + }; + const current = new URLSearchParams(); + current.append('customizationAlt', 'c'); + const out: Record = {}; + preserveQueryStrings(out, customizationService, current); + expect(out.customizationAlt).toEqual(['c']); + expect(customizationService.getValue).toHaveBeenCalled(); + }); + + it('serializes single preserved values as plain query keys with arrayFormat repeat', () => { + const current = new URLSearchParams(); + current.append('configUrl', 'foo.js'); + const out: Record = {}; + preserveQueryStrings(out, undefined, current); + const search = qs.stringify(out, { + skipNull: true, + skipEmptyString: true, + arrayFormat: 'repeat', + }); + expect(search).toBe('configUrl=foo.js'); + expect(search).not.toMatch(/configUrl\[/); + }); + + it('serializes repeated preserved values as repeated keys', () => { + const current = new URLSearchParams(); + current.append('customization', 'a'); + current.append('customization', 'b'); + const out: Record = {}; + preserveQueryStrings(out, undefined, current); + const search = qs.stringify(out, { + skipNull: true, + skipEmptyString: true, + arrayFormat: 'repeat', + }); + expect(search).toBe('customization=a&customization=b'); + }); +}); diff --git a/platform/app/src/utils/preserveQueryParameters.ts b/platform/app/src/utils/preserveQueryParameters.ts index f50e978ad18..237b6d5cf5c 100644 --- a/platform/app/src/utils/preserveQueryParameters.ts +++ b/platform/app/src/utils/preserveQueryParameters.ts @@ -1,26 +1,55 @@ -function preserve(query, current, key) { - const value = current.get(key); - if (value) { - query.append(key, value); +import type CustomizationService from '@ohif/core/src/services/CustomizationService'; + +/** + * Keys preserved when navigating between worklist and viewer modes. + * All preserved keys are handled as multi-valued query parameters. + */ +export const PRESERVE_CUSTOMIZATION_KEYS_KEY = 'ohif.preserveCustomizationKeys'; +export const preserveKeys = [ + 'configUrl', + 'multimonitor', + 'screenNumber', + 'hangingProtocolId', + 'customization', +]; + +function preserveKey(query: URLSearchParams, current: URLSearchParams, key: string) { + const values = current.getAll(key); + for (const value of values) { + if (value) { + query.append(key, value); + } } } -export const preserveKeys = ['configUrl', 'multimonitor', 'screenNumber', 'hangingProtocolId']; +function getPreserveKeys(customizationService?: CustomizationService): string[] { + const customKeys = customizationService?.getValue?.(PRESERVE_CUSTOMIZATION_KEYS_KEY, []) || []; + if (!customKeys?.length) { + return preserveKeys; + } + + return [...preserveKeys, ...customKeys]; +} export function preserveQueryParameters( - query, - current = new URLSearchParams(window.location.search) -) { - for (const key of preserveKeys) { - preserve(query, current, key); + query: URLSearchParams, + customizationService?: CustomizationService, + current: URLSearchParams = new URLSearchParams(window.location.search) +): void { + for (const key of getPreserveKeys(customizationService)) { + preserveKey(query, current, key); } } -export function preserveQueryStrings(query, current = new URLSearchParams(window.location.search)) { - for (const key of preserveKeys) { - const value = current.get(key); - if (value) { - query[key] = value; +export function preserveQueryStrings( + query: Record, + customizationService?: CustomizationService, + current: URLSearchParams = new URLSearchParams(window.location.search) +): void { + for (const key of getPreserveKeys(customizationService)) { + const values = current.getAll(key).filter(Boolean); + if (values.length) { + query[key] = values; } } } diff --git a/platform/core/src/services/CustomizationService/CustomizationService.init.test.ts b/platform/core/src/services/CustomizationService/CustomizationService.init.test.ts new file mode 100644 index 00000000000..ea33d84928b --- /dev/null +++ b/platform/core/src/services/CustomizationService/CustomizationService.init.test.ts @@ -0,0 +1,50 @@ +import CustomizationService from './CustomizationService'; + +const commandsManager = {}; + +describe('CustomizationService.init (extension default/global deduplication)', () => { + it('does not re-merge the same extension customization modules on repeated init', () => { + const getModuleEntry = jest.fn((id: string) => { + if (id === 'ext1.customizationModule.default') { + return { value: { fromDefault: { $set: 1 } } }; + } + if (id === 'ext1.customizationModule.global') { + return { value: { fromGlobal: { $set: 'g' } } }; + } + return undefined; + }); + + const extensionManager = { + registeredExtensionIds: ['ext1'], + getRegisteredExtensionIds: () => ['ext1'], + getModuleEntry, + }; + + const service = new CustomizationService({ commandsManager, configuration: {} }); + service.init(extensionManager as any); + service.init(extensionManager as any); + + expect(getModuleEntry).toHaveBeenCalledTimes(2); + expect(service.getCustomization('fromDefault')).toBe(1); + expect(service.getCustomization('fromGlobal')).toBe('g'); + }); + + it('merges a module the first time it appears on a later init', () => { + const moduleEntries: Record }> = {}; + + const extensionManager = { + registeredExtensionIds: ['ext2'], + getRegisteredExtensionIds: () => ['ext2'], + getModuleEntry: (id: string) => moduleEntries[id], + }; + + const service = new CustomizationService({ commandsManager, configuration: {} }); + service.init(extensionManager as any); + expect(service.getCustomization('late')).toBeUndefined(); + + moduleEntries['ext2.customizationModule.default'] = { value: { late: { $set: true } } }; + service.init(extensionManager as any); + + expect(service.getCustomization('late')).toBe(true); + }); +}); diff --git a/platform/core/src/services/CustomizationService/CustomizationService.requires.test.ts b/platform/core/src/services/CustomizationService/CustomizationService.requires.test.ts new file mode 100644 index 00000000000..277e699244a --- /dev/null +++ b/platform/core/src/services/CustomizationService/CustomizationService.requires.test.ts @@ -0,0 +1,184 @@ +import CustomizationService from './CustomizationService'; + +const commandsManager = {}; + +const policy = { + prefixes: { default: './customizations/' }, +}; + +describe('CustomizationService.requires (URL customization modules)', () => { + let service: CustomizationService; + + beforeEach(() => { + service = new CustomizationService({ commandsManager, configuration: {} }); + }); + + it('loads a single module', async () => { + const importFn = jest.fn(async (url: string) => ({ + customizations: { global: { entry: { value: url } } }, + })); + const loaded = await service.requires(['A'], { policy, importFn }); + expect(loaded).toHaveLength(1); + expect(loaded[0].request.name).toBe('A'); + expect(loaded[0].module.customizations).toBeDefined(); + expect(importFn).toHaveBeenCalledTimes(1); + }); + + it('returns immediately when the same module is already loaded', async () => { + const importFn = jest.fn(async (url: string) => ({ + customizations: { global: { entry: { value: url } } }, + })); + await service.requires(['A'], { policy, importFn }); + expect(importFn).toHaveBeenCalledTimes(1); + const loadedAgain = await service.requires(['A'], { policy, importFn }); + expect(loadedAgain).toHaveLength(0); + expect(importFn).toHaveBeenCalledTimes(1); + }); + + it('loads dependencies first via module requires', async () => { + const importFn = jest.fn(async (url: string) => { + if (url.endsWith('/A.js')) { + return { + customizations: { global: { 'pkg.A': { value: 'A' } }, requires: ['B'] }, + }; + } + if (url.endsWith('/B.js')) { + return { + customizations: { global: { 'pkg.B': { value: 'B' } } }, + }; + } + return {}; + }); + + const loaded = await service.requires(['A'], { policy, importFn }); + + expect(loaded.map(l => l.request.name)).toEqual(['B', 'A']); + }); + + it('handles cycles per the spec: A requires B, B requires A => B then A', async () => { + const importFn = jest.fn(async (url: string) => { + if (url.endsWith('/A.js')) { + return { + customizations: { global: { 'pkg.A': { value: 'A' } }, requires: ['B'] }, + }; + } + if (url.endsWith('/B.js')) { + return { + customizations: { global: { 'pkg.B': { value: 'B' } }, requires: ['A'] }, + }; + } + return {}; + }); + + const loaded = await service.requires(['A'], { policy, importFn }); + + expect(loaded.map(l => l.request.name)).toEqual(['B', 'A']); + expect(importFn).toHaveBeenCalledTimes(2); + }); + + it('does not treat customization field refs as URL dependencies', async () => { + const importFn = jest.fn(async () => ({ + customizations: { + global: { + 'viewportOverlay.topLeft.X': { customization: 'ohif.overlayItem' }, + }, + }, + })); + const loaded = await service.requires(['A'], { policy, importFn }); + expect(loaded).toHaveLength(1); + expect(importFn).toHaveBeenCalledTimes(1); + }); + + it('parses comma-separated names like the URL query integration', async () => { + const importFn = jest.fn(async () => ({ + customizations: { global: { 'pkg.X': {} } }, + })); + const loaded = await service.requires(['A', 'B', '/default/C'], { policy, importFn }); + expect(loaded.map(l => l.request.name)).toEqual(['A', 'B', 'C']); + expect(importFn).toHaveBeenCalledTimes(3); + }); + + it('logs warnings for rejected entries but still loads valid ones when not strict', async () => { + const importFn = jest.fn(async () => ({ + customizations: { global: {} }, + })); + const warn = jest.fn(); + const loaded = await service.requires(['A', '/missing/foo', '../escape'], { + policy, + importFn, + logger: { warn, error: jest.fn() }, + }); + expect(loaded).toHaveLength(1); + expect(loaded[0].request.name).toBe('A'); + expect(warn).toHaveBeenCalled(); + }); + + it('throws in strict mode when any path is rejected as invalid', async () => { + const importFn = jest.fn(async () => ({ + customizations: { global: {} }, + })); + const outcome = await service + .requires(['A', '/missing/foo'], { + policy: { ...policy, strict: true }, + importFn, + logger: { warn: jest.fn(), error: jest.fn() }, + }) + .catch(e => e); + expect(outcome).toBeInstanceOf(Error); + expect((outcome as Error).message).toMatch(/strict mode/); + expect(importFn).not.toHaveBeenCalled(); + }); + + it('throws in strict mode when import fails', async () => { + const importFn = jest.fn(async () => { + throw new Error('404'); + }); + const outcome = await service + .requires(['A'], { + policy: { ...policy, strict: true }, + importFn, + logger: { warn: jest.fn(), error: jest.fn() }, + }) + .catch(e => e); + expect(outcome).toBeInstanceOf(Error); + expect((outcome as Error).message).toMatch(/failed to import/); + }); + + it('warns and skips when import fails if not strict', async () => { + const importFn = jest.fn(async () => { + throw new Error('404'); + }); + const warn = jest.fn(); + const loaded = await service.requires(['A'], { + policy, + importFn, + logger: { warn, error: jest.fn() }, + }); + expect(loaded).toHaveLength(0); + expect(warn).toHaveBeenCalled(); + }); + + it('throws in strict mode when the module has no customization payload', async () => { + const importFn = jest.fn(async () => ({})); + const outcome = await service + .requires(['A'], { + policy: { ...policy, strict: true }, + importFn, + logger: { warn: jest.fn(), error: jest.fn() }, + }) + .catch(e => e); + expect(outcome).toBeInstanceOf(Error); + expect((outcome as Error).message).toMatch(/no customizations payload/); + }); + + it('applyCustomizationUrlSearchParams delegates to requires', async () => { + const importFn = jest.fn(async () => ({ + customizations: { global: { 'pkg.X': {} } }, + })); + const params = new URLSearchParams(); + params.append('customization', 'A,B'); + params.append('customization', '/default/C'); + await service.applyCustomizationUrlSearchParams(params, { policy, importFn }); + expect(importFn).toHaveBeenCalledTimes(3); + }); +}); diff --git a/platform/core/src/services/CustomizationService/CustomizationService.ts b/platform/core/src/services/CustomizationService/CustomizationService.ts index 439810848a4..1bb0fb28141 100644 --- a/platform/core/src/services/CustomizationService/CustomizationService.ts +++ b/platform/core/src/services/CustomizationService/CustomizationService.ts @@ -2,7 +2,21 @@ import update, { extend } from 'immutability-helper'; import { PubSubService } from '../_shared/pubSubServiceInterface'; import type { Customization } from './types'; import type { CommandsManager } from '../../classes'; -import type { ExtensionManager } from '../../extensions'; +import type ExtensionManager from '../../extensions/ExtensionManager'; +import { getCustomizationUrlPolicy } from './customizationUrl'; +import { getUrlCustomizationModulePayload } from './getUrlCustomizationModulePayload'; +import { resolveCustomizationUrl } from './resolve'; +import { + parseCustomizationParams, + validateCustomizationRequests, +} from './validate'; +import type { ValidatedCustomization } from './validate'; +import type { CustomizationUrlPolicy } from './customizationUrlDefaults'; +import type { + CustomizationModule, + LoadedCustomization, + LoadOptions, +} from './customizationUrlTypes'; const EVENTS = { MODE_CUSTOMIZATION_MODIFIED: 'event::CustomizationService:modeModified', @@ -28,7 +42,8 @@ export enum CustomizationScope { /** * Default customizations that serve as fallbacks when no global or mode-specific - * customizations are defined. These can only be defined once. + * customizations are defined. These are not cleared when the service re-inits + * for a mode change; only Mode scope is reset. */ Default = 'default', } @@ -89,10 +104,9 @@ export default class CustomizationService extends PubSubService { private modeCustomizations = new Map(); /** - * A collection of default customizations used as fallbacks. These serve as - * the base configuration and are registered at setup. Default customizations - * provide baseline values that can be overridden by mode or global customizations. - * Use these for cases where default values are necessary for predictable behavior. + * A collection of default customizations used as fallbacks. Entries are merged + * over time (including from `init()` re-reading extension default modules) and + * are not cleared on mode change. Mode and Global scopes override these values. */ private defaultCustomizations = new Map(); @@ -103,31 +117,62 @@ export default class CustomizationService extends PubSubService { private transformedCustomizations = new Map(); private configuration: AppTypes.Config; + /** + * URL customization modules already imported and applied (key = normalized `/prefix/name`). + * Entries are kept for the lifetime of the page: repeated loads skip imports, and the app + * normally applies `?customization=` only at bootstrap (see {@link applyWindowUrlCustomizations}). + */ + private _urlCustomizationLoaded = new Map(); + + private _urlCustomizationPending = new Map>(); + + /** + * Extension module entry ids (e.g. `${extensionId}.customizationModule.default`) whose + * default/global payloads have already been merged via {@link init}. Matches the URL + * loader pattern: repeated {@link init} skips work for the same slot so immutability-style + * merges are not applied twice. A slot is recorded only after a module was present and applied; + * if a module appears only on a later {@link init} (e.g. a newly registered extension), it is merged then. + */ + private _extensionCustomizationModuleApplied = new Set(); + constructor({ configuration, commandsManager }) { super(EVENTS); this.configuration = configuration; this.commandsManager = commandsManager; } + /** + * Clears mode customizations and merges each extension's `customizationModule.default` / + * `customizationModule.global` into the service. Safe to call multiple times (e.g. from + * {@link onModeEnter}): each extension module slot is merged at most once per page session, + * matching the deduplication pattern used for URL-loaded modules in {@link requires}. + * Slots with no module yet are left unmarked so a later call can merge when the module appears. + */ public init(extensionManager: ExtensionManager): void { this.extensionManager = extensionManager; - // Clear defaults as those are defined by the customization modules - this.defaultCustomizations.clear(); - // Clear modes because those are defined in onModeEnter functions. + // Mode customizations are defined per mode in onModeEnter; reset them here. + // Default customizations are not cleared — they are merged again from + // extension modules below so definitions stay available across mode changes. this.modeCustomizations.clear(); this.extensionManager.getRegisteredExtensionIds().forEach(extensionId => { const keyDefault = `${extensionId}.customizationModule.default`; - const defaultCustomizations = this._findExtensionValue(keyDefault); - if (defaultCustomizations) { - const { value } = defaultCustomizations; - this._addReference(value, CustomizationScope.Default); + if (!this._extensionCustomizationModuleApplied.has(keyDefault)) { + const defaultCustomizations = this._findExtensionValue(keyDefault); + if (defaultCustomizations) { + const { value } = defaultCustomizations; + this._addReference(value, CustomizationScope.Default); + this._extensionCustomizationModuleApplied.add(keyDefault); + } } const keyGlobal = `${extensionId}.customizationModule.global`; - const globalCustomizations = this._findExtensionValue(keyGlobal); - if (globalCustomizations) { - const { value } = globalCustomizations; - this._addReference(value, CustomizationScope.Global); + if (!this._extensionCustomizationModuleApplied.has(keyGlobal)) { + const globalCustomizations = this._findExtensionValue(keyGlobal); + if (globalCustomizations) { + const { value } = globalCustomizations; + this._addReference(value, CustomizationScope.Global); + this._extensionCustomizationModuleApplied.add(keyGlobal); + } } }); @@ -138,6 +183,109 @@ export default class CustomizationService extends PubSubService { } } + /** + * Loads and applies `?customization=` modules from `window.location.search`. + * Wraps {@link applyCustomizationUrlSearchParams} in try/catch so callers + * (e.g. app bootstrap) do not need their own error handling. + * + * **Intended SPA behavior:** The shell typically calls this once during startup. It does not + * run again on client-side route changes. The query key `customization` may still appear in + * URLs (for example preserved by worklist navigation) without implying that modules are + * re-evaluated on every navigation. Modules resolved here are also deduplicated by normalized + * URL for the lifetime of the page in {@link requires}. To pick up a different `?customization=` + * set, use a full page load or call {@link applyCustomizationUrlSearchParams} / + * {@link requires} from your own integration code when appropriate. + */ + public async applyWindowUrlCustomizations(overrides?: Partial): Promise { + try { + if (typeof window === 'undefined') { + return; + } + await this.applyCustomizationUrlSearchParams( + new URLSearchParams(window.location.search), + overrides + ); + } catch (err) { + console.warn('[customizationUrl] application failed:', err); + } + } + + /** + * Parses `?customization=` values from the search string and delegates to + * {@link requires}. + */ + public async applyCustomizationUrlSearchParams( + params: URLSearchParams, + overrides?: Partial + ): Promise { + const raws = parseCustomizationParams(params); + if (!raws.length) { + return; + } + await this.requires(raws, overrides); + } + + /** + * Depth-first dynamic import of URL customization modules + * `requires` edges and `customization` field + * references are loaded before dependents. Already-loaded modules (same normalized key) are + * skipped for the rest of the page session; they are not unloaded when the address bar changes. + * + * When `policy.strict` is true, invalid query entries, resolve failures, failed + * imports, or modules without a customization payload reject the returned promise. + * When not strict, those cases are warned and skipped. + */ + public requires( + names: string | string[], + overrides?: Partial + ): Promise { + const policy = overrides?.policy ?? getCustomizationUrlPolicy(this); + const list = (Array.isArray(names) ? names : [names]) + .map(s => String(s).trim()) + .filter(Boolean); + if (!list.length) { + return Promise.resolve([]); + } + + const { valid, rejected } = validateCustomizationRequests(list, policy); + const logger = overrides?.logger || console; + for (const r of rejected) { + logger.warn(`[customizationUrl] rejecting customization "${r.raw}": ${r.reason}`); + } + if (policy.strict && rejected.length > 0) { + return Promise.reject( + new Error(`[customizationUrl] strict mode: ${rejected.length} invalid entries`) + ); + } + if (!valid.length) { + return Promise.resolve([]); + } + + const importFn = overrides?.importFn ?? this._urlDefaultImport.bind(this); + const requestedSet = new Set(); + const newlyLoaded: LoadedCustomization[] = []; + + return valid + .reduce( + (prev, request) => + prev.then(() => + this._urlCustomizationLoadOne( + request, + policy, + importFn, + logger, + requestedSet, + newlyLoaded + ) + ), + Promise.resolve() as Promise + ) + .then(() => { + this._applyLoadedUrlCustomizationModules(newlyLoaded); + return newlyLoaded; + }); + } + public onModeEnter(): void { this.clearTransformedCustomizations(); @@ -159,6 +307,197 @@ export default class CustomizationService extends PubSubService { this.modeCustomizations.clear(); } + private _urlDefaultImport(url: string): Promise { + if ( + typeof window !== 'undefined' && + typeof (window as any).browserImportFunction === 'function' + ) { + return (window as any).browserImportFunction(url); + } + return Promise.reject(new Error(`No runtime importer available to load ${url}`)); + } + + private _normalizeImportedCustomizationModule(imported: any): CustomizationModule { + return imported && typeof imported === 'object' && 'customizations' in imported + ? imported + : imported && typeof imported.default === 'object' + ? imported.default + : imported; + } + + private _collectUrlDependencyRefs(module: CustomizationModule): string[] { + const refs = new Set(); + const payload = getUrlCustomizationModulePayload(module); + if (!payload || typeof payload !== 'object') { + return Array.from(refs); + } + const moduleRequires = (payload as any).requires; + if (typeof moduleRequires === 'string' && moduleRequires) { + refs.add(moduleRequires); + } else if (Array.isArray(moduleRequires)) { + for (const id of moduleRequires) { + if (typeof id === 'string' && id) { + refs.add(id); + } + } + } + return Array.from(refs); + } + + private _urlDependencyToRequest( + name: string, + policy: CustomizationUrlPolicy + ): ValidatedCustomization | null { + const trimmed = name.trim(); + if (trimmed && !trimmed.startsWith('/') && /^ohif\.[a-zA-Z0-9._-]+$/.test(trimmed)) { + return null; + } + const result = validateCustomizationRequests([name], policy); + if (result.valid.length) { + return result.valid[0]; + } + return null; + } + + private _urlCustomizationLoadOne( + request: ValidatedCustomization, + policy: CustomizationUrlPolicy, + importFn: (url: string) => Promise, + logger: { warn: (...args: any[]) => void; error: (...args: any[]) => void }, + requestedSet: Set, + newlyLoaded: LoadedCustomization[] + ): Promise { + const key = request.normalized; + if (this._urlCustomizationLoaded.has(key)) { + return Promise.resolve(this._urlCustomizationLoaded.get(key) || null); + } + if (this._urlCustomizationPending.has(key)) { + return this._urlCustomizationPending.get(key)!; + } + + requestedSet.add(key); + + const promise = this._urlCustomizationLoadOneBody( + request, + policy, + importFn, + logger, + requestedSet, + newlyLoaded + ); + + this._urlCustomizationPending.set(key, promise); + // Use then(success, failure) for cleanup — `finally` left rejections unhandled + // with the current Promise polyfill in the Jest/Node test stack. + promise.then( + () => this._urlCustomizationPending.delete(key), + () => this._urlCustomizationPending.delete(key) + ); + return promise; + } + + private _urlCustomizationLoadOneBody( + request: ValidatedCustomization, + policy: CustomizationUrlPolicy, + importFn: (url: string) => Promise, + logger: { warn: (...args: any[]) => void; error: (...args: any[]) => void }, + requestedSet: Set, + newlyLoaded: LoadedCustomization[] + ): Promise { + const key = request.normalized; + const importFailedSentinel = Symbol('importFailed'); + + let url: string; + try { + url = resolveCustomizationUrl(request, policy); + } catch (err) { + const msg = `[customizationUrl] failed to resolve "${request.raw}": ${(err as Error).message}`; + if (policy.strict) { + return Promise.reject(new Error(msg)); + } + logger.warn(msg); + return Promise.resolve(null); + } + + return importFn(url) + .catch(err => { + const msg = `[customizationUrl] failed to import customization "${request.raw}" (${url}): ${(err as Error)?.message ?? String(err)}`; + if (policy.strict) { + throw new Error(msg); + } + logger.warn( + `[customizationUrl] failed to import customization "${request.raw}" (${url})`, + err + ); + return importFailedSentinel; + }) + .then(importedOrSentinel => { + if (importedOrSentinel === importFailedSentinel) { + return null; + } + const imported = importedOrSentinel; + const module = this._normalizeImportedCustomizationModule(imported); + if (!module || typeof module !== 'object') { + const msg = `[customizationUrl] missing customization module "${request.raw}" (${url}): module is not an object`; + if (policy.strict) { + throw new Error(msg); + } + logger.warn(msg); + return null; + } + if (!getUrlCustomizationModulePayload(module)) { + const msg = `[customizationUrl] missing customization module "${request.raw}" (${url}): no customizations payload`; + if (policy.strict) { + throw new Error(msg); + } + logger.warn(msg); + return null; + } + + const depRefs = this._collectUrlDependencyRefs(module); + let depsChain: Promise = Promise.resolve(); + for (const depRef of depRefs) { + depsChain = depsChain.then(() => { + const depRequest = this._urlDependencyToRequest(depRef, policy); + if (!depRequest || requestedSet.has(depRequest.normalized)) { + return undefined; + } + return this._urlCustomizationLoadOne( + depRequest, + policy, + importFn, + logger, + requestedSet, + newlyLoaded + ); + }); + } + + return depsChain.then(() => { + const loaded: LoadedCustomization = { request, module, url }; + this._urlCustomizationLoaded.set(key, loaded); + newlyLoaded.push(loaded); + return loaded; + }); + }); + } + + private _applyLoadedUrlCustomizationModules(loaded: LoadedCustomization[]): void { + if (!loaded?.length) { + return; + } + for (const entry of loaded) { + const payload = getUrlCustomizationModulePayload(entry.module); + if (payload?.global && typeof payload.global === 'object') { + this.setCustomizations(payload.global, CustomizationScope.Global); + } else if (!(payload as any)?.requires) { + console.warn( + `[customizationUrl] customization module "${entry.request.raw}" (${entry.url}) has no global payload and no requires; nothing was applied` + ); + } + } + } + /** * Unified getter for customizations. * @@ -184,6 +523,14 @@ export default class CustomizationService extends PubSubService { return newTransformed; } + /** + * Returns a customization value, or the provided fallback when unset. + */ + public getValue(customizationId: string, fallbackValue?: T): T | undefined { + const value = this.getCustomization(customizationId); + return (value === undefined ? fallbackValue : (value as T)) as T | undefined; + } + /** * Takes an object with multiple properties, each property containing * immutability-helper commands, and applies them one by one. @@ -340,15 +687,16 @@ export default class CustomizationService extends PubSubService { this.transformedCustomizations.clear(); this._broadcastEvent(this.EVENTS.GLOBAL_CUSTOMIZATION_MODIFIED, { - buttons: this.defaultCustomizations, - button: this.defaultCustomizations.get(id), + buttons: this.globalCustomizations, + button: this.globalCustomizations.get(id), }); } private setDefaultCustomization(id: string, value: Customization): void { - if (this.defaultCustomizations.has(id)) { - console.warn(`Trying to update existing default for customization ${id}`); - } + // There are two inits now, without a clear between them, so we can't warn about existing defaults + // if (this.defaultCustomizations.has(id)) { + // console.warn(`Trying to update existing default for customization ${id}`); + // } this.transformedCustomizations.clear(); const sourceCustomization = this.defaultCustomizations.get(id); diff --git a/platform/core/src/services/CustomizationService/customizationUrl.ts b/platform/core/src/services/CustomizationService/customizationUrl.ts new file mode 100644 index 00000000000..4c2ca8aa3e0 --- /dev/null +++ b/platform/core/src/services/CustomizationService/customizationUrl.ts @@ -0,0 +1,38 @@ +import { + CUSTOMIZATION_URL_KEY, + customizationUrlDefaults, + DEFAULT_PREFIX, +} from './customizationUrlDefaults'; +import type { CustomizationUrlPolicy } from './customizationUrlDefaults'; +import { getUrlCustomizationModulePayload } from './getUrlCustomizationModulePayload'; +import { + parseCustomizationParams, + validateCustomizationRequests, + normalizeCustomizationValue, +} from './validate'; +import type { ValidatedCustomization, ValidationResult } from './validate'; +import { resolveCustomizationUrl } from './resolve'; + +export function getCustomizationUrlPolicy(customizationService: any): CustomizationUrlPolicy { + const policy = customizationService?.getCustomization?.(CUSTOMIZATION_URL_KEY); + return (policy as CustomizationUrlPolicy) || customizationUrlDefaults; +} + +export { + CUSTOMIZATION_URL_KEY, + customizationUrlDefaults, + DEFAULT_PREFIX, + getUrlCustomizationModulePayload, + parseCustomizationParams, + validateCustomizationRequests, + normalizeCustomizationValue, + resolveCustomizationUrl, +}; + +export type { + CustomizationUrlPolicy, + ValidatedCustomization, + ValidationResult, +}; + +export type { CustomizationModule, LoadedCustomization, LoadOptions } from './customizationUrlTypes'; diff --git a/platform/core/src/services/CustomizationService/customizationUrlDefaults.ts b/platform/core/src/services/CustomizationService/customizationUrlDefaults.ts new file mode 100644 index 00000000000..89c2da5be64 --- /dev/null +++ b/platform/core/src/services/CustomizationService/customizationUrlDefaults.ts @@ -0,0 +1,32 @@ +/** + * Default policy for the URL `customization` query parameter. + * + * Effective policy is merged from extension default/global customization + * registrations (`ohif.customizationUrl`). These values apply when nothing + * has been registered yet. + * + * Shape: + * - prefixes: map of prefix -> base URL used to resolve a `/prefix/name` + * URL value to a runtime-imported JS module path. + * - strict: when true, invalid customization query entries abort the load; + * failed imports, resolve errors, or modules with no customization payload + * also abort (throw). When false (default), invalid entries and missing + * modules are skipped with a warning. + */ +export interface CustomizationUrlPolicy { + prefixes: Record; + strict?: boolean; +} + +export const CUSTOMIZATION_URL_KEY = 'ohif.customizationUrl'; + +export const DEFAULT_PREFIX = 'default'; + +export const customizationUrlDefaults: CustomizationUrlPolicy = { + prefixes: { + [DEFAULT_PREFIX]: './customizations/', + }, + strict: false, +}; + +export default customizationUrlDefaults; diff --git a/platform/core/src/services/CustomizationService/customizationUrlTypes.ts b/platform/core/src/services/CustomizationService/customizationUrlTypes.ts new file mode 100644 index 00000000000..aa37157cab4 --- /dev/null +++ b/platform/core/src/services/CustomizationService/customizationUrlTypes.ts @@ -0,0 +1,24 @@ +import type { CustomizationUrlPolicy } from './customizationUrlDefaults'; +import type { ValidatedCustomization } from './validate'; + +export interface CustomizationModule { + customizations?: { + global?: Record; + requires?: string | string[]; + }; + global?: Record; + requires?: string | string[]; + [key: string]: any; +} + +export interface LoadedCustomization { + request: ValidatedCustomization; + module: CustomizationModule; + url: string; +} + +export interface LoadOptions { + policy?: CustomizationUrlPolicy; + importFn?: (url: string) => Promise; + logger?: { warn: (...args: any[]) => void; error: (...args: any[]) => void }; +} diff --git a/platform/core/src/services/CustomizationService/getUrlCustomizationModulePayload.ts b/platform/core/src/services/CustomizationService/getUrlCustomizationModulePayload.ts new file mode 100644 index 00000000000..fab9209f49d --- /dev/null +++ b/platform/core/src/services/CustomizationService/getUrlCustomizationModulePayload.ts @@ -0,0 +1,24 @@ +import type { CustomizationModule } from './customizationUrlTypes'; + +export function getUrlCustomizationModulePayload( + module: CustomizationModule | null | undefined +): { global?: Record; requires?: string | string[] } | null { + if (!module || typeof module !== 'object') { + return null; + } + if (module.customizations && typeof module.customizations === 'object') { + return module.customizations; + } + const m = module as CustomizationModule; + const hasGlobal = m.global && typeof m.global === 'object'; + const hasRequires = + typeof m.requires === 'string' || + (Array.isArray(m.requires) && m.requires.length > 0); + if (hasGlobal || hasRequires) { + return { + ...(hasGlobal ? { global: m.global } : {}), + ...(hasRequires ? { requires: m.requires } : {}), + }; + } + return null; +} diff --git a/platform/core/src/services/CustomizationService/resolve.test.ts b/platform/core/src/services/CustomizationService/resolve.test.ts new file mode 100644 index 00000000000..07b6356ad3d --- /dev/null +++ b/platform/core/src/services/CustomizationService/resolve.test.ts @@ -0,0 +1,64 @@ +import { resolveCustomizationUrl } from './resolve'; +import type { ValidatedCustomization } from './validate'; + +const policy = { + prefixes: { + default: './customizations/', + remote: 'https://customizations.example.com/ohifCustomizations', + relative: '/customAssets/', + }, +}; + +function req(prefix: string, name: string): ValidatedCustomization { + return { + raw: name, + normalized: `/${prefix}/${name}`, + prefix, + name, + }; +} + +describe('CustomizationService URL resolve', () => { + beforeAll(() => { + Object.defineProperty(window, 'location', { + configurable: true, + value: { + origin: 'https://viewer.example.com', + search: '', + }, + }); + }); + + it('resolves /default/ against same-origin/base', () => { + const url = resolveCustomizationUrl(req('default', 'veterinaryOverlay'), policy); + expect(url.startsWith('https://viewer.example.com')).toBe(true); + expect(url.endsWith('/customizations/veterinaryOverlay.js')).toBe(true); + }); + + it('resolves an absolute trusted prefix to its remote URL', () => { + const url = resolveCustomizationUrl(req('remote', 'veterinaryOverlay'), policy); + expect(url).toBe( + 'https://customizations.example.com/ohifCustomizations/veterinaryOverlay.js' + ); + }); + + it('resolves a relative absolute-path prefix against same origin', () => { + const url = resolveCustomizationUrl(req('relative', 'foo'), policy); + expect(url).toBe('https://viewer.example.com/customAssets/foo.js'); + }); + + it('throws on unknown prefix', () => { + expect(() => resolveCustomizationUrl(req('missing', 'x'), policy)).toThrow(); + }); + + it('throws when the name contains traversal', () => { + expect(() => + resolveCustomizationUrl(req('default', '../escape'), policy) + ).toThrow(/traversal/); + }); + + it('accepts names that already include .js suffix', () => { + const url = resolveCustomizationUrl(req('default', 'veterinary.js'), policy); + expect(url.endsWith('/customizations/veterinary.js')).toBe(true); + }); +}); diff --git a/platform/core/src/services/CustomizationService/resolve.ts b/platform/core/src/services/CustomizationService/resolve.ts new file mode 100644 index 00000000000..790997024e3 --- /dev/null +++ b/platform/core/src/services/CustomizationService/resolve.ts @@ -0,0 +1,46 @@ +import type { CustomizationUrlPolicy } from './customizationUrlDefaults'; +import type { ValidatedCustomization } from './validate'; + +const ABSOLUTE_URL_REGEX = /^([a-z][a-z0-9+.-]*:|\/\/)/i; + +function getViewerPublicUrl(): string { + if (typeof window === 'undefined') { + return '/'; + } + return (window as any).PUBLIC_URL || '/'; +} + +export function resolveCustomizationUrl( + request: ValidatedCustomization, + policy: CustomizationUrlPolicy +): string { + const prefixes = policy.prefixes || {}; + const base = prefixes[request.prefix]; + if (!base) { + throw new Error(`Unknown customization prefix: ${request.prefix}`); + } + if (request.name.includes('..')) { + throw new Error(`Customization name contains traversal: ${request.name}`); + } + + const fileName = request.name.endsWith('.js') ? request.name : `${request.name}.js`; + const baseWithSlash = base.endsWith('/') ? base : `${base}/`; + const joined = `${baseWithSlash}${fileName}`; + + if (ABSOLUTE_URL_REGEX.test(base)) { + return joined; + } + + const origin = + typeof window !== 'undefined' && window.location?.origin ? window.location.origin : ''; + const publicUrl = getViewerPublicUrl(); + const root = publicUrl?.startsWith('/') ? publicUrl : `/${publicUrl || ''}`; + const relative = joined.startsWith('./') + ? joined.slice(2) + : joined.startsWith('/') + ? joined.slice(1) + : joined; + const rootWithSlash = root.endsWith('/') ? root : `${root}/`; + const path = `${rootWithSlash}${relative}`; + return origin ? `${origin}${path}` : path; +} diff --git a/platform/core/src/services/CustomizationService/validate.test.ts b/platform/core/src/services/CustomizationService/validate.test.ts new file mode 100644 index 00000000000..93160f76f57 --- /dev/null +++ b/platform/core/src/services/CustomizationService/validate.test.ts @@ -0,0 +1,110 @@ +import { + parseCustomizationParams, + normalizeCustomizationValue, + validateCustomizationRequests, +} from './validate'; +import { customizationUrlDefaults } from './customizationUrlDefaults'; + +describe('CustomizationService URL validate', () => { + describe('parseCustomizationParams', () => { + it('returns repeated and comma-delimited values flattened', () => { + const params = new URLSearchParams(); + params.append('customization', 'a,b'); + params.append('customization', 'c'); + expect(parseCustomizationParams(params)).toEqual(['a', 'b', 'c']); + }); + + it('matches the parameter key case-insensitively', () => { + const params = new URLSearchParams(); + params.append('Customization', 'foo'); + params.append('CUSTOMIZATION', 'bar'); + expect(parseCustomizationParams(params)).toEqual(['foo', 'bar']); + }); + + it('skips empty pieces and trims whitespace', () => { + const params = new URLSearchParams(); + params.append('customization', ' a , , b '); + expect(parseCustomizationParams(params)).toEqual(['a', 'b']); + }); + }); + + describe('normalizeCustomizationValue', () => { + it('prepends /default/ for path-relative values', () => { + expect(normalizeCustomizationValue('veterinaryOverlay')).toBe( + '/default/veterinaryOverlay' + ); + }); + + it('preserves explicit /prefix/name forms', () => { + expect(normalizeCustomizationValue('/remote/foo')).toBe('/remote/foo'); + }); + + it('returns null when there is no name part', () => { + expect(normalizeCustomizationValue('/onlyPrefix')).toBeNull(); + expect(normalizeCustomizationValue('')).toBeNull(); + }); + }); + + describe('validateCustomizationRequests', () => { + const policy = { + ...customizationUrlDefaults, + prefixes: { + default: './customizations/', + remote: 'https://customizations.example.com/ohifCustomizations', + }, + }; + + it('accepts default-prefixed names', () => { + const result = validateCustomizationRequests(['veterinary'], policy); + expect(result.rejected).toEqual([]); + expect(result.valid).toHaveLength(1); + expect(result.valid[0].normalized).toBe('/default/veterinary'); + expect(result.valid[0].prefix).toBe('default'); + expect(result.valid[0].name).toBe('veterinary'); + }); + + it('accepts arbitrary logical names under a configured prefix', () => { + const result = validateCustomizationRequests(['siteTheme2026'], policy); + expect(result.rejected).toEqual([]); + expect(result.valid).toHaveLength(1); + expect(result.valid[0].name).toBe('siteTheme2026'); + }); + + it('accepts remote-prefixed names', () => { + const result = validateCustomizationRequests(['/remote/veterinaryOverlay'], policy); + expect(result.rejected).toEqual([]); + expect(result.valid[0].prefix).toBe('remote'); + expect(result.valid[0].name).toBe('veterinaryOverlay'); + }); + + it('rejects values with .. traversal', () => { + const result = validateCustomizationRequests(['../etc/passwd'], policy); + expect(result.valid).toEqual([]); + expect(result.rejected[0].reason).toMatch(/traversal/); + }); + + it('rejects full URLs', () => { + const result = validateCustomizationRequests( + ['http://evil.example.com/x', 'https://evil/x', '//evil/x'], + policy + ); + expect(result.valid).toEqual([]); + expect(result.rejected).toHaveLength(3); + for (const r of result.rejected) { + expect(r.reason).toMatch(/full URLs/); + } + }); + + it('rejects unknown prefixes', () => { + const result = validateCustomizationRequests(['/missing/x'], policy); + expect(result.valid).toEqual([]); + expect(result.rejected[0].reason).toMatch(/unknown prefix/); + }); + + it('rejects unsafe name segments', () => { + const result = validateCustomizationRequests(['/default/foo/./bar'], policy); + expect(result.valid).toEqual([]); + expect(result.rejected[0].reason).toMatch(/unsafe/); + }); + }); +}); diff --git a/platform/core/src/services/CustomizationService/validate.ts b/platform/core/src/services/CustomizationService/validate.ts new file mode 100644 index 00000000000..44db55b1b9f --- /dev/null +++ b/platform/core/src/services/CustomizationService/validate.ts @@ -0,0 +1,103 @@ +import type { CustomizationUrlPolicy } from './customizationUrlDefaults'; +import { DEFAULT_PREFIX } from './customizationUrlDefaults'; + +export interface ValidatedCustomization { + raw: string; + normalized: string; + prefix: string; + name: string; +} + +export interface ValidationResult { + valid: ValidatedCustomization[]; + rejected: { raw: string; reason: string }[]; +} + +const FULL_URL_REGEX = /^([a-z][a-z0-9+.-]*:|\/\/)/i; + +export function parseCustomizationParams( + params: URLSearchParams, + paramKey = 'customization' +): string[] { + const out: string[] = []; + const keys = Array.from(new Set(params.keys())); + for (const key of keys) { + if (key.toLowerCase() !== paramKey.toLowerCase()) { + continue; + } + for (const raw of params.getAll(key)) { + if (!raw) continue; + for (const piece of raw.split(',')) { + const trimmed = piece.trim(); + if (trimmed) { + out.push(trimmed); + } + } + } + } + return out; +} + +export function normalizeCustomizationValue(value: string): string | null { + if (!value) return null; + let v = value.trim(); + if (!v) return null; + if (!v.startsWith('/')) { + v = `/${DEFAULT_PREFIX}/${v}`; + } + const parts = v.split('/').filter(Boolean); + if (parts.length < 2) { + return null; + } + return `/${parts.join('/')}`; +} + +function hasUnsafeNameSegments(name: string): boolean { + if (!name.trim()) { + return true; + } + return name.split('/').some(seg => !seg || seg === '.' || seg === '..' || seg.includes('..')); +} + +export function validateCustomizationRequests( + raws: string[], + policy: CustomizationUrlPolicy +): ValidationResult { + const result: ValidationResult = { valid: [], rejected: [] }; + const prefixes = policy.prefixes || {}; + + for (const raw of raws) { + if (raw.includes('..')) { + result.rejected.push({ raw, reason: 'contains ".." traversal segment' }); + continue; + } + if (FULL_URL_REGEX.test(raw)) { + result.rejected.push({ raw, reason: 'full URLs are not permitted' }); + continue; + } + + const normalized = normalizeCustomizationValue(raw); + if (!normalized) { + result.rejected.push({ raw, reason: 'could not be normalized to /prefix/name form' }); + continue; + } + + const parts = normalized.split('/').filter(Boolean); + const prefix = parts[0]; + const name = parts.slice(1).join('/'); + + if (!Object.prototype.hasOwnProperty.call(prefixes, prefix)) { + result.rejected.push({ raw, reason: `unknown prefix "${prefix}"` }); + continue; + } + + if (hasUnsafeNameSegments(name)) { + result.rejected.push({ raw, reason: 'invalid or unsafe customization name' }); + continue; + } + + result.valid.push({ raw, normalized, prefix, name }); + } + + return result; +} diff --git a/platform/core/src/services/MultiMonitorService.ts b/platform/core/src/services/MultiMonitorService.ts index 810ec5ee3a9..735f384b704 100644 --- a/platform/core/src/services/MultiMonitorService.ts +++ b/platform/core/src/services/MultiMonitorService.ts @@ -191,7 +191,7 @@ export class MultiMonitorService { * Try moving the screen to the correct location - this will only work with * screens opened with openWindow containing no more than 1 tab. */ - public async onModeEnter() { + public onModeEnter() { this.setBasePath(); if ( diff --git a/platform/core/src/services/index.ts b/platform/core/src/services/index.ts index 8ddd7946121..4c8149d4be0 100644 --- a/platform/core/src/services/index.ts +++ b/platform/core/src/services/index.ts @@ -21,6 +21,8 @@ import { MultiMonitorService } from './MultiMonitorService'; import type Services from '../types/Services'; +export * from './CustomizationService/customizationUrl'; + export { Services, MeasurementService, diff --git a/platform/core/src/utils/formatValue.js b/platform/core/src/utils/formatValue.js new file mode 100644 index 00000000000..067acc69742 --- /dev/null +++ b/platform/core/src/utils/formatValue.js @@ -0,0 +1,22 @@ +/** + * Formats values for safe text display. + */ +export default function formatValue(value) { + if (value == null) { + return null; + } + + if (typeof value === 'string') { + return value; + } + + if (typeof value === 'object' && typeof value.Alphabetic === 'string') { + return value.Alphabetic; + } + + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + + return null; +} diff --git a/platform/core/src/utils/index.ts b/platform/core/src/utils/index.ts index 0ef35bc1bc2..0486151d94c 100644 --- a/platform/core/src/utils/index.ts +++ b/platform/core/src/utils/index.ts @@ -15,6 +15,7 @@ import isDicomUid from './isDicomUid'; import formatDate from './formatDate'; import formatTime from './formatTime'; import formatPN from './formatPN'; +import formatValue from './formatValue'; import generateAcceptHeader from './generateAcceptHeader'; import resolveObjectPath from './resolveObjectPath'; import hierarchicalListUtils from './hierarchicalListUtils'; @@ -72,6 +73,7 @@ const utils = { formatDate, formatTime, formatPN, + formatValue, b64toBlob, urlUtil, imageIdToURI, @@ -115,6 +117,7 @@ export { absoluteUrl, sortBy, formatDate, + formatValue, writeScript, b64toBlob, urlUtil, diff --git a/platform/docs/docs/platform/services/customization-service/customizationService.md b/platform/docs/docs/platform/services/customization-service/customizationService.md index b8284785fcc..103ce737f77 100644 --- a/platform/docs/docs/platform/services/customization-service/customizationService.md +++ b/platform/docs/docs/platform/services/customization-service/customizationService.md @@ -236,6 +236,20 @@ When a customization is retrieved: As you have guessed the `.setCustomizations` accept a second argument which is the scope. By default it is set to `mode`. +## Areas of Customization + +Use this introduction page for the core model (scope, priority, and syntax), then refer to focused pages for specific areas: + +- [Specific Customizations](./specificCustomizations.md): Built-in keys such as `ohif.preserveCustomizationKeys` and `ohif.customizationUrl`, including `?customization=` and `requires` behavior, plus how URL-loaded modules interact with bootstrap and SPA navigation (intended one-time load per page). +- [Custom Routes](./customRoutes.md): Route-level customization through `routes.customRoutes`. +- [Context Menu](./contextMenu.md): Context menu structures and interaction customization. +- [Study Browser](./StudyBrowser.md): Study browser-specific configuration values. +- [Viewport Overlay](./viewportOverlay.md): Overlay item configuration and layout. +- [Viewport Scrollbar](./ViewportScrollbar.md): Scrollbar behavior and display customizations. +- [Measurements](./Measurements.md): Measurement-related customization surface. +- [Segmentation](./Segmentation.md): Segmentation-specific customization options. +- [Advanced Customization](./advanced.md): `inheritsFrom`, `$transform`, and compositional patterns. + ## Customization Syntax diff --git a/platform/docs/docs/platform/services/customization-service/specificCustomizations.md b/platform/docs/docs/platform/services/customization-service/specificCustomizations.md new file mode 100644 index 00000000000..e554bd2b61b --- /dev/null +++ b/platform/docs/docs/platform/services/customization-service/specificCustomizations.md @@ -0,0 +1,132 @@ +--- +title: Specific Customizations +summary: Documentation for specific built-in customization keys, including URL parameter preservation and URL-driven customization module loading. +sidebar_position: 9 +--- + +# Specific Customizations + +This page documents concrete customization keys that have app-level behavior. + +## `ohif.preserveCustomizationKeys` + +- **Purpose**: Controls which query-string keys should be preserved while navigating between worklist and viewer routes. +- **Default behavior**: The app always preserves: + - `configUrl` + - `multimonitor` + - `screenNumber` + - `hangingProtocolId` + - `customization` +- **How this customization is applied**: The value from `ohif.preserveCustomizationKeys` is appended to the default list above (it does not replace the defaults). + +Example: + +```js +window.config = { + customizationService: [ + { + 'ohif.preserveCustomizationKeys': { + $set: ['customizationAlt', 'experimentFlag'], + }, + }, + ], +}; +``` + +With this example, navigation preserves the default keys plus `customizationAlt` and `experimentFlag`. + +## `ohif.customizationUrl` + +- **Purpose**: Controls how values in the `?customization=` URL parameter are resolved and loaded. +- **Main field**: `prefixes` maps logical prefixes to base URLs. +- **Default prefix**: `default` maps to `./customizations/`. +- **Not the same as preserve keys**: This setting does not decide which query keys are preserved. It only controls how customization modules are loaded from `?customization=...`. + +Example: + +```js +window.config = { + customizationService: [ + { + 'ohif.customizationUrl': { + $set: { + prefixes: { + default: './customizations/', + remote: 'https://cdn.example.com/ohif-customizations/', + }, + strict: false, + }, + }, + }, + ], +}; +``` + +### Using `?customization=` + +You can pass one or more customization entries in the URL: + +- `?customization=/default/ctAbdomen` +- `?customization=/remote/siteA` +- `?customization=/default/basePack&customization=/default/siteOverrides` + +Each entry is normalized to `/prefix/name`, resolved through `ohif.customizationUrl.prefixes`, imported, and then applied. + +#### Security considerations (`?customization=`) + +Loading customization modules from the URL is **dynamic JavaScript import** in the same browser context as the viewer. Treat it with the same seriousness as any other executable bundle you might load for your deployment. + +- **Allowlisted resolution only:** Query values must normalize to `/prefix/name`. The loader rejects values that look like full URLs (with a scheme), rejects path traversal (`..`), rejects unknown `prefix` keys, and rejects unsafe name segments. The final import URL is always built from your configured `ohif.customizationUrl.prefixes` plus a `.js` file under that base—users cannot pass an arbitrary absolute URL as the customization token alone. +- **Your prefixes define the trust boundary:** If a `prefix` maps to a host or path you do not control, or to a directory where untrusted parties can publish files, `?customization=` becomes a way to pull that code into the app. Prefer HTTPS bases, narrow directories, and static hosting of reviewed modules. +- **`strict` mode:** With `ohif.customizationUrl.strict: true`, invalid entries, resolve failures, failed imports, or modules without a customization payload cause the load to **fail** instead of being skipped with a warning. That can be appropriate when you want a hard stop rather than a partially applied configuration. +- **`requires` chains:** Dependencies declared in a loaded module are resolved with the **same** policy and validation. A trusted root module can still pull in further modules from the same prefix allowlist—review entire chains you ship. +- **Links and social engineering:** Anyone can share a URL that includes `?customization=...`. Recipients’ browsers will attempt to load the corresponding modules if they pass validation. Combine with normal defenses (user education, authenticated portals, enterprise policies) as you would for any deep link that changes application behavior. + +#### What `requires` means + +A customization module can declare dependencies via `requires` so dependent modules load first. + +Example module shape: + +```js +export default { + customizations: { + requires: ['/default/basePack', '/remote/sharedTools'], + global: { + 'someCustomizationKey': { + $set: true, + }, + }, + }, +}; +``` + +When this module is loaded via `?customization=...`, the loader: + +1. Resolves and loads each `requires` dependency first. +2. Applies dependency customizations first. +3. Applies the requested module after dependencies. + +This allows packaging layered customizations (base -> shared -> site-specific) without repeating setup in every module. + +### URL modules, bootstrap, and client-side navigation (intended behavior) + +Modules referenced from `?customization=` are loaded when the app applies URL customizations from +`window.location.search`, which happens **once at bootstrap** in the default shell (for example +from app initialization). That is intentional: + +- **No automatic refresh on SPA navigation:** Client-side routing may change the visible URL, and + keys such as `customization` are often **kept in the query string** on purpose (see + `ohif.preserveCustomizationKeys` above) so bookmarks and deep links stay consistent. That + preservation does **not** mean the viewer re-imports URL customization modules on every route + change. +- **Previously loaded modules stay applied:** The service remembers each normalized module key for + the lifetime of the page. A later call to the same loader path skips modules that were already + imported, and global payloads from those modules are not rolled back when only the query string + changes. + +If you need a different `?customization=` pack to take effect without a full reload, your +integration must trigger loading explicitly (for example by calling +`customizationService.applyCustomizationUrlSearchParams` or `customizationService.requires` with +the new list). New module keys not seen before can still be loaded that way; unloading or +replacing an already-loaded pack is not supported out of the box. diff --git a/tests/Customization.spec.ts b/tests/Customization.spec.ts new file mode 100644 index 00000000000..c9c8c4994fc --- /dev/null +++ b/tests/Customization.spec.ts @@ -0,0 +1,22 @@ +import { expect, test, visitStudyOptions } from './utils'; + +test('should apply customization from URL query parameter', async ({ page }) => { + const studyInstanceUID = '2.25.96975534054447904995905761963464388233'; + + await visitStudyOptions(page, studyInstanceUID, { + customization: 'veterinaryOverlay', + }); + + const patientNameOverlayItem = page + .locator('[data-cy="viewport-overlay-top-left"] [title="Patient name"]') + .first(); + + await expect(patientNameOverlayItem).toBeVisible({ timeout: 60_000 }); + + const patientNameOverlayText = (await patientNameOverlayItem.textContent())?.trim() ?? ''; + const patientNameValue = patientNameOverlayText.replace(/^Patient\s*/, ''); + + expect(patientNameValue.length).toBeGreaterThan(0); + expect(patientNameValue).not.toContain('[object Object]'); + expect(patientNameValue).toMatch(/horse/i); +}); diff --git a/tests/utils/index.ts b/tests/utils/index.ts index fe37bcb3243..e92791cbdeb 100644 --- a/tests/utils/index.ts +++ b/tests/utils/index.ts @@ -1,4 +1,4 @@ -import { visitStudy } from './visitStudy'; +import { visitStudy, visitStudyOptions } from './visitStudy'; import { addOHIFConfiguration, addOHIFGlobalCustomizations, @@ -32,6 +32,7 @@ import { export { visitStudy, + visitStudyOptions, addOHIFConfiguration, addOHIFGlobalCustomizations, checkForScreenshot, diff --git a/tests/utils/visitStudy.ts b/tests/utils/visitStudy.ts index 20732c65f88..38b493dc659 100644 --- a/tests/utils/visitStudy.ts +++ b/tests/utils/visitStudy.ts @@ -1,4 +1,11 @@ -import { Page } from 'playwright-test-coverage'; +import type { Page } from '@playwright/test'; + +type VisitStudyOptions = { + mode?: string; + delay?: number; + datasources?: string; + customization?: string; +}; /** * Visit the study @@ -8,18 +15,40 @@ import { Page } from 'playwright-test-coverage'; * @param delay - The delay to wait after visiting the study * @param datasources - the data source to load the study from */ -export async function visitStudy( +export async function visitStudyOptions( page: Page, studyInstanceUID: string, - mode: string, - delay: number = 0, - datasources = 'ohif' + options: VisitStudyOptions = {} ) { + const mode = options.mode || 'viewer'; + const resolvedDelay = options.delay ?? 0; + const resolvedDatasources = options.datasources || 'ohif'; + const { customization } = options; + // await page.goto(`/?resultsPerPage=100&datasources=${datasources}`); // await page.getByTestId(studyInstanceUID).click(); // await page.getByRole('button', { name: mode }).click(); - await page.goto(`/${mode}/${datasources}?StudyInstanceUIDs=${studyInstanceUID}`); + const params = new URLSearchParams({ StudyInstanceUIDs: studyInstanceUID }); + if (customization) { + params.set('customization', customization); + } + + await page.goto(`/${mode}/${resolvedDatasources}?${params.toString()}`); await page.waitForLoadState('domcontentloaded'); await page.waitForLoadState('networkidle'); - await page.waitForTimeout(delay); + await page.waitForTimeout(resolvedDelay); +} + +export async function visitStudy( + page: Page, + studyInstanceUID: string, + mode = 'viewer', + delay: number = 0, + datasources = 'ohif' +) { + return visitStudyOptions(page, studyInstanceUID, { + mode, + delay, + datasources, + }); }