diff --git a/extensions/cornerstone/src/Viewport/Overlays/CustomizableViewportOverlay.tsx b/extensions/cornerstone/src/Viewport/Overlays/CustomizableViewportOverlay.tsx index cadc7f650..f2f0ee1ff 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/modes/basic/src/toolbarButtons.ts b/extensions/cornerstone/src/customizations/toolbarButtonsCustomization.ts similarity index 88% rename from modes/basic/src/toolbarButtons.ts rename to extensions/cornerstone/src/customizations/toolbarButtonsCustomization.ts index ebddc9435..c134cd363 100644 --- a/modes/basic/src/toolbarButtons.ts +++ b/extensions/cornerstone/src/customizations/toolbarButtonsCustomization.ts @@ -1,9 +1,11 @@ import type { Button } from '@ohif/core/types'; import { EVENTS } from '@cornerstonejs/core'; -import { ViewportGridService } from '@ohif/core'; +import { ViewportGridService, ToolbarService } from '@ohif/core'; import i18n from 'i18next'; +const { TOOLBAR_SECTIONS } = ToolbarService; + const callbacks = (toolName: string) => [ { commandName: 'setViewportForToolConfiguration', @@ -708,4 +710,91 @@ const toolbarButtons: Button[] = [ // }, ]; -export default toolbarButtons; +/** + * Default toolbar layout: which buttons appear in each toolbar section. + * Registered as a customization so modes (and `?customization=` modules) can + * append/replace entries without rebuilding. + */ +export const toolbarSections = { + [TOOLBAR_SECTIONS.primary]: [ + 'MeasurementTools', + 'Zoom', + 'Pan', + 'TrackballRotate', + 'WindowLevel', + 'Capture', + 'Layout', + 'Crosshairs', + 'MoreTools', + ], + + [TOOLBAR_SECTIONS.viewportActionMenu.topLeft]: ['orientationMenu', 'dataOverlayMenu'], + + [TOOLBAR_SECTIONS.viewportActionMenu.bottomMiddle]: ['AdvancedRenderingControls'], + + AdvancedRenderingControls: [ + 'windowLevelMenuEmbedded', + 'voiManualControlMenu', + 'Colorbar', + 'opacityMenu', + 'thresholdMenu', + ], + + [TOOLBAR_SECTIONS.viewportActionMenu.topRight]: [ + 'modalityLoadBadge', + 'trackingStatus', + 'navigationComponent', + ], + + [TOOLBAR_SECTIONS.viewportActionMenu.bottomLeft]: ['windowLevelMenu'], + + MeasurementTools: [ + 'Length', + 'Bidirectional', + 'ArrowAnnotate', + 'EllipticalROI', + 'RectangleROI', + 'CircleROI', + 'PlanarFreehandROI', + 'SplineROI', + 'LivewireContour', + ], + + MoreTools: [ + 'Reset', + 'rotate-right', + 'flipHorizontal', + 'ImageSliceSync', + 'ReferenceLines', + 'ImageOverlayViewer', + 'StackScroll', + 'invert', + 'Probe', + 'Cine', + 'Angle', + 'CobbAngle', + 'Magnify', + 'CalibrationLine', + 'TagBrowser', + 'AdvancedMagnify', + 'UltrasoundDirectionalTool', + 'WindowLevelRegion', + 'SegmentLabelTool', + ], +}; + +/** + * Customizations registered (at default scope) by the cornerstone extension: + * - `cornerstone.toolbarButtons` – the default toolbar button definitions + * - `cornerstone.toolbarSections` – the default toolbar layout (section -> button ids) + * + * Modes read these by name in `onModeEnter`; URL `?customization=` modules can + * extend them with immutability-helper commands (e.g. `$push` a new button). + */ +const toolbarButtonsCustomization = { + 'cornerstone.toolbarButtons': toolbarButtons, + 'cornerstone.toolbarSections': toolbarSections, +}; + +export { toolbarButtons }; +export default toolbarButtonsCustomization; diff --git a/extensions/cornerstone/src/getCustomizationModule.tsx b/extensions/cornerstone/src/getCustomizationModule.tsx index 076d4b89e..206afc5ab 100644 --- a/extensions/cornerstone/src/getCustomizationModule.tsx +++ b/extensions/cornerstone/src/getCustomizationModule.tsx @@ -8,6 +8,7 @@ import volumeRenderingCustomization from './customizations/volumeRenderingCustom import colorbarCustomization from './customizations/colorbarCustomization'; import modalityColorMapCustomization from './customizations/modalityColorMapCustomization'; import windowLevelPresetsCustomization from './customizations/windowLevelPresetsCustomization'; +import toolbarButtonsCustomization from './customizations/toolbarButtonsCustomization'; import miscCustomization from './customizations/miscCustomization'; import captureViewportModalCustomization from './customizations/captureViewportModalCustomization'; import viewportDownloadWarningCustomization from './customizations/viewportDownloadWarningCustomization'; @@ -32,6 +33,7 @@ function getCustomizationModule({ commandsManager, servicesManager, extensionMan ...colorbarCustomization, ...modalityColorMapCustomization, ...windowLevelPresetsCustomization, + ...toolbarButtonsCustomization, ...miscCustomization, ...captureViewportModalCustomization, ...viewportDownloadWarningCustomization, diff --git a/extensions/cornerstone/src/initCornerstoneTools.js b/extensions/cornerstone/src/initCornerstoneTools.js index 27bbca70c..be4c2d102 100644 --- a/extensions/cornerstone/src/initCornerstoneTools.js +++ b/extensions/cornerstone/src/initCornerstoneTools.js @@ -3,6 +3,7 @@ import { WindowLevelTool, SegmentBidirectionalTool, StackScrollTool, + PlanarRotateTool, VolumeRotateTool, ZoomTool, MIPJumpToClickTool, @@ -74,6 +75,7 @@ export default function initCornerstoneTools(configuration = {}) { addTool(SegmentBidirectionalTool); addTool(WindowLevelTool); addTool(StackScrollTool); + addTool(PlanarRotateTool); addTool(VolumeRotateTool); addTool(ZoomTool); addTool(ProbeTool); @@ -138,6 +140,7 @@ const toolNames = { WindowLevel: WindowLevelTool.toolName, StackScroll: StackScrollTool.toolName, Zoom: ZoomTool.toolName, + PlanarRotate: PlanarRotateTool.toolName, VolumeRotate: VolumeRotateTool.toolName, MipJumpToClick: MIPJumpToClickTool.toolName, Length: LengthTool.toolName, diff --git a/extensions/default/src/DicomWebDataSource/qido.js b/extensions/default/src/DicomWebDataSource/qido.js index 2a5d84b61..38d3773b8 100644 --- a/extensions/default/src/DicomWebDataSource/qido.js +++ b/extensions/default/src/DicomWebDataSource/qido.js @@ -52,6 +52,7 @@ function processResults(qidoStudies) { accession: getString(qidoStudy['00080050']) || '', // short string, probably a number? mrn: getString(qidoStudy['00100020']) || '', // medicalRecordNumber patientName: utils.formatPN(getName(qidoStudy['00100010'])) || '', + patientBirthDate: getString(qidoStudy['00100030']) || '', // YYYYMMDD instances: Number(getString(qidoStudy['00201208'])) || 0, // number description: getString(qidoStudy['00081030']) || '', modalities: getString(getModalities(qidoStudy['00080060'], qidoStudy['00080061'])) || '', @@ -153,6 +154,7 @@ function mapParams(params, options = {}) { '00081030', // Study Description '00080060', // Modality '00080090', // Referring Physician's Name + '00100030', // Patient's Birth Date // Add more fields here if you want them in the result ].join(','); diff --git a/extensions/default/src/ViewerLayout/ViewerHeader.tsx b/extensions/default/src/ViewerLayout/ViewerHeader.tsx index 9f6425776..da9f98936 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 6e72827e9..5d52e8e5c 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/modes/basic/src/index.tsx b/modes/basic/src/index.tsx index 2b8f3c9d4..223d1d1a0 100644 --- a/modes/basic/src/index.tsx +++ b/modes/basic/src/index.tsx @@ -1,11 +1,9 @@ import update from 'immutability-helper'; -import { ToolbarService, utils } from '@ohif/core'; +import { utils } from '@ohif/core'; import initToolGroups from './initToolGroups'; -import toolbarButtons from './toolbarButtons'; import { id } from './id'; -const { TOOLBAR_SECTIONS } = ToolbarService; const { structuredCloneWithFunctions } = utils; /** @@ -145,9 +143,22 @@ export function onModeEnter({ // Init Default and SR ToolGroups initToolGroups(extensionManager, toolGroupService, commandsManager); - toolbarService.register(this.toolbarButtons); + // Toolbar buttons and layout may be supplied either as a customization name + // (a string, resolved through the customization service so `?customization=` + // modules can extend the cornerstone-registered defaults) or as a literal + // value (the button array / sections object) for modes that define them inline. + const resolveToolbarCustomization = (value: unknown) => + typeof value === 'string' ? customizationService.getCustomization(value) : value; - for (const [key, section] of Object.entries(this.toolbarSections)) { + const toolbarButtons = resolveToolbarCustomization(this.toolbarButtons) as any; + const toolbarSections = (resolveToolbarCustomization(this.toolbarSections) ?? {}) as Record< + string, + string[] + >; + + toolbarService.register(toolbarButtons); + + for (const [key, section] of Object.entries(toolbarSections)) { toolbarService.updateSection(key, section); } @@ -212,74 +223,6 @@ export function onModeExit({ servicesManager }: withAppTypes) { cornerstoneViewportService.destroy(); } -export const toolbarSections = { - [TOOLBAR_SECTIONS.primary]: [ - 'MeasurementTools', - 'Zoom', - 'Pan', - 'TrackballRotate', - 'WindowLevel', - 'Capture', - 'Layout', - 'Crosshairs', - 'MoreTools', - ], - - [TOOLBAR_SECTIONS.viewportActionMenu.topLeft]: ['orientationMenu', 'dataOverlayMenu'], - - [TOOLBAR_SECTIONS.viewportActionMenu.bottomMiddle]: ['AdvancedRenderingControls'], - - AdvancedRenderingControls: [ - 'windowLevelMenuEmbedded', - 'voiManualControlMenu', - 'Colorbar', - 'opacityMenu', - 'thresholdMenu', - ], - - [TOOLBAR_SECTIONS.viewportActionMenu.topRight]: [ - 'modalityLoadBadge', - 'trackingStatus', - 'navigationComponent', - ], - - [TOOLBAR_SECTIONS.viewportActionMenu.bottomLeft]: ['windowLevelMenu'], - - MeasurementTools: [ - 'Length', - 'Bidirectional', - 'ArrowAnnotate', - 'EllipticalROI', - 'RectangleROI', - 'CircleROI', - 'PlanarFreehandROI', - 'SplineROI', - 'LivewireContour', - ], - - MoreTools: [ - 'Reset', - 'rotate-right', - 'flipHorizontal', - 'ImageSliceSync', - 'ReferenceLines', - 'ImageOverlayViewer', - 'StackScroll', - 'invert', - 'Probe', - 'Cine', - 'Angle', - 'CobbAngle', - 'Magnify', - 'CalibrationLine', - 'TagBrowser', - 'AdvancedMagnify', - 'UltrasoundDirectionalTool', - 'WindowLevelRegion', - 'SegmentLabelTool', - ], -}; - export const basicLayout = { id: ohif.layout, props: { @@ -342,7 +285,10 @@ export const modeInstance = { hide: false, displayName: 'Non-Longitudinal Basic', _activatePanelTriggersSubscriptions: [], - toolbarSections, + // Toolbar buttons and layout are referenced by customization name; the + // cornerstone extension registers the defaults and `?customization=` modules + // can extend them. onModeEnter resolves these names via the customization service. + toolbarSections: 'cornerstone.toolbarSections', /** * Lifecycle hooks @@ -364,7 +310,7 @@ export const modeInstance = { // general handler needs to come last. For this case, the dicomvideo must // come first to remove video transfer syntax before ohif uses images sopClassHandlers, - toolbarButtons, + toolbarButtons: 'cornerstone.toolbarButtons', enableSegmentationEdit: false, nonModeModalities: NON_IMAGE_MODALITIES, }; @@ -389,4 +335,4 @@ export const mode = { }; export default mode; -export { initToolGroups, toolbarButtons }; +export { initToolGroups }; diff --git a/modes/basic/src/initToolGroups.ts b/modes/basic/src/initToolGroups.ts index d5460eed0..7032bad6a 100644 --- a/modes/basic/src/initToolGroups.ts +++ b/modes/basic/src/initToolGroups.ts @@ -69,6 +69,7 @@ function initDefaultToolGroup(extensionManager, toolGroupService, commandsManage { toolName: toolNames.Angle }, { toolName: toolNames.CobbAngle }, { toolName: toolNames.Magnify }, + { toolName: toolNames.PlanarRotate }, { toolName: toolNames.CalibrationLine }, { toolName: toolNames.PlanarFreehandContourSegmentation, diff --git a/modes/longitudinal/src/index.ts b/modes/longitudinal/src/index.ts index cb8b9a18a..792ac3a01 100644 --- a/modes/longitudinal/src/index.ts +++ b/modes/longitudinal/src/index.ts @@ -1,6 +1,6 @@ import i18n from 'i18next'; import { id } from './id'; -import { initToolGroups, toolbarButtons, cornerstone, +import { initToolGroups, cornerstone, ohif, dicomsr, dicomvideo, @@ -73,4 +73,4 @@ const mode = { }; export default mode; -export { initToolGroups, toolbarButtons }; +export { initToolGroups }; diff --git a/platform/app/.webpack/webpack.pwa.js b/platform/app/.webpack/webpack.pwa.js index ad1455769..82dc4e983 100644 --- a/platform/app/.webpack/webpack.pwa.js +++ b/platform/app/.webpack/webpack.pwa.js @@ -15,7 +15,6 @@ const WATCH_IGNORED = /node_modules[\\/](?!@cornerstonejs(?:[\\/]|$))/; // ~~ Env Vars const HTML_TEMPLATE = process.env.HTML_TEMPLATE || 'index.html'; const PUBLIC_URL = process.env.PUBLIC_URL || '/'; -const APP_CONFIG = process.env.APP_CONFIG || 'config/default.js'; // proxy settings const PROXY_TARGET = process.env.PROXY_TARGET; @@ -103,6 +102,12 @@ const setHeaders = (res, path) => { module.exports = (env, argv) => { const baseConfig = webpackBase(env, argv, { SRC_DIR, DIST_DIR }); const isProdBuild = process.env.NODE_ENV === 'production'; + // Honor an explicit APP_CONFIG; otherwise the dev server gets the + // full-featured `config/dev.js` and a production build the locked-down + // `config/default.js`. This lets `APP_CONFIG=config/foo.js pnpm run dev` + // override the default instead of being clobbered by the script. + const APP_CONFIG = + process.env.APP_CONFIG || (isProdBuild ? 'config/default.js' : 'config/dev.js'); const hasProxy = PROXY_TARGET && PROXY_DOMAIN; const mergedConfig = merge(baseConfig, { diff --git a/platform/app/public/config/customization.js b/platform/app/public/config/customization.js index b1467b5cd..d05491e81 100644 --- a/platform/app/public/config/customization.js +++ b/platform/app/public/config/customization.js @@ -11,11 +11,44 @@ window.config = { strictZSpacingForVolumeViewport: true, // filterQueryParam: false, - // Add some customizations to the default e2e datasource - customizationService: [ - '@ohif/extension-default.customizationModule.datasources', - '@ohif/extension-default.customizationModule.helloPage', - ], + // Allowlist for the `?customization=` URL parameter and for + // `customizationService.requires` below. The `default` prefix (no slashes) + // resolves a value to `/customizations/.jsonc`. Files are + // fetched as DATA (JSON with comments) and never executed. + customizationUrlPrefixes: { + default: './customizations/', + }, + + // Phase-tagged startup customizations. Each block is applied at a fixed point + // in the lifecycle so ordering is deterministic regardless of when extensions + // and modes load: + // - requires: URL customization data files to resolve up front. + // - bootstrap: applied (Global) BEFORE extensions register. + // - global: applied (Global) AFTER extensions register. + // - mode: applied (Mode) on each mode enter — `*` (general) first, + // then a block keyed by the mode id / routeName. + customizationService: { + // Pulls in platform/app/public/customizations/patientBirthDate.jsonc, which + // adds a "Birth Date" column to the WorkList study list (global phase). + requires: ['patientBirthDate'], + + // The previous (legacy-array) customizations, now in the explicit `global` + // phase. A `global` block accepts the same input as setCustomizations: an + // array mixing string references and inline object maps. + global: [ + '@ohif/extension-default.customizationModule.datasources', + '@ohif/extension-default.customizationModule.helloPage', + ], + + // Example of mode-scoped customizations (cleared/reapplied per mode): + // the `*` block applies to every mode first; a mode-named block (matched + // against the mode id or routeName, e.g. 'viewer') applies after it. + // + // mode: { + // '*': { 'someCustomizationId': { $set: 'applies to all modes' } }, + // viewer: { 'someCustomizationId': { $set: 'overrides for the viewer mode' } }, + // }, + }, defaultDataSourceName: 'e2e', investigationalUseDialog: { diff --git a/platform/app/public/config/default.js b/platform/app/public/config/default.js index afca474b1..990de7324 100644 --- a/platform/app/public/config/default.js +++ b/platform/app/public/config/default.js @@ -1,5 +1,21 @@ /** @type {AppTypes.Config} */ +// Secure, minimal default configuration. +// +// This is what a plain production build with no APP_CONFIG produces, so it is +// deliberately locked down: +// - The local file data source (`dicomlocal`) and the runtime `?url=` sources +// (`dicomjson`, `dicomwebproxy`) are NOT enabled — they widen the attack +// surface of a default deployment. +// - `?customization=` URL loading is OFF: no `customizationUrlPrefixes` are +// configured, so any `?customization=` value is rejected (and aborts boot +// rather than silently loading). +// - `dangerouslyUseDynamicConfig` (the `configUrl` query parameter) is off. +// +// It does not need to "just work" untouched — point the data source below at +// your own DICOMweb server. For a fully-featured setup with every data source +// and customization loading enabled, see config/dev.js (local development) and +// config/netlify.js (the public demo deploy). window.config = { name: 'config/default.js', routerBasename: null, @@ -7,6 +23,21 @@ window.config = { extensions: [], modes: [], customizationService: {}, + + // --- URL-driven customizations (?customization=) ---------------------------- + // OFF by default. To allow loading customization data files from the URL, set + // `customizationUrlPrefixes` to a map of allowed prefixes. The `default` prefix + // (no slashes) is used for values with no leading slash; every other prefix + // must start AND end with a slash and is matched against the leading + // `/segment/` of the value. Files are fetched and parsed as JSONC data — they + // are never executed. Example (left disabled here on purpose): + // + // customizationUrlPrefixes: { + // default: './customizations/', // ?customization=ctPresets + // '/remote/': 'https://cdn.example.com/ohif-custom/', // ?customization=/remote/siteA + // }, + // ---------------------------------------------------------------------------- + showStudyList: true, // some windows systems have issues with more than 3 web workers maxNumberOfWebWorkers: 3, @@ -26,81 +57,13 @@ window.config = { prefetch: 25, }, showErrorDetails: 'always', // 'always', 'dev', 'production' - // filterQueryParam: false, - // Defines multi-monitor layouts - multimonitor: [ - { - id: 'split', - test: ({ multimonitor }) => multimonitor === 'split', - screens: [ - { - id: 'ohif0', - screen: null, - location: { - screen: 0, - width: 0.5, - height: 1, - left: 0, - top: 0, - }, - options: 'location=no,menubar=no,scrollbars=no,status=no,titlebar=no', - }, - { - id: 'ohif1', - screen: null, - location: { - width: 0.5, - height: 1, - left: 0.5, - top: 0, - }, - options: 'location=no,menubar=no,scrollbars=no,status=no,titlebar=no', - }, - ], - }, - - { - id: '2', - test: ({ multimonitor }) => multimonitor === '2', - screens: [ - { - id: 'ohif0', - screen: 0, - location: { - width: 1, - height: 1, - left: 0, - top: 0, - }, - options: 'fullscreen=yes,location=no,menubar=no,scrollbars=no,status=no,titlebar=no', - }, - { - id: 'ohif1', - screen: 1, - location: { - width: 1, - height: 1, - left: 0, - top: 0, - }, - options: 'fullscreen=yes,location=no,menubar=no,scrollbars=no,status=no,titlebar=no', - }, - ], - }, - ], + // `dangerouslyUseDynamicConfig` (load configuration from a `configUrl` query + // parameter) is intentionally left OFF in the secure default build. See + // config/dev.js for the documented shape. defaultDataSourceName: 'ohif', - /* Dynamic config allows user to pass "configUrl" query string this allows to load config without recompiling application. The regex will ensure valid configuration source */ - // dangerouslyUseDynamicConfig: { - // enabled: true, - // // regex will ensure valid configuration source and default is /.*/ which matches any character. To use this, setup your own regex to choose a specific source of configuration only. - // // Example 1, to allow numbers and letters in an absolute or sub-path only. - // // regex: /(0-9A-Za-z.]+)(\/[0-9A-Za-z.]+)*/ - // // Example 2, to restricts to either hosptial.com or othersite.com. - // // regex: /(https:\/\/hospital.com(\/[0-9A-Za-z.]+)*)|(https:\/\/othersite.com(\/[0-9A-Za-z.]+)*)/ - // regex: /.*/, - // }, dataSources: [ { + // Read-only public demo server. Replace with your own DICOMweb server. namespace: '@ohif/extension-default.dataSourcesModule.dicomweb', sourceName: 'ohif', configuration: { @@ -118,9 +81,6 @@ window.config = { supportsWildcard: true, staticWado: true, singlepart: 'bulkdata,video', - // whether the data source should use retrieveBulkData to grab metadata, - // and in case of relative path, what would it be relative to, options - // are in the series level or study level (some servers like series some study) bulkDataURI: { enabled: true, relativeResolution: 'studies', @@ -130,157 +90,13 @@ window.config = { }, }, - { - namespace: '@ohif/extension-default.dataSourcesModule.dicomweb', - sourceName: 'ohif2', - configuration: { - friendlyName: 'AWS S3 Static wado secondary server', - name: 'aws', - wadoUriRoot: 'https://dd14fa38qiwhyfd.cloudfront.net/dicomweb', - qidoRoot: 'https://dd14fa38qiwhyfd.cloudfront.net/dicomweb', - wadoRoot: 'https://dd14fa38qiwhyfd.cloudfront.net/dicomweb', - qidoSupportsIncludeField: false, - supportsReject: false, - imageRendering: 'wadors', - thumbnailRendering: 'wadors', - enableStudyLazyLoad: true, - supportsFuzzyMatching: false, - supportsWildcard: true, - staticWado: true, - singlepart: 'bulkdata,video', - // whether the data source should use retrieveBulkData to grab metadata, - // and in case of relative path, what would it be relative to, options - // are in the series level or study level (some servers like series some study) - bulkDataURI: { - enabled: true, - relativeResolution: 'studies', - }, - omitQuotationForMultipartRequest: true, - }, - }, - { - namespace: '@ohif/extension-default.dataSourcesModule.dicomweb', - sourceName: 'ohif3', - configuration: { - friendlyName: 'AWS S3 Static wado secondary server', - name: 'aws', - wadoUriRoot: 'https://d3t6nz73ql33tx.cloudfront.net/dicomweb', - qidoRoot: 'https://d3t6nz73ql33tx.cloudfront.net/dicomweb', - wadoRoot: 'https://d3t6nz73ql33tx.cloudfront.net/dicomweb', - qidoSupportsIncludeField: false, - supportsReject: false, - imageRendering: 'wadors', - thumbnailRendering: 'wadors', - enableStudyLazyLoad: true, - supportsFuzzyMatching: false, - supportsWildcard: true, - staticWado: true, - singlepart: 'bulkdata,video', - // whether the data source should use retrieveBulkData to grab metadata, - // and in case of relative path, what would it be relative to, options - // are in the series level or study level (some servers like series some study) - bulkDataURI: { - enabled: true, - relativeResolution: 'studies', - }, - omitQuotationForMultipartRequest: true, - }, - }, - - { - namespace: '@ohif/extension-default.dataSourcesModule.dicomweb', - sourceName: 'local5000', - configuration: { - friendlyName: 'Static WADO Local Data', - name: 'DCM4CHEE', - qidoRoot: 'http://localhost:5000/dicomweb', - wadoRoot: 'http://localhost:5000/dicomweb', - qidoSupportsIncludeField: false, - supportsReject: true, - supportsStow: true, - imageRendering: 'wadors', - thumbnailRendering: 'wadors', - enableStudyLazyLoad: true, - supportsFuzzyMatching: false, - supportsWildcard: true, - staticWado: true, - singlepart: 'video', - bulkDataURI: { - enabled: true, - relativeResolution: 'studies', - }, - }, - }, - { - namespace: '@ohif/extension-default.dataSourcesModule.dicomweb', - sourceName: 'orthanc', - configuration: { - friendlyName: 'local Orthanc DICOMWeb Server', - name: 'DCM4CHEE', - wadoUriRoot: 'http://localhost/pacs/dicom-web', - qidoRoot: 'http://localhost/pacs/dicom-web', - wadoRoot: 'http://localhost/pacs/dicom-web', - qidoSupportsIncludeField: true, - supportsReject: true, - dicomUploadEnabled: true, - imageRendering: 'wadors', - thumbnailRendering: 'wadors', - enableStudyLazyLoad: true, - supportsFuzzyMatching: true, - supportsWildcard: true, - omitQuotationForMultipartRequest: true, - bulkDataURI: { - enabled: true, - // This is an example config that can be used to fix the retrieve URL - // where it has the wrong prefix (eg a canned prefix). It is better to - // just use the correct prefix out of the box, but that is sometimes hard - // when URLs go through several systems. - // Example URLS are: - // "BulkDataURI" : "http://localhost/dicom-web/studies/1.2.276.0.7230010.3.1.2.2344313775.14992.1458058363.6979/series/1.2.276.0.7230010.3.1.3.1901948703.36080.1484835349.617/instances/1.2.276.0.7230010.3.1.4.1901948703.36080.1484835349.618/bulk/00420011", - // when running on http://localhost:3003 with no server running on localhost. This can be corrected to: - // /orthanc/dicom-web/studies/1.2.276.0.7230010.3.1.2.2344313775.14992.1458058363.6979/series/1.2.276.0.7230010.3.1.3.1901948703.36080.1484835349.617/instances/1.2.276.0.7230010.3.1.4.1901948703.36080.1484835349.618/bulk/00420011 - // which is a valid relative URL, and will result in using the http://localhost:3003/orthanc/.... path - // startsWith: 'http://localhost/', - // prefixWith: '/orthanc/', - }, - }, - }, - - { - namespace: '@ohif/extension-default.dataSourcesModule.dicomwebproxy', - sourceName: 'dicomwebproxy', - configuration: { - friendlyName: 'dicomweb delegating proxy', - name: 'dicomwebproxy', - // Security controls for runtime ?url=... datasource loading: - // In authenticated environments, runtime ?url origins must be allowlisted: - // dangerouslyAllowedOriginsForAuthenticatedEnvironments: [ - // 'https://config.example.com', - // 'http://localhost:5000', - // ], - }, - }, - { - namespace: '@ohif/extension-default.dataSourcesModule.dicomjson', - sourceName: 'dicomjson', - configuration: { - friendlyName: 'dicom json', - name: 'json', - // Security controls for runtime ?url=... datasource loading: - // In authenticated environments, runtime ?url origins must be allowlisted: - // dangerouslyAllowedOriginsForAuthenticatedEnvironments: [ - // 'https://config.example.com', - // 'http://localhost:5000', - // ], - }, - }, - { - namespace: '@ohif/extension-default.dataSourcesModule.dicomlocal', - sourceName: 'dicomlocal', - configuration: { - friendlyName: 'dicom local', - }, - }, + // The following data sources are intentionally NOT enabled in the secure + // default because they broaden the attack surface of a default deployment. + // Enable them only in a deployment you control (see config/dev.js): + // - dicomlocal: loads DICOM files from the user's machine. + // - dicomjson: loads metadata from an arbitrary `?url=` (gate with + // `dangerouslyAllowedOriginsForAuthenticatedEnvironments`). + // - dicomwebproxy: delegating proxy driven by `?url=`. ], httpErrorHandler: error => { // This is 429 when rejected from the public idc sandbox too often. @@ -289,29 +105,4 @@ window.config = { // Could use services manager here to bring up a dialog/modal if needed. console.warn('test, navigate to https://ohif.org/'); }, - // segmentation: { - // segmentLabel: { - // enabledByDefault: true, - // labelColor: [255, 255, 0, 1], // must be an array - // hoverTimeout: 1, - // background: 'rgba(100, 100, 100, 0.5)', // can be any valid css color - // }, - // }, - // whiteLabeling: { - // createLogoComponentFn: function (React) { - // return React.createElement( - // 'a', - // { - // target: '_self', - // rel: 'noopener noreferrer', - // className: 'text-purple-600 line-through', - // href: '_X___IDC__LOGO__LINK___Y_', - // }, - // React.createElement('img', { - // src: './Logo.svg', - // className: 'w-14 h-14', - // }) - // ); - // }, - // }, }; diff --git a/platform/app/public/config/dev.js b/platform/app/public/config/dev.js new file mode 100644 index 000000000..49987d825 --- /dev/null +++ b/platform/app/public/config/dev.js @@ -0,0 +1,272 @@ +/** @type {AppTypes.Config} */ + +// Local development configuration. +// +// This is the default config for the dev server (`pnpm run dev`, `dev:fast`, +// `start`). Like config/netlify.js it is full-featured — every data source is +// enabled and the `?customization=` URL feature is ON via +// `customizationUrlPrefixes` — so the whole app can be exercised locally. The +// locked-down config/default.js is what a plain production build emits instead. +window.config = { + name: 'config/dev.js', + routerBasename: null, + // whiteLabeling: {}, + extensions: [], + modes: [], + customizationService: {}, + + // URL-driven customizations (?customization=). The `default` prefix (no + // slashes) is used for values without a leading slash; every other prefix + // must start AND end with a slash and matches the leading `/segment/` of the + // value. Files are fetched and parsed as JSONC data — never executed. + // e.g. ?customization=ctPresets -> ./customizations/ctPresets.jsonc + customizationUrlPrefixes: { + default: './customizations/', + }, + + showStudyList: true, + // some windows systems have issues with more than 3 web workers + maxNumberOfWebWorkers: 3, + // below flag is for performance reasons, but it might not work for all servers + showWarningMessageForCrossOrigin: true, + showCPUFallbackMessage: true, + showLoadingIndicator: true, + experimentalStudyBrowserSort: false, + strictZSpacingForVolumeViewport: true, + groupEnabledModesFirst: true, + allowMultiSelectExport: false, + maxNumRequests: { + interaction: 100, + thumbnail: 5, + // Prefetch number is dependent on the http protocol. For http 2 or + // above, the number of requests can be go a lot higher. + prefetch: 25, + }, + showErrorDetails: 'always', // 'always', 'dev', 'production' + // filterQueryParam: false, + // Defines multi-monitor layouts + multimonitor: [ + { + id: 'split', + test: ({ multimonitor }) => multimonitor === 'split', + screens: [ + { + id: 'ohif0', + screen: null, + location: { + screen: 0, + width: 0.5, + height: 1, + left: 0, + top: 0, + }, + options: 'location=no,menubar=no,scrollbars=no,status=no,titlebar=no', + }, + { + id: 'ohif1', + screen: null, + location: { + width: 0.5, + height: 1, + left: 0.5, + top: 0, + }, + options: 'location=no,menubar=no,scrollbars=no,status=no,titlebar=no', + }, + ], + }, + + { + id: '2', + test: ({ multimonitor }) => multimonitor === '2', + screens: [ + { + id: 'ohif0', + screen: 0, + location: { + width: 1, + height: 1, + left: 0, + top: 0, + }, + options: 'fullscreen=yes,location=no,menubar=no,scrollbars=no,status=no,titlebar=no', + }, + { + id: 'ohif1', + screen: 1, + location: { + width: 1, + height: 1, + left: 0, + top: 0, + }, + options: 'fullscreen=yes,location=no,menubar=no,scrollbars=no,status=no,titlebar=no', + }, + ], + }, + ], + defaultDataSourceName: 'ohif', + /* Dynamic config allows user to pass "configUrl" query string this allows to load config without recompiling application. The regex will ensure valid configuration source */ + // dangerouslyUseDynamicConfig: { + // enabled: true, + // regex: /.*/, + // }, + dataSources: [ + { + namespace: '@ohif/extension-default.dataSourcesModule.dicomweb', + sourceName: 'ohif', + configuration: { + friendlyName: 'AWS S3 Static wado server', + name: 'aws', + wadoUriRoot: 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb', + qidoRoot: 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb', + wadoRoot: 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb', + qidoSupportsIncludeField: false, + imageRendering: 'wadors', + thumbnailRendering: 'thumbnail', + thumbnailRequestStrategy: 'fetch', + enableStudyLazyLoad: true, + supportsFuzzyMatching: true, + supportsWildcard: true, + staticWado: true, + singlepart: 'bulkdata,video', + bulkDataURI: { + enabled: true, + relativeResolution: 'studies', + transform: url => url.replace('/pixeldata.mp4', '/rendered'), + }, + omitQuotationForMultipartRequest: true, + }, + }, + + { + namespace: '@ohif/extension-default.dataSourcesModule.dicomweb', + sourceName: 'ohif2', + configuration: { + friendlyName: 'AWS S3 Static wado secondary server', + name: 'aws', + wadoUriRoot: 'https://dd14fa38qiwhyfd.cloudfront.net/dicomweb', + qidoRoot: 'https://dd14fa38qiwhyfd.cloudfront.net/dicomweb', + wadoRoot: 'https://dd14fa38qiwhyfd.cloudfront.net/dicomweb', + qidoSupportsIncludeField: false, + supportsReject: false, + imageRendering: 'wadors', + thumbnailRendering: 'wadors', + enableStudyLazyLoad: true, + supportsFuzzyMatching: false, + supportsWildcard: true, + staticWado: true, + singlepart: 'bulkdata,video', + bulkDataURI: { + enabled: true, + relativeResolution: 'studies', + }, + omitQuotationForMultipartRequest: true, + }, + }, + { + namespace: '@ohif/extension-default.dataSourcesModule.dicomweb', + sourceName: 'ohif3', + configuration: { + friendlyName: 'AWS S3 Static wado secondary server', + name: 'aws', + wadoUriRoot: 'https://d3t6nz73ql33tx.cloudfront.net/dicomweb', + qidoRoot: 'https://d3t6nz73ql33tx.cloudfront.net/dicomweb', + wadoRoot: 'https://d3t6nz73ql33tx.cloudfront.net/dicomweb', + qidoSupportsIncludeField: false, + supportsReject: false, + imageRendering: 'wadors', + thumbnailRendering: 'wadors', + enableStudyLazyLoad: true, + supportsFuzzyMatching: false, + supportsWildcard: true, + staticWado: true, + singlepart: 'bulkdata,video', + bulkDataURI: { + enabled: true, + relativeResolution: 'studies', + }, + omitQuotationForMultipartRequest: true, + }, + }, + + { + namespace: '@ohif/extension-default.dataSourcesModule.dicomweb', + sourceName: 'local5000', + configuration: { + friendlyName: 'Static WADO Local Data', + name: 'DCM4CHEE', + qidoRoot: 'http://localhost:5000/dicomweb', + wadoRoot: 'http://localhost:5000/dicomweb', + qidoSupportsIncludeField: false, + supportsReject: true, + supportsStow: true, + imageRendering: 'wadors', + thumbnailRendering: 'wadors', + enableStudyLazyLoad: true, + supportsFuzzyMatching: false, + supportsWildcard: true, + staticWado: true, + singlepart: 'video', + bulkDataURI: { + enabled: true, + relativeResolution: 'studies', + }, + }, + }, + { + namespace: '@ohif/extension-default.dataSourcesModule.dicomweb', + sourceName: 'orthanc', + configuration: { + friendlyName: 'local Orthanc DICOMWeb Server', + name: 'DCM4CHEE', + wadoUriRoot: 'http://localhost/pacs/dicom-web', + qidoRoot: 'http://localhost/pacs/dicom-web', + wadoRoot: 'http://localhost/pacs/dicom-web', + qidoSupportsIncludeField: true, + supportsReject: true, + dicomUploadEnabled: true, + imageRendering: 'wadors', + thumbnailRendering: 'wadors', + enableStudyLazyLoad: true, + supportsFuzzyMatching: true, + supportsWildcard: true, + omitQuotationForMultipartRequest: true, + bulkDataURI: { + enabled: true, + }, + }, + }, + + { + namespace: '@ohif/extension-default.dataSourcesModule.dicomwebproxy', + sourceName: 'dicomwebproxy', + configuration: { + friendlyName: 'dicomweb delegating proxy', + name: 'dicomwebproxy', + }, + }, + { + namespace: '@ohif/extension-default.dataSourcesModule.dicomjson', + sourceName: 'dicomjson', + configuration: { + friendlyName: 'dicom json', + name: 'json', + }, + }, + { + namespace: '@ohif/extension-default.dataSourcesModule.dicomlocal', + sourceName: 'dicomlocal', + configuration: { + friendlyName: 'dicom local', + }, + }, + ], + httpErrorHandler: error => { + // This is 429 when rejected from the public idc sandbox too often. + console.warn(error.status); + + // Could use services manager here to bring up a dialog/modal if needed. + console.warn('test, navigate to https://ohif.org/'); + }, +}; diff --git a/platform/app/public/config/e2e.js b/platform/app/public/config/e2e.js index 3e5d589ce..9093acf48 100644 --- a/platform/app/public/config/e2e.js +++ b/platform/app/public/config/e2e.js @@ -20,6 +20,11 @@ window.config = { routerBasename: null, extensions: [], modes: ['@ohif/mode-test'], + // Allow the `default` prefix so e2e specs can exercise `?customization=` (e.g. + // Customization.spec.ts loads `?customization=veterinaryOverlay`). + customizationUrlPrefixes: { + default: './customizations/', + }, showStudyList: true, // below flag is for performance reasons, but it might not work for all servers maxNumberOfWebWorkers: 3, diff --git a/platform/app/public/config/netlify.js b/platform/app/public/config/netlify.js index 884ed1cff..17631a4c2 100644 --- a/platform/app/public/config/netlify.js +++ b/platform/app/public/config/netlify.js @@ -1,13 +1,30 @@ /** @type {AppTypes.Config} */ +// Public demo / Netlify deploy configuration. +// +// This is the full-featured config used for the Netlify deploy (build:viewer:ci). +// Unlike the locked-down config/default.js, it enables every data source and +// turns the `?customization=` URL feature ON via `customizationUrlPrefixes`. window.config = { + name: 'config/netlify.js', routerBasename: null, - customizationService: [ - '@ohif/extension-default.customizationModule.theme', - ], + // whiteLabeling: {}, extensions: [], modes: [], + customizationService: ['@ohif/extension-default.customizationModule.theme'], + + // URL-driven customizations (?customization=). The `default` prefix (no + // slashes) is used for values without a leading slash; every other prefix + // must start AND end with a slash and matches the leading `/segment/` of the + // value. Files are fetched and parsed as JSONC data — never executed. + // e.g. ?customization=ctPresets -> ./customizations/ctPresets.jsonc + customizationUrlPrefixes: { + default: './customizations/', + }, + showStudyList: true, + // some windows systems have issues with more than 3 web workers + maxNumberOfWebWorkers: 3, // below flag is for performance reasons, but it might not work for all servers showWarningMessageForCrossOrigin: true, showCPUFallbackMessage: true, @@ -15,8 +32,83 @@ window.config = { experimentalStudyBrowserSort: false, strictZSpacingForVolumeViewport: true, groupEnabledModesFirst: true, + allowMultiSelectExport: false, + maxNumRequests: { + interaction: 100, + thumbnail: 5, + // Prefetch number is dependent on the http protocol. For http 2 or + // above, the number of requests can be go a lot higher. + prefetch: 25, + }, + showErrorDetails: 'always', // 'always', 'dev', 'production' // filterQueryParam: false, + // Defines multi-monitor layouts + multimonitor: [ + { + id: 'split', + test: ({ multimonitor }) => multimonitor === 'split', + screens: [ + { + id: 'ohif0', + screen: null, + location: { + screen: 0, + width: 0.5, + height: 1, + left: 0, + top: 0, + }, + options: 'location=no,menubar=no,scrollbars=no,status=no,titlebar=no', + }, + { + id: 'ohif1', + screen: null, + location: { + width: 0.5, + height: 1, + left: 0.5, + top: 0, + }, + options: 'location=no,menubar=no,scrollbars=no,status=no,titlebar=no', + }, + ], + }, + + { + id: '2', + test: ({ multimonitor }) => multimonitor === '2', + screens: [ + { + id: 'ohif0', + screen: 0, + location: { + width: 1, + height: 1, + left: 0, + top: 0, + }, + options: 'fullscreen=yes,location=no,menubar=no,scrollbars=no,status=no,titlebar=no', + }, + { + id: 'ohif1', + screen: 1, + location: { + width: 1, + height: 1, + left: 0, + top: 0, + }, + options: 'fullscreen=yes,location=no,menubar=no,scrollbars=no,status=no,titlebar=no', + }, + ], + }, + ], defaultDataSourceName: 'ohif', + /* Dynamic config allows user to pass "configUrl" query string this allows to load config without recompiling application. The regex will ensure valid configuration source */ + // dangerouslyUseDynamicConfig: { + // enabled: true, + // regex: /.*/, + // }, dataSources: [ { namespace: '@ohif/extension-default.dataSourcesModule.dicomweb', @@ -32,13 +124,10 @@ window.config = { thumbnailRendering: 'thumbnail', thumbnailRequestStrategy: 'fetch', enableStudyLazyLoad: true, - supportsFuzzyMatching: false, + supportsFuzzyMatching: true, supportsWildcard: true, staticWado: true, singlepart: 'bulkdata,video', - // whether the data source should use retrieveBulkData to grab metadata, - // and in case of relative path, what would it be relative to, options - // are in the series level or study level (some servers like series some study) bulkDataURI: { enabled: true, relativeResolution: 'studies', @@ -66,9 +155,6 @@ window.config = { supportsWildcard: true, staticWado: true, singlepart: 'bulkdata,video', - // whether the data source should use retrieveBulkData to grab metadata, - // and in case of relative path, what would it be relative to, options - // are in the series level or study level (some servers like series some study) bulkDataURI: { enabled: true, relativeResolution: 'studies', @@ -76,7 +162,6 @@ window.config = { omitQuotationForMultipartRequest: true, }, }, - { namespace: '@ohif/extension-default.dataSourcesModule.dicomweb', sourceName: 'ohif3', @@ -95,9 +180,6 @@ window.config = { supportsWildcard: true, staticWado: true, singlepart: 'bulkdata,video', - // whether the data source should use retrieveBulkData to grab metadata, - // and in case of relative path, what would it be relative to, options - // are in the series level or study level (some servers like series some study) bulkDataURI: { enabled: true, relativeResolution: 'studies', @@ -130,6 +212,29 @@ window.config = { }, }, }, + { + namespace: '@ohif/extension-default.dataSourcesModule.dicomweb', + sourceName: 'orthanc', + configuration: { + friendlyName: 'local Orthanc DICOMWeb Server', + name: 'DCM4CHEE', + wadoUriRoot: 'http://localhost/pacs/dicom-web', + qidoRoot: 'http://localhost/pacs/dicom-web', + wadoRoot: 'http://localhost/pacs/dicom-web', + qidoSupportsIncludeField: true, + supportsReject: true, + dicomUploadEnabled: true, + imageRendering: 'wadors', + thumbnailRendering: 'wadors', + enableStudyLazyLoad: true, + supportsFuzzyMatching: true, + supportsWildcard: true, + omitQuotationForMultipartRequest: true, + bulkDataURI: { + enabled: true, + }, + }, + }, { namespace: '@ohif/extension-default.dataSourcesModule.dicomwebproxy', @@ -163,20 +268,3 @@ window.config = { console.warn('test, navigate to https://ohif.org/'); }, }; - -function waitForElement(selector, maxAttempts = 20, interval = 25) { - return new Promise(resolve => { - let attempts = 0; - - const checkForElement = setInterval(() => { - const element = document.querySelector(selector); - - if (element || attempts >= maxAttempts) { - clearInterval(checkForElement); - resolve(); - } - - attempts++; - }, interval); - }); -} diff --git a/platform/app/public/customizations/ctPresets.jsonc b/platform/app/public/customizations/ctPresets.jsonc new file mode 100644 index 000000000..01311dfdd --- /dev/null +++ b/platform/app/public/customizations/ctPresets.jsonc @@ -0,0 +1,21 @@ +// Example URL-loaded customization: ctPresets +// +// Replaces the CT window/level presets offered in the window-level menu with a +// site-specific set (key: `cornerstone.windowLevelPresets`). Other modalities +// keep their defaults because only the `CT` entry is overridden. +// +// Load it with `?customization=ctPresets`. +{ + "global": { + "cornerstone.windowLevelPresets": { + "$merge": { + "CT": [ + { "id": "ct-soft-tissue", "description": "Soft tissue", "window": "400", "level": "40" }, + { "id": "ct-lung", "description": "Lung", "window": "1500", "level": "-600" }, + { "id": "ct-angio", "description": "Angio", "window": "600", "level": "300" }, + { "id": "ct-bone", "description": "Bone", "window": "2500", "level": "480" } + ] + } + } + } +} diff --git a/platform/app/public/customizations/measurementLabels.jsonc b/platform/app/public/customizations/measurementLabels.jsonc new file mode 100644 index 000000000..6a5b12d32 --- /dev/null +++ b/platform/app/public/customizations/measurementLabels.jsonc @@ -0,0 +1,23 @@ +// Example URL-loaded customization: measurementLabels +// +// Makes the viewer prompt for a label from a fixed list whenever a measurement +// is created (key: `measurementLabels`). `labelOnMeasure` triggers the prompt on +// creation and `exclusive` restricts entries to the provided items. +// +// Load it with `?customization=measurementLabels`. +{ + "global": { + "measurementLabels": { + "$set": { + "labelOnMeasure": true, + "exclusive": true, + "items": [ + { "value": "Head", "label": "Head" }, + { "value": "Shoulder", "label": "Shoulder" }, + { "value": "Knee", "label": "Knee" }, + { "value": "Toe", "label": "Toe" } + ] + } + } + } +} diff --git a/platform/app/public/customizations/patientBirthDate.jsonc b/platform/app/public/customizations/patientBirthDate.jsonc new file mode 100644 index 000000000..36e9fbd67 --- /dev/null +++ b/platform/app/public/customizations/patientBirthDate.jsonc @@ -0,0 +1,34 @@ +// Example URL-loaded customization: patientBirthDate +// +// Adds a "Birth Date" column to the WorkList study list, right after the +// Patient column. Because a URL customization file is DATA (never executed), it +// cannot carry a render function — it inserts a plain, serializable column spec +// (`id` + `meta`) and WorkList expands it into a display-only text column that +// reads `row.patientBirthDate` (surfaced by the DICOMweb data source from DICOM +// tag 00100030). +// +// This applies in the `global` phase, so it is in place before the WorkList +// renders. Load it with `?customization=patientBirthDate`, or pull it in from +// `appConfig.customizationService.requires`. +{ + "global": { + "workList.columns": { + // Insert at index 1 (immediately after the Patient column). Using a + // fixed index keeps the trailing `actions` column last. + "$splice": [ + [ + 1, + 0, + { + "id": "patientBirthDate", + "meta": { + "label": "Birth Date", + "minWidth": 130, + "priority": 40 + } + } + ] + ] + } + } +} diff --git a/platform/app/public/customizations/smoothRotate.jsonc b/platform/app/public/customizations/smoothRotate.jsonc new file mode 100644 index 000000000..c93072a54 --- /dev/null +++ b/platform/app/public/customizations/smoothRotate.jsonc @@ -0,0 +1,42 @@ +// Example URL-loaded customization: smoothRotate +// +// Adds a "Smooth Rotate" tool button to the More Tools menu of the basic and +// longitudinal viewers. It activates the cornerstone `PlanarRotate` tool, which +// rotates the image freely as you drag (unlike the fixed 90° "Rotate Right"). +// +// It extends the default toolbar customizations registered by the cornerstone +// extension (`cornerstone.toolbarButtons` / `cornerstone.toolbarSections`) using +// immutability-helper `$push` commands, so the built-in buttons are preserved. +// +// Load it with `?customization=smoothRotate`. +{ + "global": { + "cornerstone.toolbarButtons": { + "$push": [ + { + "id": "SmoothRotate", + "uiType": "ohif.toolButton", + "props": { + "type": "tool", + // Re-use the existing rotate icon. + "icon": "tool-rotate-right", + "label": "Smooth Rotate", + "tooltip": "Smooth Rotate (drag to rotate the image freely)", + "commands": { + "commandName": "setToolActiveToolbar", + "commandOptions": { + "toolName": "PlanarRotate" + } + }, + "evaluate": "evaluate.cornerstoneTool" + } + } + ] + }, + "cornerstone.toolbarSections": { + "MoreTools": { + "$push": ["SmoothRotate"] + } + } + } +} diff --git a/platform/app/public/customizations/veterinary.jsonc b/platform/app/public/customizations/veterinary.jsonc new file mode 100644 index 000000000..f64181818 --- /dev/null +++ b/platform/app/public/customizations/veterinary.jsonc @@ -0,0 +1,5 @@ +// Example chaining module: ensures `veterinaryOverlay` is loaded and applied +// first when using `?customization=veterinary` alone. +{ + "requires": ["veterinaryOverlay"] +} diff --git a/platform/app/public/customizations/veterinaryOverlay.jsonc b/platform/app/public/customizations/veterinaryOverlay.jsonc new file mode 100644 index 000000000..57dff05f2 --- /dev/null +++ b/platform/app/public/customizations/veterinaryOverlay.jsonc @@ -0,0 +1,56 @@ +// Example URL-loaded customization: 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 `global`-at-top-level shape as the cornerstone overlay samples +// and `inheritsFrom: 'ohif.overlayItem'` on each row, matching +// extensions/cornerstone/.../viewportOverlayCustomization.tsx. +{ + "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 18393f643..0f13807c6 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 717e7f172..20a83c427 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) { @@ -91,8 +91,25 @@ async function appInit(appConfigOrFunc, defaultExtensions, defaultModes) { * Example2: [[ext1, config], ext2, [ext3, config]] */ const loadedExtensions = await loadModules([...defaultExtensions, ...appConfig.extensions]); + + const { customizationService } = servicesManager.services; + // Resolve every customization module up front — from + // `appConfig.customizationService.requires` and the `?customization=` URL + // parameter — long before any mode loads, then apply the `bootstrap` phase + // BEFORE extensions register so it is in place while they initialize. Modules + // are only loaded when `appConfig.customizationUrlPrefixes` allows their + // prefix; the feature is off by default, and a value with an unconfigured + // prefix throws here (aborting startup) rather than being silently ignored. + await customizationService.loadAndApplyBootstrapCustomizations(extensionManager); + await extensionManager.registerExtensions(loadedExtensions, appConfig.dataSources); + // Merge extension default/global modules, then layer on the `global` phase of + // the structured config + the URL modules resolved above (so `$apply`-style + // overrides see the extension-provided defaults they build on). + customizationService.init(extensionManager); + customizationService.applyGlobalCustomizations(); + // 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/hooks/useStudyListStateSync.ts b/platform/app/src/hooks/useStudyListStateSync.ts index 703b3bef4..91dfb3385 100644 --- a/platform/app/src/hooks/useStudyListStateSync.ts +++ b/platform/app/src/hooks/useStudyListStateSync.ts @@ -175,7 +175,9 @@ function parseFiltersFromURL(params: URLSearchParams): ColumnFiltersState { * Build URL query string from study list state preserving key query parameters. */ function buildQueryFromState(state: StudyListState): string { - const query: Record = {}; + // preserveQueryStrings may write repeated values as arrays, so the value type + // must allow string[] in addition to string. + const query: Record = {}; // Sorting if (state.sorting.length > 0) { diff --git a/platform/app/src/routes/Mode/Mode.tsx b/platform/app/src/routes/Mode/Mode.tsx index 8ea46a8f6..2d87e24a4 100644 --- a/platform/app/src/routes/Mode/Mode.tsx +++ b/platform/app/src/routes/Mode/Mode.tsx @@ -221,6 +221,12 @@ export default function ModeRoute({ appConfig, }); + // `extensionManager.onModeEnter` resets the customization mode scope via + // `customizationService.onModeEnter`; now layer on the `mode` phase blocks + // for this mode — the general (`*`) block first, then any block keyed by + // this mode's id / routeName so a single mode can override the general one. + customizationService.applyModeCustomizations([mode.id, mode.routeName]); + // use the URL hangingProtocolId if it exists, otherwise use the one // defined in the mode configuration const hangingProtocolIdToUse = hangingProtocolService.getProtocolById( diff --git a/platform/app/src/routes/WorkList/WorkList.tsx b/platform/app/src/routes/WorkList/WorkList.tsx index 0f3e43801..d15d2f27c 100644 --- a/platform/app/src/routes/WorkList/WorkList.tsx +++ b/platform/app/src/routes/WorkList/WorkList.tsx @@ -51,7 +51,16 @@ export default function WorkList({ // `workList.columns` is registered as a value (StudyList.defaultColumns) and // merged via customization commands, so we read the result directly. const customized = customizationService.getCustomization('workList.columns'); - return Array.isArray(customized) ? customized : StudyList.defaultColumns; + const resolved = Array.isArray(customized) ? customized : StudyList.defaultColumns; + // Expand data-only column specs. A `?customization=` JSONC file (or any + // serializable source) cannot carry render functions, so an entry that has + // an `id` but no `accessorFn`/`cell` is turned into a display-only text + // column that reads `row[id]` — matching `StudyList.textColumn`. + return resolved.map((col: any) => + col && typeof col === 'object' && col.id && !col.accessorFn && !col.cell + ? StudyList.textColumn(col.id, col.meta?.label ?? col.id, col.meta) + : col + ); }, [customizationService]); const logoComponent = appConfig?.whiteLabeling?.createLogoComponentFn?.(React) ?? ( diff --git a/platform/app/src/service-worker.js b/platform/app/src/service-worker.js index a235b8c79..9c79e8c74 100644 --- a/platform/app/src/service-worker.js +++ b/platform/app/src/service-worker.js @@ -14,7 +14,7 @@ workbox.core.clientsClaim(); // Cache static assets that aren't precached workbox.routing.registerRoute( - /\.(?:js|css|json5)$/, + /\.(?:js|css|json5|jsonc)$/, new workbox.strategies.StaleWhileRevalidate({ cacheName: 'static-resources', }) diff --git a/platform/app/src/utils/preserveQueryParameters.test.ts b/platform/app/src/utils/preserveQueryParameters.test.ts new file mode 100644 index 000000000..d8db8937f --- /dev/null +++ b/platform/app/src/utils/preserveQueryParameters.test.ts @@ -0,0 +1,102 @@ +// query-string is the stringifier used by the worklist navigation callers +// (useStudyListStateSync, LegacyWorkList); test against it, not the `qs` library. +import qs from 'query-string'; + +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 single values as strings and repeated values 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).toBe('foo.js'); + expect(out.customization).toEqual(['a', 'b']); + }); + + it('keeps a single customization value as a string', () => { + const current = new URLSearchParams(); + current.append('customization', 'only'); + const out: Record = {}; + preserveQueryStrings(out, undefined, current); + expect(out.customization).toBe('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).toBe('c'); + expect(customizationService.getValue).toHaveBeenCalled(); + }); + + it('serializes single preserved values as plain query keys with default options', () => { + const current = new URLSearchParams(); + current.append('configUrl', 'foo.js'); + const out: Record = {}; + preserveQueryStrings(out, undefined, current); + // Default options only — no arrayFormat. Single values are plain strings, so + // they round-trip unchanged regardless of arrayFormat. + const search = qs.stringify(out, { skipNull: true, skipEmptyString: true }); + expect(search).toBe('configUrl=foo.js'); + expect(search).not.toMatch(/configUrl\[/); + }); + + it('serializes repeated preserved values as duplicated keys with default options', () => { + const current = new URLSearchParams(); + current.append('customization', 'a'); + current.append('customization', 'b'); + const out: Record = {}; + preserveQueryStrings(out, undefined, current); + // query-string's default arrayFormat already produces duplicated keys, so callers + // need no arrayFormat option for repeated preserved keys to round-trip. + const search = qs.stringify(out, { skipNull: true, skipEmptyString: true }); + expect(search).toBe('customization=a&customization=b'); + }); +}); diff --git a/platform/app/src/utils/preserveQueryParameters.ts b/platform/app/src/utils/preserveQueryParameters.ts index f50e978ad..195c28e24 100644 --- a/platform/app/src/utils/preserveQueryParameters.ts +++ b/platform/app/src/utils/preserveQueryParameters.ts @@ -1,26 +1,63 @@ -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'; -export const preserveKeys = ['configUrl', 'multimonitor', 'screenNumber', 'hangingProtocolId']; +/** + * Keys preserved when navigating between worklist and viewer modes. + * Each key may repeat in the URL; a single occurrence is preserved as a string and + * multiple occurrences as an array (see preserveQueryStrings). + */ +export const PRESERVE_CUSTOMIZATION_KEYS_KEY = 'ohif.preserveCustomizationKeys'; +export const preserveKeys = [ + 'configUrl', + 'multimonitor', + 'screenNumber', + 'hangingProtocolId', + 'customization', +]; -export function preserveQueryParameters( - query, - current = new URLSearchParams(window.location.search) -) { - for (const key of preserveKeys) { - preserve(query, current, key); - } -} - -export function preserveQueryStrings(query, current = new URLSearchParams(window.location.search)) { - for (const key of preserveKeys) { - const value = current.get(key); +function preserveKey(query: URLSearchParams, current: URLSearchParams, key: string) { + const values = current.getAll(key); + for (const value of values) { if (value) { - query[key] = value; + query.append(key, value); } } } + +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: 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: 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) { + continue; + } + // A single value is stored as a plain string and repeated values as an array. + // The worklist stringifier (query-string) serializes both correctly with its + // default arrayFormat: a string becomes `key=value` and an array becomes + // duplicated `key=a&key=b` keys — no arrayFormat option required by callers. + // Keeping single values as strings also keeps them safe under stricter + // serializers (e.g. the `qs` library, whose default would index arrays). + query[key] = values.length === 1 ? values[0] : values; + } +} diff --git a/platform/core/package.json b/platform/core/package.json index 37ea211d6..e99c80636 100644 --- a/platform/core/package.json +++ b/platform/core/package.json @@ -49,6 +49,7 @@ "gl-matrix": "3.4.3", "immutability-helper": "3.1.1", "isomorphic-base64": "1.0.2", + "json5": "2.2.3", "lodash.clonedeep": "4.5.0", "lodash.isequal": "4.5.0", "moment": "2.30.1", 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 000000000..ea33d8492 --- /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.phases.test.ts b/platform/core/src/services/CustomizationService/CustomizationService.phases.test.ts new file mode 100644 index 000000000..0fc6c7cab --- /dev/null +++ b/platform/core/src/services/CustomizationService/CustomizationService.phases.test.ts @@ -0,0 +1,130 @@ +import CustomizationService, { GENERAL_MODE_KEY } from './CustomizationService'; + +const commandsManager = {}; + +const policy = { + prefixes: { default: './customizations/' }, +}; + +/** Minimal ExtensionManager stub exposing the appConfig the URL policy reads. */ +function makeExtensionManager(appConfig: Record = {}) { + return { + appConfig: { customizationUrlPrefixes: policy.prefixes, ...appConfig }, + registeredExtensionIds: [], + getRegisteredExtensionIds: () => [], + getModuleEntry: () => undefined, + } as any; +} + +describe('CustomizationService phase-tagged loading', () => { + it('applies bootstrap before extensions and global after, both Global scope', async () => { + const service = new CustomizationService({ + commandsManager, + configuration: { + bootstrap: { early: { $set: 'pre' } }, + global: { late: { $set: 'post' } }, + }, + }); + + const extensionManager = makeExtensionManager(); + await service.loadAndApplyBootstrapCustomizations(extensionManager); + + // bootstrap is applied immediately; global is not yet. + expect(service.getCustomization('early')).toBe('pre'); + expect(service.getCustomization('late')).toBeUndefined(); + + service.init(extensionManager); + service.applyGlobalCustomizations(); + + expect(service.getCustomization('late')).toBe('post'); + expect(service.getCustomizations(service.Scope.Global).get('early')).toBeDefined(); + }); + + it('does not treat a phase-tagged config as legacy Global references', () => { + const service = new CustomizationService({ + commandsManager, + configuration: { global: { a: { $set: 1 } } }, + }); + service.init(makeExtensionManager()); + // `global` must NOT be added as a customization id during init; it is a phase. + expect(service.getCustomization('global')).toBeUndefined(); + expect(service.getCustomization('a')).toBeUndefined(); + }); + + it('applies the general mode block first, then the mode-specific block', () => { + const service = new CustomizationService({ + commandsManager, + configuration: { + mode: { + [GENERAL_MODE_KEY]: { greeting: { $set: 'general' }, shared: { $set: 'general' } }, + viewer: { greeting: { $set: 'viewer' } }, + }, + }, + }); + service.init(makeExtensionManager()); + + service.onModeEnter(); + service.applyModeCustomizations(['viewer']); + + // mode-specific overrides general; non-overridden general value remains. + expect(service.getCustomization('greeting')).toBe('viewer'); + expect(service.getCustomization('shared')).toBe('general'); + }); + + it('matches a mode block by id OR routeName', () => { + const service = new CustomizationService({ + commandsManager, + configuration: { + mode: { viewer: { fromRouteName: { $set: true } } }, + }, + }); + service.init(makeExtensionManager()); + + service.onModeEnter(); + service.applyModeCustomizations(['@ohif/mode-longitudinal', 'viewer']); + expect(service.getCustomization('fromRouteName')).toBe(true); + }); + + it('clears mode-scope customizations on re-enter so a different mode does not leak', () => { + const service = new CustomizationService({ + commandsManager, + configuration: { + mode: { + viewer: { onlyViewer: { $set: true } }, + segmentation: { onlySeg: { $set: true } }, + }, + }, + }); + service.init(makeExtensionManager()); + + service.onModeEnter(); + service.applyModeCustomizations(['viewer']); + expect(service.getCustomization('onlyViewer')).toBe(true); + + service.onModeEnter(); + service.applyModeCustomizations(['segmentation']); + expect(service.getCustomization('onlyViewer')).toBeUndefined(); + expect(service.getCustomization('onlySeg')).toBe(true); + }); + + it('applies phase blocks from URL-loaded modules and merges $apply over extension defaults', async () => { + const service = new CustomizationService({ commandsManager, configuration: {} }); + const extensionManager = makeExtensionManager(); + + // Seed an extension-style default so $apply has something to build on. + service.init(extensionManager); + service.setCustomizations({ 'list.columns': ['a', 'b'] }, service.Scope.Default); + + const importFn = jest.fn(async () => ({ + global: { 'list.columns': { $push: ['c'] } }, + mode: { '*': { banner: { $set: 'all' } }, viewer: { banner: { $set: 'viewer' } } }, + })); + + await service.loadCustomizationModules(['A'], { policy, importFn }); + service.applyGlobalCustomizations(); + expect(service.getCustomization('list.columns')).toEqual(['a', 'b', 'c']); + + service.applyModeCustomizations(['viewer']); + expect(service.getCustomization('banner')).toBe('viewer'); + }); +}); 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 000000000..c39b81367 --- /dev/null +++ b/platform/core/src/services/CustomizationService/CustomizationService.requires.test.ts @@ -0,0 +1,148 @@ +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) => ({ + 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.global).toBeDefined(); + expect(importFn).toHaveBeenCalledTimes(1); + }); + + it('returns immediately when the same module is already loaded', async () => { + const importFn = jest.fn(async (url: string) => ({ + 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.jsonc')) { + return { + global: { 'pkg.A': { value: 'A' } }, + requires: ['B'], + }; + } + if (url.endsWith('/B.jsonc')) { + return { + 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.jsonc')) { + return { + global: { 'pkg.A': { value: 'A' } }, + requires: ['B'], + }; + } + if (url.endsWith('/B.jsonc')) { + return { + 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 () => ({ + 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 () => ({ + global: { 'pkg.X': {} }, + })); + const loaded = await service.requires(['A', 'B', 'C'], { policy, importFn }); + expect(loaded.map(l => l.request.name)).toEqual(['A', 'B', 'C']); + expect(importFn).toHaveBeenCalledTimes(3); + }); + + it('throws and loads nothing when any entry is rejected', async () => { + const importFn = jest.fn(async () => ({ + global: {}, + })); + await expect( + service.requires(['A', '/missing/foo', '../escape'], { policy, importFn }) + ).rejects.toThrow(/refusing to load customization/); + expect(importFn).not.toHaveBeenCalled(); + }); + + it('warns and skips when import fails', 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('warns and skips when the module has no customization payload', async () => { + const importFn = jest.fn(async () => ({})); + 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('applyCustomizationUrlSearchParams delegates to requires', async () => { + const importFn = jest.fn(async () => ({ + global: { 'pkg.X': {} }, + })); + const params = new URLSearchParams(); + params.append('customization', 'A,B'); + params.append('customization', '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 88041a641..c7173b30d 100644 --- a/platform/core/src/services/CustomizationService/CustomizationService.ts +++ b/platform/core/src/services/CustomizationService/CustomizationService.ts @@ -1,8 +1,33 @@ import update, { extend } from 'immutability-helper'; +import JSON5 from 'json5'; 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, + CustomizationPhaseInput, + LoadedCustomization, + LoadOptions, + PhasedCustomizationConfig, +} from './customizationUrlTypes'; + +/** + * Reserved key in a `mode` phase block for the "general" customizations that + * apply to every mode. The general block is applied FIRST on each mode enter; + * a block keyed by the entered mode's id / routeName is applied after it so a + * single mode can override the general values. + */ +export const GENERAL_MODE_KEY = '*'; const EVENTS = { MODE_CUSTOMIZATION_MODIFIED: 'event::CustomizationService:modeModified', @@ -28,7 +53,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 +115,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,41 +128,334 @@ 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>(); + + /** + * Every URL customization module resolved this page session, in load order + * (dependencies before dependents). The lifecycle phase appliers + * ({@link applyBootstrapCustomizations}, {@link applyGlobalCustomizations}, + * {@link applyModeCustomizations}) iterate this list so a module's phase + * blocks are applied at the right time regardless of when it was fetched. + */ + private _resolvedUrlModules: LoadedCustomization[] = []; + + /** + * Normalized form of `appConfig.customizationService`. Either a phase-tagged + * config ({@link PhasedCustomizationConfig}) or, for the legacy array/object + * form, the references to add (Global) during {@link init}. + */ + private _customizationConfig: { + phased?: PhasedCustomizationConfig; + legacyReferences?: unknown; + } | null = null; + + /** Id/aliases of the mode currently entered, used to re-apply mode phase blocks. */ + private _currentModeIds: string[] = []; + + /** + * 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; } + // =========================================================================== + // Public API + // =========================================================================== + + /** + * 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); + } } }); - // Only add references for the configuration once. - if (!this.configuration?._hasBeenAdded) { - this.addReferences(this.configuration); + // Only add references for the configuration once. The phase-tagged config + // form (bootstrap/global/mode) is applied by the lifecycle appliers + // instead, so only the legacy array/object form is added here as Global. + const config = this._getCustomizationConfig(); + if (config.legacyReferences !== undefined && !this.configuration?._hasBeenAdded) { + this.addReferences(config.legacyReferences); Object.defineProperty(this.configuration, '_hasBeenAdded', { value: true, writable: false }); } } + /** + * Memoized, normalized view of `appConfig.customizationService`. Detects the + * phase-tagged form (any of `requires` / `bootstrap` / `global` / `mode`) + * and otherwise treats the value as legacy Global references. + */ + private _getCustomizationConfig(): { phased?: PhasedCustomizationConfig; legacyReferences?: unknown } { + if (!this._customizationConfig) { + this._customizationConfig = normalizeCustomizationConfig(this.configuration); + } + return this._customizationConfig; + } + + /** + * Loads and applies `?customization=` modules from `window.location.search`. + * + * **Throws on disallowed values.** A `?customization=` value whose prefix is + * not in `appConfig.customizationUrlPrefixes` (the default — the feature is off + * until prefixes are configured) rejects this promise, which by design aborts + * app bootstrap rather than silently ignoring the request. + * + * **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. + * + * **What is loaded:** Each `?customization=` entry resolves to a JSONC file (JSON with + * comments / trailing commas) under a configured prefix directory. The file is fetched and + * parsed as **data** — it is never executed. Executable code (plugins, modes, extensions) + * loads only through `pluginConfig.json`, never from the customization URL path. A module's + * `global` payload is applied as global customizations and its `requires` are loaded first. + */ + public async applyWindowUrlCustomizations(overrides?: Partial): Promise { + if (typeof window === 'undefined') { + return; + } + await this.applyCustomizationUrlSearchParams( + new URLSearchParams(window.location.search), + overrides + ); + } + + /** + * 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. + * + * Invalid query entries, resolve failures, failed imports, and modules without a + * customization payload are warned and skipped. + */ + public requires( + names: string | string[], + overrides?: Partial + ): Promise { + return this.loadCustomizationModules(names, overrides).then(newlyLoaded => { + // Back-compat: a direct `requires()` / `applyWindowUrlCustomizations()` + // call applies the `global` slice immediately. The `bootstrap` and + // `mode` phases are driven by the boot orchestration + // ({@link loadAndApplyBootstrapCustomizations}) and {@link onModeEnter}. + this._applyLoadedUrlCustomizationModules(newlyLoaded); + return newlyLoaded; + }); + } + + /** + * Resolves (fetches + parses) the given URL customization modules and their + * `requires` dependencies depth-first, WITHOUT applying any phase block. + * Newly resolved modules are appended to {@link _resolvedUrlModules} in load + * order so the lifecycle appliers can apply each phase at the right time. + * + * Rejects (aborting the load) when any entry is disallowed by the policy. + */ + public loadCustomizationModules( + 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; + // A `?customization=` value that is not allowed by the configured prefixes is + // a hard error: stop the load and let it propagate. The feature is off until + // `appConfig.customizationUrlPrefixes` allows a prefix, so on a default build + // any `?customization=` value throws here rather than being silently ignored. + if (rejected.length) { + const details = rejected.map(r => `"${r.raw}" (${r.reason})`).join('; '); + return Promise.reject( + new Error( + `[customizationUrl] refusing to load customization(s): ${details}. ` + + `Allowed prefixes are configured in appConfig.customizationUrlPrefixes.` + ) + ); + } + 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._resolvedUrlModules.push(...newlyLoaded); + return newlyLoaded; + }); + } + + /** + * Boot-time entry point that runs BEFORE extensions are registered. It: + * 1. records the ExtensionManager (so the URL policy / appConfig is readable), + * 2. resolves every customization module requested via the + * `appConfig.customizationService.requires` list and the `?customization=` + * URL parameter (their data is fetched once, up front — long before any + * mode loads), and + * 3. applies the `bootstrap` phase blocks. + * + * Rejects (aborting bootstrap) if a `?customization=` value is disallowed. + */ + public async loadAndApplyBootstrapCustomizations( + extensionManager: ExtensionManager, + overrides?: Partial + ): Promise { + this.extensionManager = extensionManager; + const config = this._getCustomizationConfig(); + + const names: string[] = []; + const requires = config.phased?.requires; + if (typeof requires === 'string' && requires) { + names.push(requires); + } else if (Array.isArray(requires)) { + names.push(...requires.filter(name => typeof name === 'string' && name)); + } + if (typeof window !== 'undefined') { + names.push(...parseCustomizationParams(new URLSearchParams(window.location.search))); + } + + if (names.length) { + await this.loadCustomizationModules(names, overrides); + } + + this.applyBootstrapCustomizations(); + } + + /** + * Applies the `bootstrap` phase (Global scope) of the structured app config + * and every resolved URL module. App-config blocks apply first so URL modules + * layer on top. + */ + public applyBootstrapCustomizations(): void { + this._applyPhase('bootstrap'); + } + + /** + * Applies the `global` phase (Global scope) of the structured app config and + * every resolved URL module. Call after {@link init} so extension default / + * global customizations are already in place for `$apply`-style merges. + */ + public applyGlobalCustomizations(): void { + this._applyPhase('global'); + } + + /** + * Applies the `mode` phase (Mode scope) for the entered mode. The general + * block (`*`) is applied FIRST, then any block keyed by one of `modeIds` + * (the mode's id / routeName), so a single mode can override the general + * values. Call this AFTER `customizationService.onModeEnter()` has reset the + * mode scope (i.e. after `extensionManager.onModeEnter()`). + */ + public applyModeCustomizations(modeIds: string | string[]): void { + const ids = (Array.isArray(modeIds) ? modeIds : [modeIds]).filter(Boolean) as string[]; + this._currentModeIds = ids; + + const blocks = this._collectPhaseBlocks('mode') as Array<{ + mode?: Record; + }>; + + // General first, for every source. + for (const block of blocks) { + const general = block.mode?.[GENERAL_MODE_KEY]; + if (general) { + this.setCustomizations(general, CustomizationScope.Mode); + } + } + // Then mode-specific, for every source. + for (const block of blocks) { + for (const id of ids) { + const specific = block.mode?.[id]; + if (specific) { + this.setCustomizations(specific, CustomizationScope.Mode); + } + } + } + } + public onModeEnter(): void { this.clearTransformedCustomizations(); @@ -148,17 +466,6 @@ export default class CustomizationService extends PubSubService { this.clearTransformedCustomizations(); } - private clearTransformedCustomizations(): void { - super.reset(); - - const modeCustomizationKeys = Array.from(this.modeCustomizations.keys()); - for (const key of modeCustomizationKeys) { - this.transformedCustomizations.delete(key); - } - - this.modeCustomizations.clear(); - } - /** * Unified getter for customizations. * @@ -184,6 +491,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. @@ -229,34 +544,6 @@ export default class CustomizationService extends PubSubService { this._setCustomization(customizationId, customization, scope); } - /** - * Internal method to set a single customization - */ - private _setCustomization( - customizationId: string, - customization: Customization, - scope: CustomizationScope = CustomizationScope.Mode - ): void { - // if (typeof customization === 'string') { - // const extensionValue = this._findExtensionValue(customization); - // customization = extensionValue.value; - // } - - switch (scope) { - case CustomizationScope.Global: - this.setGlobalCustomization(customizationId, customization); - break; - case CustomizationScope.Mode: - this.setModeCustomization(customizationId, customization); - break; - case CustomizationScope.Default: - this.setDefaultCustomization(customizationId, customization); - break; - default: - throw new Error(`Invalid customization scope: ${scope}`); - } - } - /** * Gets all customizations for a given scope. * @@ -303,68 +590,6 @@ export default class CustomizationService extends PubSubService { return result.$transform?.(this) || result; } - /** - * - * Sets a mode-specific customization. - * - * This method allows you to define or update a customization that applies only to the current mode. - * Mode customizations are temporary and isolated, reset whenever a mode changes. - * - * @param customizationId - The unique identifier for the customization. - * @param customization - The customization object containing the desired settings. - */ - private setModeCustomization(customizationId: string, customization: Customization): void { - const defaultCustomization = this.defaultCustomizations.get(customizationId); - const modeCustomization = this.modeCustomizations.get(customizationId); - const globCustomization = this.globalCustomizations.get(customizationId); - - const sourceCustomization = - modeCustomization || this._cloneIfNeeded(globCustomization) || defaultCustomization; - - const result = this._update(sourceCustomization, customization); - this.modeCustomizations.set(customizationId, result); - - this.transformedCustomizations.clear(); - this._broadcastEvent(this.EVENTS.MODE_CUSTOMIZATION_MODIFIED, { - buttons: this.modeCustomizations, - button: this.modeCustomizations.get(customizationId), - }); - } - - private setGlobalCustomization(id: string, value: Customization): void { - const defaultCustomization = this.defaultCustomizations.get(id); - const globCustomization = this.globalCustomizations.get(id); - - const sourceCustomization = this._cloneIfNeeded(globCustomization) || defaultCustomization; - this.globalCustomizations.set(id, this._update(sourceCustomization, value)); - - this.transformedCustomizations.clear(); - this._broadcastEvent(this.EVENTS.GLOBAL_CUSTOMIZATION_MODIFIED, { - buttons: this.defaultCustomizations, - button: this.defaultCustomizations.get(id), - }); - } - - private setDefaultCustomization(id: string, value: Customization): void { - if (this.defaultCustomizations.has(id)) { - console.warn(`Trying to update existing default for customization ${id}`); - } - this.transformedCustomizations.clear(); - - const sourceCustomization = this.defaultCustomizations.get(id); - this.defaultCustomizations.set(id, this._update(sourceCustomization, value)); - - this._broadcastEvent(this.EVENTS.DEFAULT_CUSTOMIZATION_MODIFIED, { - buttons: this.defaultCustomizations, - button: this.defaultCustomizations.get(id), - }); - } - - private _findExtensionValue(value: string) { - const entry = this.extensionManager.getModuleEntry(value); - return entry as { value: Customization }; - } - /** * Registers a custom command to be used in customization updates. * @param commandName - The name of the command (without the $ prefix) @@ -381,40 +606,6 @@ export default class CustomizationService extends PubSubService { extend(commandName, handler); } - /** - * Uses immutability-helper to apply the user's commands (e.g. $set, $push, $apply, etc.) - * Takes into account the 'mergeType' if it's explicitly 'Replace'; otherwise does a normal update. - */ - private _update(oldValue: Customization | undefined, newValue: Customization): Customization { - if (!oldValue) { - oldValue = undefined; - } - - // Use immutability-helper to apply the commands - // if $ is not part of the value in the json string, then we just return the newValue - if (!hasDollarKey(newValue)) { - return newValue; - } - - const result = update(oldValue, newValue); - return result; - } - - private _cloneIfNeeded(value: any) { - // If it's null/undefined or not an object, return as is - if (!value || typeof value !== 'object') { - return value; - } - - // If it's an array, create a shallow copy - if (Array.isArray(value)) { - return [...value]; - } - - // Otherwise create a shallow copy of the object - return { ...value }; - } - _addReference(value?: any, type = CustomizationScope.Global): void { if (!value) { return; @@ -451,6 +642,362 @@ export default class CustomizationService extends PubSubService { this._addReference(references, type); } } + + // =========================================================================== + // Private methods + // =========================================================================== + + private clearTransformedCustomizations(): void { + super.reset(); + + const modeCustomizationKeys = Array.from(this.modeCustomizations.keys()); + for (const key of modeCustomizationKeys) { + this.transformedCustomizations.delete(key); + } + + this.modeCustomizations.clear(); + } + + /** + * Default loader for a `?customization=` module. Customization files are + * **data**, not code: the file is fetched and parsed as JSONC (JSON with + * comments / trailing commas, via JSON5 which is a superset). It is never + * executed. Executable modules — plugins, modes and extensions — load only + * through `pluginConfig.json`, never from the customization URL path. + */ + private async _urlDefaultImport(url: string): Promise { + if (typeof fetch !== 'function') { + throw new Error(`No fetch implementation available to load customization ${url}`); + } + const response = await fetch(url); + if (!response.ok) { + throw new Error( + `Failed to fetch customization ${url}: ${response.status} ${response.statusText}` + ); + } + const text = await response.text(); + return JSON5.parse(text); + } + + 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 { + // `requires` entries are module names to load; each is validated/resolved like any + // other URL customization request. Entries that don't resolve to a valid module + // (e.g. a bare customization-key reference) are rejected by validation and + // skipped — there is no namespace carve-out. + 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}`; + logger.warn(msg); + return Promise.resolve(null); + } + + return importFn(url) + .catch(err => { + 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`; + logger.warn(msg); + return null; + } + if (!getUrlCustomizationModulePayload(module)) { + const msg = `[customizationUrl] missing customization module "${request.raw}" (${url}): no customizations payload`; + 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) { + this.setCustomizations(payload.global, CustomizationScope.Global); + } + } + } + + /** + * Collects the phase blocks for `phase` from every source, in apply order: + * the structured app config first, then each resolved URL module in load + * order. Used by {@link applyModeCustomizations}; the simpler `bootstrap` / + * `global` phases go through {@link _applyPhase}. + */ + private _collectPhaseBlocks(phase: keyof PhasedCustomizationConfig): PhasedCustomizationConfig[] { + const blocks: PhasedCustomizationConfig[] = []; + const phased = this._getCustomizationConfig().phased; + if (phased && phased[phase] !== undefined) { + blocks.push(phased); + } + for (const entry of this._resolvedUrlModules) { + const payload = getUrlCustomizationModulePayload(entry.module); + if (payload && payload[phase] !== undefined) { + blocks.push(payload); + } + } + return blocks; + } + + /** Applies a Global-scoped phase (`bootstrap` / `global`) from all sources. */ + private _applyPhase(phase: 'bootstrap' | 'global'): void { + for (const block of this._collectPhaseBlocks(phase)) { + const value = block[phase] as CustomizationPhaseInput | undefined; + if (value) { + this.setCustomizations(value, CustomizationScope.Global); + } + } + } + + /** + * Internal method to set a single customization + */ + private _setCustomization( + customizationId: string, + customization: Customization, + scope: CustomizationScope = CustomizationScope.Mode + ): void { + // if (typeof customization === 'string') { + // const extensionValue = this._findExtensionValue(customization); + // customization = extensionValue.value; + // } + + switch (scope) { + case CustomizationScope.Global: + this.setGlobalCustomization(customizationId, customization); + break; + case CustomizationScope.Mode: + this.setModeCustomization(customizationId, customization); + break; + case CustomizationScope.Default: + this.setDefaultCustomization(customizationId, customization); + break; + default: + throw new Error(`Invalid customization scope: ${scope}`); + } + } + + /** + * + * Sets a mode-specific customization. + * + * This method allows you to define or update a customization that applies only to the current mode. + * Mode customizations are temporary and isolated, reset whenever a mode changes. + * + * @param customizationId - The unique identifier for the customization. + * @param customization - The customization object containing the desired settings. + */ + private setModeCustomization(customizationId: string, customization: Customization): void { + const defaultCustomization = this.defaultCustomizations.get(customizationId); + const modeCustomization = this.modeCustomizations.get(customizationId); + const globCustomization = this.globalCustomizations.get(customizationId); + + const sourceCustomization = + modeCustomization || this._cloneIfNeeded(globCustomization) || defaultCustomization; + + const result = this._update(sourceCustomization, customization); + this.modeCustomizations.set(customizationId, result); + + this.transformedCustomizations.clear(); + this._broadcastEvent(this.EVENTS.MODE_CUSTOMIZATION_MODIFIED, { + buttons: this.modeCustomizations, + button: this.modeCustomizations.get(customizationId), + }); + } + + private setGlobalCustomization(id: string, value: Customization): void { + const defaultCustomization = this.defaultCustomizations.get(id); + const globCustomization = this.globalCustomizations.get(id); + + const sourceCustomization = this._cloneIfNeeded(globCustomization) || defaultCustomization; + this.globalCustomizations.set(id, this._update(sourceCustomization, value)); + + this.transformedCustomizations.clear(); + this._broadcastEvent(this.EVENTS.GLOBAL_CUSTOMIZATION_MODIFIED, { + buttons: this.globalCustomizations, + button: this.globalCustomizations.get(id), + }); + } + + private setDefaultCustomization(id: string, value: Customization): void { + // 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); + this.defaultCustomizations.set(id, this._update(sourceCustomization, value)); + + this._broadcastEvent(this.EVENTS.DEFAULT_CUSTOMIZATION_MODIFIED, { + buttons: this.defaultCustomizations, + button: this.defaultCustomizations.get(id), + }); + } + + private _findExtensionValue(value: string) { + const entry = this.extensionManager.getModuleEntry(value); + return entry as { value: Customization }; + } + + /** + * Uses immutability-helper to apply the user's commands (e.g. $set, $push, $apply, etc.) + * Takes into account the 'mergeType' if it's explicitly 'Replace'; otherwise does a normal update. + */ + private _update(oldValue: Customization | undefined, newValue: Customization): Customization { + if (!oldValue) { + oldValue = undefined; + } + + // Use immutability-helper to apply the commands + // if $ is not part of the value in the json string, then we just return the newValue + if (!hasDollarKey(newValue)) { + return newValue; + } + + const result = update(oldValue, newValue); + return result; + } + + private _cloneIfNeeded(value: any) { + // If it's null/undefined or not an object, return as is + if (!value || typeof value !== 'object') { + return value; + } + + // If it's an array, create a shallow copy + if (Array.isArray(value)) { + return [...value]; + } + + // Otherwise create a shallow copy of the object + return { ...value }; + } } /** Add custom $filter command */ @@ -523,6 +1070,48 @@ extend('$filter', (query, original) => { return deepFilter(original, query); }); +const PHASE_CONFIG_KEYS: Array = [ + 'requires', + 'bootstrap', + 'global', + 'mode', +]; + +/** + * Normalizes `appConfig.customizationService` into either: + * - `{ phased }` — the structured, phase-tagged config, detected by + * the presence of any of `requires` / `bootstrap` + * / `global` / `mode`; or + * - `{ legacyReferences }` — the legacy array / object-map form, which is + * added (Global scope) during `init()` exactly as + * before. + */ +export function normalizeCustomizationConfig(configuration: unknown): { + phased?: PhasedCustomizationConfig; + legacyReferences?: unknown; +} { + if (!configuration) { + return {}; + } + if (Array.isArray(configuration)) { + return { legacyReferences: configuration }; + } + if (typeof configuration === 'object') { + const isPhased = PHASE_CONFIG_KEYS.some(key => key in (configuration as object)); + if (isPhased) { + const phased: PhasedCustomizationConfig = {}; + for (const key of PHASE_CONFIG_KEYS) { + if (key in (configuration as object)) { + (phased as Record)[key] = (configuration as Record)[key]; + } + } + return { phased }; + } + return { legacyReferences: configuration }; + } + return {}; +} + function hasDollarKey(value) { if (Array.isArray(value)) { for (const item of value) { diff --git a/platform/core/src/services/CustomizationService/customizationUrl.ts b/platform/core/src/services/CustomizationService/customizationUrl.ts new file mode 100644 index 000000000..2af76ba34 --- /dev/null +++ b/platform/core/src/services/CustomizationService/customizationUrl.ts @@ -0,0 +1,49 @@ +import { + CUSTOMIZATION_URL_PREFIXES_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'; + +/** + * Builds the `?customization=` policy from the **app config** property + * `customizationUrlPrefixes` (read off `extensionManager.appConfig`). This is + * intentionally not a customization: customizations can be loaded from the URL, + * so letting one define prefixes would let it widen its own allowlist. When the + * property is absent the policy has no prefixes and the feature is off. + */ +export function getCustomizationUrlPolicy(customizationService: any): CustomizationUrlPolicy { + const prefixes = + customizationService?.extensionManager?.appConfig?.[CUSTOMIZATION_URL_PREFIXES_KEY]; + if (prefixes && typeof prefixes === 'object') { + return { prefixes }; + } + return customizationUrlDefaults; +} + +export { + CUSTOMIZATION_URL_PREFIXES_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 000000000..49b74d8b1 --- /dev/null +++ b/platform/core/src/services/CustomizationService/customizationUrlDefaults.ts @@ -0,0 +1,42 @@ +/** + * Policy for the `?customization=` URL query parameter. + * + * The effective policy comes from the **app config** property + * `customizationUrlPrefixes` (NOT a customization — a customization must never + * be able to widen its own allowlist). When that property is absent the policy + * has no prefixes, so the feature is **off by default**: any `?customization=` + * value is rejected because its prefix is not configured. + * + * Shape: + * - prefixes: map of prefix -> base URL used to resolve a customization value + * to a fetched `.jsonc` data file. The `default` prefix (no slashes) is used + * for values with no leading slash; every other prefix must start and end + * with a slash (e.g. `/remote/`) and is matched against the leading + * `/segment/` of the value. + * + * Example app config: + * window.config = { + * customizationUrlPrefixes: { + * default: './customizations/', + * '/remote/': 'https://cdn.example.com/ohif-customizations/', + * }, + * }; + */ +export interface CustomizationUrlPolicy { + prefixes: Record; +} + +/** + * App config property name holding the prefix allowlist. Read directly off + * `appConfig` — deliberately not a customization key. + */ +export const CUSTOMIZATION_URL_PREFIXES_KEY = 'customizationUrlPrefixes'; + +export const DEFAULT_PREFIX = 'default'; + +/** Off by default: no prefixes are allowed until the app config configures them. */ +export const customizationUrlDefaults: CustomizationUrlPolicy = { + prefixes: {}, +}; + +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 000000000..4c43f7c5f --- /dev/null +++ b/platform/core/src/services/CustomizationService/customizationUrlTypes.ts @@ -0,0 +1,56 @@ +import type { CustomizationUrlPolicy } from './customizationUrlDefaults'; +import type { ValidatedCustomization } from './validate'; + +/** + * The value accepted by any single customization phase block. It is whatever + * {@link CustomizationService.setCustomizations} accepts: + * - an object map of `customizationId -> customization` (with optional + * immutability-helper commands like `$set` / `$apply` / `$splice`), or + * - an array that mixes string references (extension module ids resolved via + * the ExtensionManager) and inline object maps. + */ +export type CustomizationPhaseInput = string[] | Record; + +/** + * Mode-phase customizations, keyed by mode. The reserved `*` key (see + * `GENERAL_MODE_KEY`) is the "general" block applied to every mode FIRST; any + * other key is matched against the entered mode's `id` / `routeName` and applied + * AFTER the general block, so a single mode can override the general values. + */ +export type ModePhaseCustomizations = Record; + +/** + * Phase-tagged customization payload. The same shape is used by: + * - URL-loaded customization modules (`?customization=` JSONC files), and + * - the `appConfig.customizationService` structured config. + * + * Each block is applied at a distinct point in the app lifecycle so ordering is + * deterministic regardless of when extensions / modes load: + * - `requires` — other URL customization modules to resolve first. + * - `bootstrap` — applied (Global scope) BEFORE extensions register. + * - `global` — applied (Global scope) AFTER extensions register / init. + * - `mode` — applied (Mode scope) on every mode enter; general first, + * then the entered mode's specific block. + */ +export interface PhasedCustomizationConfig { + requires?: string | string[]; + bootstrap?: CustomizationPhaseInput; + global?: CustomizationPhaseInput; + mode?: ModePhaseCustomizations; +} + +export interface CustomizationModule extends PhasedCustomizationConfig { + [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 000000000..b1a044399 --- /dev/null +++ b/platform/core/src/services/CustomizationService/getUrlCustomizationModulePayload.ts @@ -0,0 +1,41 @@ +import type { CustomizationModule, PhasedCustomizationConfig } from './customizationUrlTypes'; + +/** + * Extracts the phase-tagged payload from a loaded customization module. + * + * A module is considered to carry a payload when it declares any of the + * lifecycle phase blocks (`bootstrap` / `global` / `mode`) or a `requires` + * edge. Returns `null` when none are present so callers can warn/skip a module + * that does nothing. + */ +export function getUrlCustomizationModulePayload( + module: CustomizationModule | null | undefined +): PhasedCustomizationConfig | null { + if (!module || typeof module !== 'object') { + return null; + } + const hasBootstrap = isPhaseInput(module.bootstrap); + const hasGlobal = isPhaseInput(module.global); + const hasMode = module.mode && typeof module.mode === 'object' && !Array.isArray(module.mode); + const hasRequires = + typeof module.requires === 'string' || + (Array.isArray(module.requires) && module.requires.length > 0); + + if (!hasBootstrap && !hasGlobal && !hasMode && !hasRequires) { + return null; + } + return { + ...(hasBootstrap ? { bootstrap: module.bootstrap } : {}), + ...(hasGlobal ? { global: module.global } : {}), + ...(hasMode ? { mode: module.mode } : {}), + ...(hasRequires ? { requires: module.requires } : {}), + }; +} + +/** A phase block is either an object map or an array of references. */ +function isPhaseInput(value: unknown): boolean { + if (Array.isArray(value)) { + return value.length > 0; + } + return Boolean(value) && typeof value === 'object'; +} diff --git a/platform/core/src/services/CustomizationService/index.ts b/platform/core/src/services/CustomizationService/index.ts index b80db0017..a2953a80a 100644 --- a/platform/core/src/services/CustomizationService/index.ts +++ b/platform/core/src/services/CustomizationService/index.ts @@ -1,3 +1,11 @@ import CustomizationService from './CustomizationService'; +export { GENERAL_MODE_KEY, normalizeCustomizationConfig } from './CustomizationService'; +export type { + CustomizationModule, + CustomizationPhaseInput, + ModePhaseCustomizations, + PhasedCustomizationConfig, +} from './customizationUrlTypes'; + export default CustomizationService; 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 000000000..3e37a6499 --- /dev/null +++ b/platform/core/src/services/CustomizationService/resolve.test.ts @@ -0,0 +1,87 @@ +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', () => { + let originalLocation: PropertyDescriptor | undefined; + + beforeAll(() => { + originalLocation = Object.getOwnPropertyDescriptor(window, 'location'); + Object.defineProperty(window, 'location', { + configurable: true, + value: { + origin: 'https://viewer.example.com', + search: '', + }, + }); + }); + + afterAll(() => { + if (originalLocation) { + Object.defineProperty(window, 'location', originalLocation); + } + }); + + 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.jsonc')).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.jsonc' + ); + }); + + 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.jsonc'); + }); + + 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 .jsonc suffix', () => { + const url = resolveCustomizationUrl(req('default', 'veterinary.jsonc'), policy); + expect(url.endsWith('/customizations/veterinary.jsonc')).toBe(true); + }); + + it('throws when an encoded traversal would escape the base after URL normalization', () => { + // `%2e%2e` survives the literal `..` check but the URL parser collapses it to + // `..`, which would otherwise escape the prefix directory. + expect(() => resolveCustomizationUrl(req('default', '%2e%2e/secret'), policy)).toThrow( + /escapes its configured prefix/ + ); + }); + + it('throws when an encoded traversal escapes an absolute (remote) prefix', () => { + expect(() => + resolveCustomizationUrl(req('remote', '%2e%2e/%2e%2e/evil'), policy) + ).toThrow(/escapes its configured prefix/); + }); +}); diff --git a/platform/core/src/services/CustomizationService/resolve.ts b/platform/core/src/services/CustomizationService/resolve.ts new file mode 100644 index 000000000..b22cfea3e --- /dev/null +++ b/platform/core/src/services/CustomizationService/resolve.ts @@ -0,0 +1,84 @@ +import type { CustomizationUrlPolicy } from './customizationUrlDefaults'; +import type { ValidatedCustomization } from './validate'; + +const ABSOLUTE_URL_REGEX = /^([a-z][a-z0-9+.-]*:|\/\/)/i; + +// Used only to parse same-origin/relative paths into a comparable URL. Any +// host works since we compare origin + pathname against the base built the +// same way; it is never part of the returned value. +const PARSE_BASE = 'https://ohif.invalid'; + +function getViewerPublicUrl(): string { + if (typeof window === 'undefined') { + return '/'; + } + return (window as any).PUBLIC_URL || '/'; +} + +/** + * Defense-in-depth containment check on the *final* resolved string. We parse + * both the resolved URL and its configured base directory with the same URL + * parser that `import()` uses — which collapses `.`/`..`/`%2e%2e` path segments + * — and require the result to stay within the base directory. Validation should + * already have rejected traversal, but parsing the final string here guarantees + * a module can never load outside its allowlisted prefix even if an earlier + * check is bypassed. + */ +function assertWithinBase( + finalUrl: string, + baseDirUrl: string, + request: ValidatedCustomization +): void { + let resolved: URL; + let baseDir: URL; + try { + resolved = new URL(finalUrl, PARSE_BASE); + baseDir = new URL(baseDirUrl, PARSE_BASE); + } catch { + throw new Error(`Customization URL could not be parsed for "${request.raw}"`); + } + const baseDirPath = baseDir.pathname.endsWith('/') ? baseDir.pathname : `${baseDir.pathname}/`; + const within = resolved.origin === baseDir.origin && resolved.pathname.startsWith(baseDirPath); + if (!within) { + throw new Error(`Customization "${request.raw}" escapes its configured prefix directory`); + } +} + +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('.jsonc') ? request.name : `${request.name}.jsonc`; + const baseWithSlash = base.endsWith('/') ? base : `${base}/`; + const joined = `${baseWithSlash}${fileName}`; + + if (ABSOLUTE_URL_REGEX.test(base)) { + assertWithinBase(joined, baseWithSlash, request); + return joined; + } + + const origin = + typeof window !== 'undefined' && window.location?.origin ? window.location.origin : ''; + const publicUrl = getViewerPublicUrl(); + const root = publicUrl?.startsWith('/') ? publicUrl : `/${publicUrl || ''}`; + const strip = (value: string): string => + value.startsWith('./') ? value.slice(2) : value.startsWith('/') ? value.slice(1) : value; + const relative = strip(joined); + const relativeBase = strip(baseWithSlash); + const rootWithSlash = root.endsWith('/') ? root : `${root}/`; + const path = `${rootWithSlash}${relative}`; + const baseDirPath = `${rootWithSlash}${relativeBase}`; + + const finalUrl = origin ? `${origin}${path}` : path; + assertWithinBase(finalUrl, origin ? `${origin}${baseDirPath}` : baseDirPath, request); + return finalUrl; +} 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 000000000..5ae667618 --- /dev/null +++ b/platform/core/src/services/CustomizationService/validate.test.ts @@ -0,0 +1,160 @@ +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('keeps multi-segment names under a slash prefix', () => { + const result = validateCustomizationRequests(['/remote/siteA/theme'], policy); + expect(result.rejected).toEqual([]); + expect(result.valid[0].prefix).toBe('/remote/'); + expect(result.valid[0].name).toBe('siteA/theme'); + }); + + 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 an explicit /default/ prefix (the default prefix has no slashes)', () => { + // The `default` prefix is only reachable by values without a leading slash. + // A leading-slash value matches `/default/`, which is not a configured key. + const result = validateCustomizationRequests(['/default/foo'], policy); + expect(result.valid).toEqual([]); + expect(result.rejected[0].reason).toMatch(/unknown prefix/); + }); + + it('rejects unsafe name segments', () => { + const result = validateCustomizationRequests(['foo/./bar'], policy); + expect(result.valid).toEqual([]); + expect(result.rejected[0].reason).toMatch(/unsafe/); + }); + + it('rejects percent-encoded traversal that decodes to ".."', () => { + // URLSearchParams decodes one layer, so a double-encoded `..` arrives here + // as `%2e%2e`; the WHATWG URL parser would normalize that to `..`. + const result = validateCustomizationRequests( + ['foo/%2e%2e/%2e%2e/app-config', '/default/%2E%2E/secret'], + policy + ); + expect(result.valid).toEqual([]); + expect(result.rejected).toHaveLength(2); + for (const r of result.rejected) { + expect(r.reason).toMatch(/traversal/); + } + }); + + it('rejects percent-encoded slashes that would inject extra path segments', () => { + const result = validateCustomizationRequests(['/default/foo%2f..%2fbar'], policy); + expect(result.valid).toEqual([]); + expect(result.rejected[0].reason).toMatch(/traversal/); + }); + + it('rejects encoded full URLs', () => { + const result = validateCustomizationRequests(['https%3A%2F%2Fevil.example.com/x'], policy); + expect(result.valid).toEqual([]); + expect(result.rejected[0].reason).toMatch(/full URLs/); + }); + + it('rejects malformed percent-encoding', () => { + const result = validateCustomizationRequests(['/default/foo%zz', '/default/bar%'], policy); + expect(result.valid).toEqual([]); + expect(result.rejected).toHaveLength(2); + for (const r of result.rejected) { + expect(r.reason).toMatch(/percent-encoding/); + } + }); + }); +}); diff --git a/platform/core/src/services/CustomizationService/validate.ts b/platform/core/src/services/CustomizationService/validate.ts new file mode 100644 index 000000000..d5a09ca9c --- /dev/null +++ b/platform/core/src/services/CustomizationService/validate.ts @@ -0,0 +1,177 @@ +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; + +/** + * Fully percent-decode a customization value so validation runs against the + * string the URL parser (used by `import()`) ultimately resolves — not the + * escaped form. This is what blocks encoded traversal such as `%2e%2e` + * (decodes to `..`) or `%2f` (decodes to `/`): the WHATWG URL parser treats + * `%2e%2e` as a `..` path segment and would otherwise let a value escape the + * configured prefix directory. + * + * Returns the stable decoded string, or `null` when the value is malformed + * (invalid escape), still contains a literal `%` after decoding, or nests + * encoding too deeply to settle. Legitimate customization names are plain + * identifiers/paths and never need percent-encoding, so rejecting these is safe. + */ +export function fullyDecodeValue(value: string): string | null { + let current = value; + for (let i = 0; i < 5; i++) { + if (!current.includes('%')) { + return current; + } + let decoded: string; + try { + decoded = decodeURIComponent(current); + } catch { + return null; + } + if (decoded === current) { + // Stable but still contains a literal `%`; treat as unsafe. + return null; + } + current = decoded; + } + return null; +} + +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; +} + +/** + * Splits a customization value into its prefix and name. + * + * - A value with **no leading slash** uses the special `default` prefix and the + * whole value is the name (e.g. `abc/def` -> prefix `default`, name `abc/def`). + * - A value with a **leading slash** is matched against its first `/segment/`, + * which becomes the prefix (slashes included), and the remainder is the name + * (e.g. `/remote/def` -> prefix `/remote/`, name `def`). This is why every + * prefix other than `default` must start and end with a slash. + * + * Returns `null` when there is no usable name part. + */ +export function splitPrefixAndName( + value: string +): { prefix: string; name: string } | null { + if (!value) { + return null; + } + if (!value.startsWith('/')) { + return { prefix: DEFAULT_PREFIX, name: value }; + } + const rest = value.slice(1); + const slashIdx = rest.indexOf('/'); + if (slashIdx <= 0) { + return null; + } + const segment = rest.slice(0, slashIdx); + const name = rest.slice(slashIdx + 1); + if (!segment || !name) { + return null; + } + return { prefix: `/${segment}/`, name }; +} + +function buildNormalized(prefix: string, name: string): string { + return prefix === DEFAULT_PREFIX ? `/${DEFAULT_PREFIX}/${name}` : `${prefix}${name}`; +} + +export function normalizeCustomizationValue(value: string): string | null { + if (!value) { + return null; + } + const split = splitPrefixAndName(value.trim()); + if (!split) { + return null; + } + return buildNormalized(split.prefix, split.name); +} + +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) { + // Validate against the fully decoded value so percent-encoded traversal + // (e.g. `%2e%2e`, `%2f`) cannot slip past the checks below and escape the + // configured prefix once the URL parser normalizes the import path. + const decoded = fullyDecodeValue(raw); + if (decoded === null) { + result.rejected.push({ raw, reason: 'contains malformed or unsupported percent-encoding' }); + continue; + } + if (decoded.includes('..')) { + result.rejected.push({ raw, reason: 'contains ".." traversal segment' }); + continue; + } + if (FULL_URL_REGEX.test(decoded)) { + result.rejected.push({ raw, reason: 'full URLs are not permitted' }); + continue; + } + + const split = splitPrefixAndName(decoded.trim()); + if (!split) { + result.rejected.push({ raw, reason: 'could not be split into a prefix and name' }); + continue; + } + const { prefix, name } = split; + + 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: buildNormalized(prefix, name), prefix, name }); + } + + return result; +} diff --git a/platform/core/src/services/MultiMonitorService.ts b/platform/core/src/services/MultiMonitorService.ts index 810ec5ee3..735f384b7 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 8ddd79461..4c8149d4b 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/types/AppTypes.ts b/platform/core/src/types/AppTypes.ts index dca0919ff..7a5817d04 100644 --- a/platform/core/src/types/AppTypes.ts +++ b/platform/core/src/types/AppTypes.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-namespace */ import HangingProtocolServiceType from '../services/HangingProtocolService'; import CustomizationServiceType from '../services/CustomizationService'; +import type { PhasedCustomizationConfig } from '../services/CustomizationService'; import MeasurementServiceType from '../services/MeasurementService'; import ViewportGridServiceType from '../services/ViewportGridService'; import ToolbarServiceType from '../services/ToolBarService'; @@ -85,7 +86,29 @@ declare global { export interface Config { studyBrowserMode?: 'all' | 'primary'; routerBasename?: string; - customizationService?: CustomizationServiceType; + /** + * Startup customizations. Two forms are accepted: + * + * - **Legacy**: an array of references / object map applied to the Global + * scope during `init()` (e.g. + * `['@ohif/extension-default.customizationModule.datasources']`). + * - **Phase-tagged** ({@link PhasedCustomizationConfig}): an object with + * any of `requires` / `bootstrap` / `global` / `mode`. `requires` + * pulls in URL-style customization data files; `bootstrap` / + * `global` apply (Global scope) before / after extensions register; and + * `mode` applies (Mode scope) per mode on entry — the `*` block to all + * modes first, then a block keyed by the mode id / routeName. + */ + customizationService?: PhasedCustomizationConfig | string[] | Record; + /** + * Allowlist of prefixes for the `?customization=` URL parameter, mapping a + * prefix to a base URL/path. The `default` prefix (no slashes) handles + * values with no leading slash; every other prefix must start and end with + * a slash (e.g. `/remote/`). Intentionally an app-config property and not a + * customization so a URL-loaded customization cannot widen its own + * allowlist. Absent (the default) means `?customization=` is disabled. + */ + customizationUrlPrefixes?: Record; extensions?: string[]; modes?: string[]; experimentalStudyBrowserSort?: boolean; diff --git a/platform/core/src/utils/formatValue.js b/platform/core/src/utils/formatValue.js new file mode 100644 index 000000000..067acc697 --- /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/formatValue.test.js b/platform/core/src/utils/formatValue.test.js new file mode 100644 index 000000000..78e9414e2 --- /dev/null +++ b/platform/core/src/utils/formatValue.test.js @@ -0,0 +1,40 @@ +import formatValue from './formatValue'; + +describe('formatValue', () => { + it('returns null for null or undefined', () => { + expect(formatValue(null)).toBeNull(); + expect(formatValue(undefined)).toBeNull(); + }); + + it('returns strings unchanged, including empty strings', () => { + expect(formatValue('horse')).toBe('horse'); + expect(formatValue('')).toBe(''); + }); + + it('extracts the Alphabetic component of a PersonName object', () => { + expect(formatValue({ Alphabetic: 'Doe^John' })).toBe('Doe^John'); + }); + + it('coerces numbers to strings, including zero', () => { + expect(formatValue(0)).toBe('0'); + expect(formatValue(42)).toBe('42'); + }); + + it('coerces booleans to strings', () => { + expect(formatValue(true)).toBe('true'); + expect(formatValue(false)).toBe('false'); + }); + + it('returns null for plain objects so they never render as [object Object]', () => { + expect(formatValue({})).toBeNull(); + expect(formatValue({ foo: 'bar' })).toBeNull(); + }); + + it('returns null for objects whose Alphabetic is not a string', () => { + expect(formatValue({ Alphabetic: 123 })).toBeNull(); + }); + + it('returns null for arrays', () => { + expect(formatValue(['a', 'b'])).toBeNull(); + }); +}); diff --git a/platform/core/src/utils/index.ts b/platform/core/src/utils/index.ts index 026fd0ce6..6e8a5c567 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'; @@ -73,6 +74,7 @@ const utils = { formatDate, formatTime, formatPN, + formatValue, b64toBlob, urlUtil, imageIdToURI, @@ -117,6 +119,7 @@ export { absoluteUrl, sortBy, formatDate, + formatValue, writeScript, b64toBlob, urlUtil, diff --git a/platform/docs/docs/migration-guide/3p12-to-3p13/customization-url.md b/platform/docs/docs/migration-guide/3p12-to-3p13/customization-url.md new file mode 100644 index 000000000..4fe02b69e --- /dev/null +++ b/platform/docs/docs/migration-guide/3p12-to-3p13/customization-url.md @@ -0,0 +1,203 @@ +--- +sidebar_position: 7 +sidebar_label: Customization URL & config lockdown +title: '?customization= URL parameter and the secure default config' +--- + +# `?customization=` URL parameter and the secure default config + +3.13 adds the ability to load customizations at runtime from the +`?customization=` URL parameter, and as part of that it **locks down the default +configuration**. Both changes can affect existing deployments. + +## `?customization=` is off by default + +A `?customization=` value loads a **JSONC data file** (JSON with comments / +trailing commas) and applies its `global` payload as global customizations. The +file is fetched and parsed as **data — it is never executed**. Executable code +(plugins, modes, extensions) still loads only through `pluginConfig.json`. + +The feature is **off until you configure an allowlist of prefixes** in the app +config. This is an **app-config property, not a customization** — a customization +could itself be loaded from the URL, so it must not be able to widen its own +allowlist. + +```js +window.config = { + customizationUrlPrefixes: { + // The `default` prefix (no slashes) handles values with no leading slash. + default: './customizations/', // ?customization=ctPresets -> ./customizations/ctPresets.jsonc + // Every other prefix MUST start and end with a slash and is matched against + // the leading `/segment/` of the value. + '/remote/': 'https://cdn.example.com/ohif-customizations/', // ?customization=/remote/siteA + }, +}; +``` + +Resolution rules: + +- `?customization=ctPresets` → `default` prefix → `./customizations/ctPresets.jsonc` +- `?customization=/remote/siteA` → `/remote/` prefix → `https://cdn.example.com/ohif-customizations/siteA.jsonc` +- **A value whose prefix is not configured throws and aborts startup** rather than + being silently ignored. With no `customizationUrlPrefixes` configured, *any* + `?customization=` value throws. (Note: because the `default` prefix has no + slashes, an explicit `/default/x` is treated as the `/default/` prefix and is + rejected unless you configure that key — use the bare `x` form instead.) + +If you previously enabled this feature via the `ohif.customizationUrl` +**customization** (an early iteration of this PR), move the `prefixes` map to the +top-level `customizationUrlPrefixes` **app-config** property and drop the +`prefixes` wrapper key: + +```js +// Before (customization — no longer read) +customizationService: [{ 'ohif.customizationUrl': { $set: { prefixes: { default: './customizations/' } } } }], + +// After (app config property) +customizationUrlPrefixes: { default: './customizations/' }, +``` + +## Phase-tagged customizations (lifecycle ordering) + +A customization module — whether loaded from a `?customization=` data file or +declared inline in `appConfig.customizationService` — can tag its payload with +the lifecycle phase it should be applied in. This makes ordering deterministic +regardless of when extensions and modes load, and lets a single source target +the bootstrap, global, and mode-entry time frames at once. + +```jsonc +{ + // Other customization data files to resolve FIRST (depth-first). Each entry is + // itself a `?customization=` value — a customization module name resolved + // through the same `customizationUrlPrefixes` rules — NOT a customization id. + // So `"requiredCustomizationToLoad"` fetches + // ./customizations/requiredCustomizationToLoad.jsonc (via the `default` prefix) + // and applies its phase blocks before this file's. + "requires": ["requiredCustomizationToLoad"], + + // Applied (Global scope) BEFORE extensions register — in place while they init. + "bootstrap": { "someId": { "$set": "value" } }, + + // Applied (Global scope) AFTER extensions register / init — so `$apply`-style + // merges can build on extension-provided defaults. + "global": { "workList.columns": { "$splice": [[1, 0, { "id": "patientBirthDate", "meta": { "label": "Birth Date" } }]] } }, + + // Applied (Mode scope) on EVERY mode enter. The reserved `*` (general) block + // is applied first; a block keyed by the entered mode's id / routeName is + // applied after it, so a single mode can override the general values. + "mode": { + "*": { "someId": { "$set": "all modes" } }, + "viewer": { "someId": { "$set": "viewer only" } } + } +} +``` + +The same shape is accepted by the app config. The legacy array / object form is +still supported and is applied to the Global scope during `init()` exactly as +before: + +```js +window.config = { + // `customizationUrlPrefixes` is a top-level app-config (window.config) property, + // deliberately NOT part of `customizationService`. The `customizationService` + // block below — and any customization loaded from a `?customization=` URL — can + // never read or widen this allowlist, because a URL-loaded customization must + // not be able to grant itself new load locations. It can only be changed here, + // in the global config, which is not itself updatable by any customization. + customizationUrlPrefixes: { default: './customizations/' }, + customizationService: { + requires: ['patientBirthDate'], // resolves ./customizations/patientBirthDate.jsonc + global: [ // mixes string references and inline maps + '@ohif/extension-default.customizationModule.datasources', + { 'workList.variant': 'default' }, + ], + mode: { + '*': { 'someId': { $set: 'all modes' } }, + viewer: { 'someId': { $set: 'viewer only' } }, + }, + }, +}; +``` + +How the phases map onto the boot sequence: + +| Phase | When | Scope | +| --- | --- | --- | +| `requires` | resolved up front (before extensions register) | — (loads other modules) | +| `bootstrap` | before `extensionManager.registerExtensions` | Global | +| `global` | after extensions register + `customizationService.init` | Global | +| `mode` | on each mode enter, after the mode scope is reset | Mode | + +All `?customization=` data files and `requires` are **fetched once, up front** +(well before any mode loads); only the *application* of each phase block is +deferred to its lifecycle point. + +> Note: a `?customization=` data file is JSON and cannot carry render functions. +> The WorkList expands a serializable column spec (an entry with an `id` and +> `meta` but no `accessorFn`/`cell`) into a display-only text column that reads +> `row[id]`, which is how the `patientBirthDate` example above renders. + +## `config/default.js` is now a secure, minimal baseline + +`config/default.js` — what a plain production build with no `APP_CONFIG` emits — +is now deliberately locked down. The most impactful part of this is the **data +source list**, which previously shipped seven sources and now ships **one**. + +### Data sources removed from `config/default.js` + +| `sourceName` | Type | Why it was removed | +| --- | --- | --- | +| `ohif2` | DICOMweb (AWS CloudFront `dd14fa38…`) | Extra read-only demo server; not needed in a baseline. | +| `ohif3` | DICOMweb (AWS CloudFront `d3t6nz73…`) | Extra read-only demo server; not needed in a baseline. | +| `local5000` | DICOMweb (`http://localhost:5000`) | Points at a local dev PACS that does not exist in a real deployment. | +| `orthanc` | DICOMweb (`http://localhost/pacs/dicom-web`) | Points at a local dev PACS that does not exist in a real deployment. | +| `dicomjson` | Runtime `?url=` JSON metadata | Loads metadata from an arbitrary `?url=`; widens the attack surface of a default build. Gate with `dangerouslyAllowedOriginsForAuthenticatedEnvironments` if you re-enable it. | +| `dicomwebproxy` | Runtime `?url=` delegating proxy | Driven by `?url=`; same attack-surface concern as `dicomjson`. | +| `dicomlocal` | Local file (drag-and-drop) | Loads DICOM files from the user's machine; not appropriate for a locked-down baseline. | + +### What remains + +- A single **read-only demo DICOMweb source** (`sourceName: 'ohif'`, + `defaultDataSourceName: 'ohif'`) — replace its `wadoRoot` / `qidoRoot` / + `wadoUriRoot` with your own DICOMweb server. + +### Other lockdowns in `config/default.js` + +- The `multimonitor` layouts were removed. +- `?customization=` is off (no `customizationUrlPrefixes`). +- `dangerouslyUseDynamicConfig` (the `configUrl` query parameter) is off. + +### If you relied on the old `default.js` + +If your deployment relied on `default.js` shipping the local/`?url=` data sources +or the full demo source list, you have two options: + +- **Point the dev/demo configs at it**: copy the `dataSources` entries you need + from `config/dev.js` or `config/netlify.js` into your own config, or +- **Build with a different config**: set `APP_CONFIG` to your own config file at + build time (`APP_CONFIG=config/my-site.js pnpm run build`). + +## New `config/dev.js`; dev server no longer uses `default.js` + +- **`config/dev.js`** (new) is the full-featured local-development config: every + data source enabled and `?customization=` turned on. The dev-server scripts + (`pnpm run dev`, `dev:fast`, `start`) now default to `config/dev.js`. +- **`config/netlify.js`** is the public demo / Netlify deploy config: the full + data source set plus `customizationUrlPrefixes: { default: './customizations/' }`. +- **`config/default.js`** remains the fallback for a real production build + (`pnpm run build` with no `APP_CONFIG`). + +### `APP_CONFIG` is honored, not clobbered + +The default config is selected by the build (`webpack.pwa.js` / `rsbuild.config.ts`), +**not** hard-coded into the npm scripts, so an explicit `APP_CONFIG` always wins: + +- `pnpm run dev` (no `APP_CONFIG`) → `config/dev.js` +- `APP_CONFIG=config/local_orthanc.js pnpm run dev` → **`config/local_orthanc.js`** +- `pnpm run build` (no `APP_CONFIG`) → `config/default.js` +- `APP_CONFIG=config/my-site.js pnpm run build` → **`config/my-site.js`** + +The rule is: **the dev server defaults to `config/dev.js` and a production build +to `config/default.js`, but any explicit `APP_CONFIG` overrides the default.** So +if you maintain your own dev tooling that expected `pnpm run dev` to use +`config/default.js`, just set `APP_CONFIG=config/default.js` explicitly. diff --git a/platform/docs/docs/platform/services/customization-service/customizationService.md b/platform/docs/docs/platform/services/customization-service/customizationService.md index 33b48a255..b459bfda2 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`, plus the `customizationUrlPrefixes` app-config allowlist that drives `?customization=` (off by default) and `requires` behavior, and how URL-loaded files 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 000000000..6680b54e0 --- /dev/null +++ b/platform/docs/docs/platform/services/customization-service/specificCustomizations.md @@ -0,0 +1,234 @@ +--- +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`. + +## `customizationUrlPrefixes` (app config) + +- **Purpose**: Allowlist of prefixes that `?customization=` values may resolve against. The `?customization=` feature is **off until this is configured**. +- **It is an app-config property, not a customization.** Because a customization can itself be loaded from the URL, letting one define prefixes would let it widen its own allowlist — so the allowlist lives on `window.config` directly and is never read from `customizationService`. +- **Prefix model**: + - The special `default` prefix (no slashes) is used for values **without** a leading slash; the whole value is the file name (e.g. `ctAbdomen`, or `siteA/theme`). + - Every other prefix **must start and end with a slash** (e.g. `/remote/`) and is matched against the leading `/segment/` of the value. +- **Off by default**: with no `customizationUrlPrefixes` configured, any `?customization=` value is rejected (see below). + +Example: + +```js +window.config = { + customizationUrlPrefixes: { + default: './customizations/', + '/remote/': 'https://cdn.example.com/ohif-customizations/', + }, +}; +``` + +### Using `?customization=` + +You can pass one or more customization entries in the URL: + +- `?customization=ctAbdomen` → `default` prefix → `./customizations/ctAbdomen.jsonc` +- `?customization=/remote/siteA` → `/remote/` prefix → `https://cdn.example.com/ohif-customizations/siteA.jsonc` +- `?customization=basePack&customization=siteOverrides` + +Each entry is split into a prefix and a name, resolved through `customizationUrlPrefixes`, fetched, parsed as JSONC, and then applied. + +#### Security considerations (`?customization=`) + +A URL-loaded customization is a **JSONC data file** (JSON with comments / trailing commas). It is fetched and parsed as data — it is **never executed** as code. Executable code (plugins, modes, extensions) loads only through `pluginConfig.json`, never from the customization URL path. This makes `?customization=` far lower risk than loading a JavaScript bundle, but the values still change application behavior, so treat the source directories as trusted configuration. + +- **Off until configured, and a hard failure when misused:** With no `customizationUrlPrefixes` set, every `?customization=` value is rejected. A value whose prefix is not on the allowlist **throws and aborts app startup** rather than being silently ignored — so a stray or hostile `?customization=` link on an unconfigured deployment fails loudly instead of partially applying. +- **Allowlisted resolution only:** The loader rejects values that look like full URLs (with a scheme), rejects path traversal (`..`), rejects unknown prefixes, and rejects unsafe name segments. The final fetch URL is always built from your configured `customizationUrlPrefixes` plus a `.jsonc` 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 inject configuration into the app. Prefer HTTPS bases, narrow directories, and static hosting of reviewed files. +- **`requires` chains:** Dependencies declared in a loaded file are resolved with the **same** policy and validation. A trusted root file can still pull in further files 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 file can declare dependencies via `requires` so dependent files load first. + +Example file shape: + +```jsonc +{ + // load these first, then apply this file's `global` payload + "requires": ["basePack", "/remote/sharedTools"], + "global": { + "someCustomizationKey": { + "$set": true + } + } +} +``` + +When this file 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. + +### Example modules + +A URL-loaded file applies its `global` payload as **global customizations** — the same layer as +`window.config`'s `customizationService` entries, but loaded at runtime from `?customization=`. Any +customization key that is read through `customizationService.getCustomization(...)` can therefore be +set this way. The examples below are complete files; drop one under `platform/app/public/customizations/` +(the `default` prefix) and load it with `?customization=`. Because the files are JSONC, you +can keep `//` comments and trailing commas in them. + +The shipped [`veterinaryOverlay.jsonc`](https://github.com/OHIF/Viewers/blob/master/platform/app/public/customizations/veterinaryOverlay.jsonc) +demonstrates a fourth scenario — replacing the viewport overlay layout via `viewportOverlay.topLeft` / +`viewportOverlay.topRight`. + +#### 1. Site-specific window/level presets + +Override the CT presets offered in the window-level menu (key: `cornerstone.windowLevelPresets`). +`$merge` replaces only the `CT` entry, so presets for other modalities (PT, etc.) are kept. + +```jsonc +// platform/app/public/customizations/ctPresets.jsonc -> ?customization=ctPresets +{ + "global": { + "cornerstone.windowLevelPresets": { + "$merge": { + "CT": [ + { "id": "ct-soft-tissue", "description": "Soft tissue", "window": "400", "level": "40" }, + { "id": "ct-lung", "description": "Lung", "window": "1500", "level": "-600" }, + { "id": "ct-angio", "description": "Angio", "window": "600", "level": "300" }, + { "id": "ct-bone", "description": "Bone", "window": "2500", "level": "480" } + ] + } + } + } +} +``` + +#### 2. Predefined measurement labels + +Make the viewer prompt for a label from a fixed list whenever a measurement is created +(key: `measurementLabels`). + +```jsonc +// platform/app/public/customizations/measurementLabels.jsonc -> ?customization=measurementLabels +{ + "global": { + "measurementLabels": { + "$set": { + "labelOnMeasure": true, + "exclusive": true, + "items": [ + { "value": "Head", "label": "Head" }, + { "value": "Shoulder", "label": "Shoulder" }, + { "value": "Knee", "label": "Knee" }, + { "value": "Toe", "label": "Toe" } + ] + } + } + } +} +``` + +#### 3. Add a toolbar button + +The basic and longitudinal viewers register their toolbar as customizations +(`cornerstone.toolbarButtons` — the button definitions, and `cornerstone.toolbarSections` — the +layout that maps each section to a list of button ids). A module can therefore add a button by +`$push`-ing a definition onto `cornerstone.toolbarButtons` and the button's id onto a section. + +The shipped [`smoothRotate.jsonc`](https://github.com/OHIF/Viewers/blob/master/platform/app/public/customizations/smoothRotate.jsonc) +adds a **Smooth Rotate** button to the *More Tools* menu that activates the cornerstone `PlanarRotate` +tool (drag to rotate the image freely, unlike the fixed 90° *Rotate Right*): + +```jsonc +// platform/app/public/customizations/smoothRotate.jsonc -> ?customization=smoothRotate +{ + "global": { + "cornerstone.toolbarButtons": { + "$push": [ + { + "id": "SmoothRotate", + "uiType": "ohif.toolButton", + "props": { + "type": "tool", + "icon": "tool-rotate-right", + "label": "Smooth Rotate", + "tooltip": "Smooth Rotate (drag to rotate the image freely)", + "commands": { + "commandName": "setToolActiveToolbar", + "commandOptions": { "toolName": "PlanarRotate" } + }, + "evaluate": "evaluate.cornerstoneTool" + } + } + ] + }, + "cornerstone.toolbarSections": { + "MoreTools": { "$push": ["SmoothRotate"] } + } + } +} +``` + +> Because the cornerstone extension registers the default toolbar at the *default* scope and a URL +> module applies at the *global* scope, the `$push` **extends** the built-in buttons rather than +> replacing them. The same pattern works for any tool already in the active tool group. + +Each payload value uses [immutability-helper](https://github.com/kolodny/immutability-helper) +commands (`$set`, `$push`, `$merge`, ...) exactly like `window.config` customizations, so a module can +also append to a list or merge into an existing object rather than replacing it wholesale. + +### 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-fetches URL customization files on every route + change. +- **Previously loaded files stay applied:** The service remembers each normalized key for + the lifetime of the page. A later call to the same loader path skips files that were already + fetched, and global payloads from those files 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/pnpm-lock.yaml b/pnpm-lock.yaml index f9da06f55..8e6e1e5b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -91,7 +91,7 @@ importers: version: 7.29.7(@babel/core@7.29.7) '@babel/preset-env': specifier: 7.29.7 - version: 7.29.7(@babel/core@7.29.7) + version: 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) '@babel/preset-react': specifier: 7.29.7 version: 7.29.7(@babel/core@7.29.7) @@ -901,7 +901,7 @@ importers: version: 7.29.7(@babel/core@7.29.7) '@babel/preset-env': specifier: 7.29.7 - version: 7.29.7(@babel/core@7.29.7) + version: 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) '@babel/preset-react': specifier: 7.29.7 version: 7.29.7(@babel/core@7.29.7) @@ -1187,7 +1187,7 @@ importers: version: 7.29.7(@babel/core@7.29.7) '@babel/preset-env': specifier: 7.29.7 - version: 7.29.7(@babel/core@7.29.7) + version: 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) '@babel/preset-react': specifier: 7.29.7 version: 7.29.7(@babel/core@7.29.7) @@ -1297,7 +1297,7 @@ importers: version: 7.29.7(@babel/core@7.29.7) '@babel/preset-env': specifier: 7.29.7 - version: 7.29.7(@babel/core@7.29.7) + version: 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) '@babel/preset-react': specifier: 7.29.7 version: 7.29.7(@babel/core@7.29.7) @@ -1581,6 +1581,9 @@ importers: isomorphic-base64: specifier: 1.0.2 version: 1.0.2 + json5: + specifier: 2.2.3 + version: 2.2.3 lodash.clonedeep: specifier: 4.5.0 version: 4.5.0 @@ -14038,7 +14041,7 @@ snapshots: '@babel/helpers': 7.29.2 '@babel/parser': 7.29.3 '@babel/template': 7.28.6 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.0(supports-color@8.1.1) '@babel/types': 7.29.0 convert-source-map: 2.0.0 debug: 4.4.3(supports-color@8.1.1) @@ -14058,7 +14061,7 @@ snapshots: '@babel/helpers': 7.29.2 '@babel/parser': 7.29.3 '@babel/template': 7.28.6 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.0(supports-color@8.1.1) '@babel/types': 7.29.0 convert-source-map: 2.0.0 debug: 4.4.3(supports-color@8.1.1) @@ -15241,7 +15244,7 @@ snapshots: '@babel/helper-module-transforms': 7.28.6(@babel/core@7.28.0(supports-color@8.1.1)) '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.0(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -15251,17 +15254,17 @@ snapshots: '@babel/helper-module-transforms': 7.28.6(@babel/core@7.28.0) '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.0(supports-color@8.1.1) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-modules-systemjs@7.29.4(@babel/core@7.29.7(supports-color@8.1.1))': + '@babel/plugin-transform-modules-systemjs@7.29.4(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.7(supports-color@8.1.1)) '@babel/helper-plugin-utils': 7.28.6 '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.0(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -16026,7 +16029,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/preset-env@7.29.7(@babel/core@7.29.7)': + '@babel/preset-env@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: '@babel/compat-data': 7.29.7 '@babel/core': 7.29.7(supports-color@8.1.1) @@ -16068,7 +16071,7 @@ snapshots: '@babel/plugin-transform-member-expression-literals': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-modules-amd': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-modules-systemjs': 7.29.4(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-modules-systemjs': 7.29.4(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) '@babel/plugin-transform-modules-umd': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-named-capturing-groups-regex': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-new-target': 7.29.7(@babel/core@7.29.7) @@ -16218,7 +16221,7 @@ snapshots: '@babel/parser': 7.29.7 '@babel/types': 7.29.7 - '@babel/traverse@7.29.0': + '@babel/traverse@7.29.0(supports-color@8.1.1)': dependencies: '@babel/code-frame': 7.29.0 '@babel/generator': 7.29.7 @@ -16880,7 +16883,7 @@ snapshots: '@babel/generator': 7.29.7 '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.29.7) '@babel/plugin-transform-runtime': 7.29.7(@babel/core@7.29.7) - '@babel/preset-env': 7.29.7(@babel/core@7.29.7) + '@babel/preset-env': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) '@babel/preset-react': 7.29.7(@babel/core@7.29.7) '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7) '@babel/runtime': 7.29.7 @@ -17411,7 +17414,7 @@ snapshots: '@docusaurus/plugin-pwa@3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.13(@swc/helpers@0.5.21))(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@swc/helpers@0.5.21))(@docusaurus/plugin-content-docs@3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.13(@swc/helpers@0.5.21))(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@swc/helpers@0.5.21))(@mdx-js/react@3.0.1(@types/react@18.3.23)(react@18.3.1))(@rspack/core@1.7.11(@swc/helpers@0.5.21))(@swc/core@1.15.13(@swc/helpers@0.5.21))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4))(@mdx-js/react@3.0.1(@types/react@18.3.23)(react@18.3.1))(@rspack/core@1.7.11(@swc/helpers@0.5.21))(@swc/core@1.15.13(@swc/helpers@0.5.21))(@types/babel__core@7.20.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4)': dependencies: '@babel/core': 7.29.7(supports-color@8.1.1) - '@babel/preset-env': 7.29.7(@babel/core@7.29.7) + '@babel/preset-env': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) '@docusaurus/bundler': 3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.13(@swc/helpers@0.5.21))(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@swc/helpers@0.5.21))(@rspack/core@1.7.11(@swc/helpers@0.5.21))(@swc/core@1.15.13(@swc/helpers@0.5.21))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) '@docusaurus/core': 3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.13(@swc/helpers@0.5.21))(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@swc/helpers@0.5.21))(@mdx-js/react@3.0.1(@types/react@18.3.23)(react@18.3.1))(@rspack/core@1.7.11(@swc/helpers@0.5.21))(@swc/core@1.15.13(@swc/helpers@0.5.21))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.4) '@docusaurus/logger': 3.10.1 @@ -18430,7 +18433,7 @@ snapshots: '@kitware/vtk.js@35.5.3(@babel/preset-env@7.29.7(@babel/core@7.29.7))(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0)': dependencies: - '@babel/preset-env': 7.29.7(@babel/core@7.29.7) + '@babel/preset-env': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) '@types/webxr': 0.5.24 autoprefixer: 10.4.27(postcss@8.5.14) commander: 8.3.0 @@ -29546,7 +29549,7 @@ snapshots: dependencies: '@apideck/better-ajv-errors': 0.3.6(ajv@8.20.0) '@babel/core': 7.29.7(supports-color@8.1.1) - '@babel/preset-env': 7.29.7(@babel/core@7.29.7) + '@babel/preset-env': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) '@babel/runtime': 7.29.7 '@rollup/plugin-babel': 5.3.1(@babel/core@7.29.7)(@types/babel__core@7.20.5)(rollup@2.80.0) '@rollup/plugin-node-resolve': 15.3.1(rollup@2.80.0) diff --git a/rsbuild.config.ts b/rsbuild.config.ts index 1bae8654a..d99ac45e2 100644 --- a/rsbuild.config.ts +++ b/rsbuild.config.ts @@ -13,7 +13,9 @@ const DIST_DIR = path.resolve(__dirname, './platform/app/dist'); const PUBLIC_DIR = path.resolve(__dirname, './platform/app/public'); // Environment variables (similar to webpack.pwa.js) -const APP_CONFIG = process.env.APP_CONFIG || 'config/default.js'; +// rsbuild is used only by the dev server (`dev:fast`), so default to the +// full-featured `config/dev.js` while still honoring an explicit APP_CONFIG. +const APP_CONFIG = process.env.APP_CONFIG || 'config/dev.js'; const PUBLIC_URL = process.env.PUBLIC_URL || '/'; // Add these constants diff --git a/tests/Customization.spec.ts b/tests/Customization.spec.ts new file mode 100644 index 000000000..c9c8c4994 --- /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 2d2e60008..dca3fc884 100644 --- a/tests/utils/index.ts +++ b/tests/utils/index.ts @@ -1,5 +1,8 @@ -import { visitStudy } from './visitStudy'; -import { addOHIFConfiguration, addOHIFGlobalCustomizations } from './OHIFConfiguration'; +import { visitStudy, visitStudyOptions } from './visitStudy'; +import { + addOHIFConfiguration, + addOHIFGlobalCustomizations, +} from './OHIFConfiguration'; import { checkForScreenshot } from './checkForScreenshot'; import { screenShotPaths } from './screenShotPaths'; import { @@ -36,6 +39,7 @@ import { export { visitStudy, + visitStudyOptions, addOHIFConfiguration, addOHIFGlobalCustomizations, checkForScreenshot, diff --git a/tests/utils/visitStudy.ts b/tests/utils/visitStudy.ts index 20732c65f..96a69a89c 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,44 @@ 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}`); + // studyInstanceUID may itself carry extra query params appended with `&` + // (e.g. `&hangingprotocolid=mpr`), so concatenate it raw rather than + // running it through URLSearchParams, which would percent-encode the `&`/`=` + // and collapse everything into a single invalid StudyInstanceUIDs value. + let url = `/${mode}/${resolvedDatasources}?StudyInstanceUIDs=${studyInstanceUID}`; + if (customization) { + url += `&customization=${encodeURIComponent(customization)}`; + } + + await page.goto(url); 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, + }); }