Skip to content

feat: Add customization URL parameter#5992

Open
wayfarer3130 wants to merge 11 commits into
masterfrom
feat/customization-url-parameter
Open

feat: Add customization URL parameter#5992
wayfarer3130 wants to merge 11 commits into
masterfrom
feat/customization-url-parameter

Conversation

@wayfarer3130
Copy link
Copy Markdown
Contributor

@wayfarer3130 wayfarer3130 commented May 4, 2026

Context

In order to allow custom versions of OHIF to be defined/added without having to rebuild OHIF, it is necessary to have a customization framework that can load dynamic modules. This has been added as a customization= parameter.

Changes & Results

Added a customization handler for the customization= parameter
Added a requires= export in the loaded global customizations to allow customizations to depend on other ones, eg veterinary depends on veterinaryOverlay
Add an example customization to test with, the start of a veterinary example.

Testing

Open the horse example with customization=veterinary in the URL
You should see additional overlays added, without having to rebuild.

Checklist

PR

  • [] My Pull Request title is descriptive, accurate and follows the
    semantic-release format and guidelines.

Code

  • [] My code has been well-documented (function documentation, inline comments,
    etc.)

Public Documentation Updates

  • [] The documentation page has been updated as necessary for any public API
    additions or removals.

Tested Environment

  • [] OS:
  • [] Node version:
  • [] Browser:

Greptile Summary

This PR adds a ?customization= URL query parameter that dynamically loads JavaScript customization modules at runtime without rebuilding OHIF, including depth-first dependency resolution via requires, per-page-session deduplication, and a veterinary overlay example.

  • New requires() / applyWindowUrlCustomizations() API (CustomizationService.ts): validates, resolves, and imports URL-based customization modules in dependency order; extension default modules are deduplicated across init() calls via _extensionCustomizationModuleApplied to avoid repeated console warnings.
  • preserveQueryParameters refactor (preserveQueryParameters.ts, WorkList.tsx): all preserved keys are now stored as arrays and qs.stringify gains arrayFormat: 'repeat' so URLs remain well-formed; a previously-flagged duplicate-key issue when the service returns keys that overlap with the built-in preserveKeys list still needs a deduplication step.
  • formatValue utility (formatValue.js, overlays): prevents [object Object] rendering of complex DICOM attribute values (PN objects, unknown types) by returning null for unrecognized inputs, improving overlay display correctness.

Confidence Score: 4/5

The core URL-loading pipeline is well-tested and safe for the happy path; the outstanding deduplication bug in getPreserveKeys means overlapping custom keys can be doubled in worklist navigation URLs.

The getPreserveKeys function concatenates the built-in preserveKeys array with whatever the service returns without deduplicating overlapping entries, causing URLSearchParams.append to fire twice for overlapping keys. The validation, resolve, import, and dependency-ordering logic is solid and covered by unit and integration tests.

platform/app/src/utils/preserveQueryParameters.ts needs a deduplication pass in getPreserveKeys; platform/core/src/services/CustomizationService/resolve.ts warrants a guard or note for absolute PUBLIC_URL deployments.

Important Files Changed

Filename Overview
platform/core/src/services/CustomizationService/CustomizationService.ts Core file — adds ~400 lines for URL customization loading, deduplication of extension module merging, and the public requires() / applyWindowUrlCustomizations() API. Several previously-flagged concerns remain open; the new code is otherwise well-structured.
platform/core/src/services/CustomizationService/resolve.ts New file — resolves a validated prefix/name pair to a fully-qualified URL. Correctly handles relative, absolute-path, and remote base prefixes, verified by the new resolve.test.ts suite.
platform/app/src/utils/preserveQueryParameters.ts Refactored to preserve multi-value keys and accept customizationService for extensible key lists. A previously-flagged deduplication bug remains when the service returns keys overlapping the built-in preserveKeys list.
platform/app/src/appInit.js Moves customizationService.init() here after extensions are registered and adds applyWindowUrlCustomizations() to layer URL customizations on top.
platform/core/src/utils/formatValue.js New utility — safely formats DICOM attribute values to a displayable string; returns null for unrecognized types, preventing [object Object] rendering in overlays.
platform/app/src/routes/WorkList/WorkList.tsx Passes customizationService to preserveQueryStrings / preserveQueryParameters and adds arrayFormat: repeat to qs.stringify to keep URLs well-formed.
platform/core/src/services/CustomizationService/validate.ts New file — parses and validates ?customization= query values: rejects full URLs, .. traversal, unknown prefixes, and unsafe name segments.
platform/app/src/App.tsx Removes the duplicate customizationService.init(extensionManager) call that ran at every React render of App.
platform/app/public/customizations/veterinaryOverlay.js Example URL-loaded customization module overriding viewport overlay slots with veterinary-specific items.
tests/Customization.spec.ts New e2e test asserting that ?customization=veterinaryOverlay adds overlay items. Hardcodes a specific study UID that must exist in the test environment.

Comments Outside Diff (1)

  1. platform/core/src/services/CustomizationService/CustomizationService.ts, line 789-799 (link)

    P2 URL customization modules with only mode-scoped entries are silently swallowed

    _applyLoadedUrlCustomizationModules only applies payload.global. If an author writes a URL customization module that contains no global key, the module is imported and validated successfully but nothing is written to the service — with no warning. At a minimum a diagnostic should be logged when payload.global is absent so misconfigured modules are not silently ignored.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: platform/core/src/services/CustomizationService/CustomizationService.ts
    Line: 789-799
    
    Comment:
    **URL customization modules with only mode-scoped entries are silently swallowed**
    
    `_applyLoadedUrlCustomizationModules` only applies `payload.global`. If an author writes a URL customization module that contains no `global` key, the module is imported and validated successfully but nothing is written to the service — with no warning. At a minimum a diagnostic should be logged when `payload.global` is absent so misconfigured modules are not silently ignored.
    
    How can I resolve this? If you propose a fix, please make it concise.
  2. platform/core/src/services/CustomizationService/CustomizationService.ts, line 654-668 (link)

    P2 _collectUrlDependencyFromValue will attempt to URL-load any non-ohif.* customization field reference

    When a loaded module contains entries like { customization: 'corn.overlayItem' } (referencing a cornerstone customization type), _urlDependencyToRequest only skips names matching ^ohif\.[…]$. All other dot-namespaced extension customization identifiers pass validation (normalized to /default/corn.overlayItem) and trigger a network import attempt. In non-strict mode this fails silently with a warning and a wasted request for every such reference.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: platform/core/src/services/CustomizationService/CustomizationService.ts
    Line: 654-668
    
    Comment:
    **`_collectUrlDependencyFromValue` will attempt to URL-load any non-`ohif.*` `customization` field reference**
    
    When a loaded module contains entries like `{ customization: 'corn.overlayItem' }` (referencing a cornerstone customization type), `_urlDependencyToRequest` only skips names matching `^ohif\.[…]$`. All other dot-namespaced extension customization identifiers pass validation (normalized to `/default/corn.overlayItem`) and trigger a network import attempt. In non-strict mode this fails silently with a warning and a wasted request for every such reference.
    
    How can I resolve this? If you propose a fix, please make it concise.
  3. platform/core/src/services/CustomizationService/CustomizationService.ts, line 510-522 (link)

    P2 URL customizations are loaded once at bootstrap and never refreshed on SPA navigation

    applyWindowUrlCustomizations is called once in appInit.js. Because _urlCustomizationLoaded is never cleared, if the user navigates to a URL with a different ?customization= parameter during client-side routing, previously-loaded customizations remain applied and new ones are not picked up. This may be intentional, but it is a non-obvious behavioral limit worth documenting — especially since the companion preserveQueryParameters change explicitly preserves the customization key across navigations.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: platform/core/src/services/CustomizationService/CustomizationService.ts
    Line: 510-522
    
    Comment:
    **URL customizations are loaded once at bootstrap and never refreshed on SPA navigation**
    
    `applyWindowUrlCustomizations` is called once in `appInit.js`. Because `_urlCustomizationLoaded` is never cleared, if the user navigates to a URL with a different `?customization=` parameter during client-side routing, previously-loaded customizations remain applied and new ones are not picked up. This may be intentional, but it is a non-obvious behavioral limit worth documenting — especially since the companion `preserveQueryParameters` change explicitly preserves the `customization` key across navigations.
    
    How can I resolve this? If you propose a fix, please make it concise.
  4. platform/app/src/routes/WorkList/WorkList.tsx, line 203-208 (link)

    P1 preserveQueryStrings now returns arrays; qs.stringify without arrayFormat will produce broken URLs

    preserveQueryStrings now stores every preserved key as an array (e.g., { configUrl: ['foo.js'] }), even when there is only one value. qs.stringify with default options uses arrayFormat: 'indices', serialising that as configUrl[0]=foo.js instead of configUrl=foo.js. Any consumer — the DICOM viewer, mode entry, or external tools — that parses configUrl from the worklist navigation URL as a plain string key will either get nothing or a key named configUrl[0]. Single-value preserved keys (configUrl, multimonitor, screenNumber, hangingProtocolId) were always strings before this PR; making them arrays without also specifying { arrayFormat: 'repeat' } (or equivalent) on every qs.stringify call is a regression.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: platform/app/src/routes/WorkList/WorkList.tsx
    Line: 203-208
    
    Comment:
    **`preserveQueryStrings` now returns arrays; `qs.stringify` without `arrayFormat` will produce broken URLs**
    
    `preserveQueryStrings` now stores every preserved key as an array (e.g., `{ configUrl: ['foo.js'] }`), even when there is only one value. `qs.stringify` with default options uses `arrayFormat: 'indices'`, serialising that as `configUrl[0]=foo.js` instead of `configUrl=foo.js`. Any consumer — the DICOM viewer, mode entry, or external tools — that parses `configUrl` from the worklist navigation URL as a plain string key will either get nothing or a key named `configUrl[0]`. Single-value preserved keys (`configUrl`, `multimonitor`, `screenNumber`, `hangingProtocolId`) were always strings before this PR; making them arrays without also specifying `{ arrayFormat: 'repeat' }` (or equivalent) on every `qs.stringify` call is a regression.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
platform/core/src/services/CustomizationService/CustomizationService.ts:502-510
**Non-`ohif.*` extension IDs in `requires` arrays are treated as URL module names**

`_urlDependencyToRequest` only skips names matching `^ohif\.[a-zA-Z0-9._-]+$`. Any other dotted name (e.g., `corn.overlayItem`, `@ohif/extension-cornerstone.someKey`) that appears in a module's `requires` array will pass the filter, be normalized to `/default/corn.overlayItem`, and trigger a real network request. In non-strict mode this silently emits a warning and wastes a fetch; in strict mode it rejects the entire load. Authors who mistakenly put an extension customization reference (rather than a URL module name) in their `requires` list will get a confusing 404 rather than a clear "not a loadable module" error. Consider broadening the skip regex to cover any name containing a `.` that does not look like a path segment, or document the restriction more prominently.

### Issue 2 of 2
platform/core/src/services/CustomizationService/resolve.ts:8-12
**Absolute-URL `PUBLIC_URL` produces a malformed path**

`getViewerPublicUrl` returns `window.PUBLIC_URL` as-is. If the deployment sets `PUBLIC_URL` to an absolute CDN origin like `https://cdn.example.com/viewer/`, the subsequent check `publicUrl?.startsWith('/')` is `false`, so `root` becomes `'/https://cdn.example.com/viewer/'`. Any relative prefix (e.g., `./customizations/`) then resolves to `https://viewer.example.com/https://cdn.example.com/viewer/customizations/foo.js`, which will 404 silently in non-strict mode. Deployments using an absolute CDN `publicPath` must configure an absolute policy prefix; documenting this requirement (or detecting the absolute-URL case and throwing early) would prevent a silent failure.

Reviews (8): Last reviewed commit: "fix: PR comments" | Re-trigger Greptile

@netlify
Copy link
Copy Markdown

netlify Bot commented May 4, 2026

Deploy Preview for ohif-dev ready!

Name Link
🔨 Latest commit 393fd48
🔍 Latest deploy log https://app.netlify.com/projects/ohif-dev/deploys/6a04b5422f37550008687fb3
😎 Deploy Preview https://deploy-preview-5992--ohif-dev.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

Comment thread platform/core/src/services/CustomizationService/resolve.test.ts Dismissed
Comment thread platform/core/src/services/CustomizationService/resolve.test.ts Fixed
@cypress
Copy link
Copy Markdown

cypress Bot commented May 4, 2026

Viewers    Run #6260

Run Properties:  status check passed Passed #6260  •  git commit 393fd4835d: fix: PR comments
Project Viewers
Branch Review feat/customization-url-parameter
Run status status check passed Passed #6260
Run duration 02m 18s
Commit git commit 393fd4835d: fix: PR comments
Committer Bill Wallace
View all properties for this run ↗︎

Test results
Tests that failed  Failures 0
Tests that were flaky  Flaky 0
Tests that did not run due to a developer annotating a test with .skip  Pending 0
Tests that did not run due to a failure in a mocha hook  Skipped 0
Tests that passed  Passing 37
View all changes introduced in this branch ↗︎

Comment thread platform/core/src/services/CustomizationService/resolve.ts Outdated
Comment thread platform/app/src/utils/preserveQueryParameters.ts
@wayfarer3130 wayfarer3130 requested a review from sedghi May 7, 2026 22:14
Copy link
Copy Markdown
Member

@sedghi sedghi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’m not fully convinced the added value of this PR justifies the new security surface yet. This introduces URL-driven runtime JavaScript loading via ?customization=, which means a shared link can change viewer behavior and, depending on deployment config, potentially load executable code into the same browser context as OHIF. The validation does block obvious arbitrary URLs and path traversal, so this is not an immediate “any URL can execute code” issue. But the security boundary becomes the configured customization prefix and whoever can publish files there. If that directory/CDN is writable by the wrong party, this could become XSS-equivalent: token/session access, DICOM metadata exposure, UI manipulation, report tampering, or authenticated API abuse from the victim’s browser.

My current view is that this level of runtime configurability may be better kept in downstream forks or deployment-specific builds, where the deploying team can own the threat model and hosting controls explicitly. I’m not sure it should become a default upstream capability.

@wayfarer3130
Copy link
Copy Markdown
Contributor Author

I’m not fully convinced the added value of this PR justifies the new security surface yet. This introduces URL-driven runtime JavaScript loading via ?customization=, which means a shared link can change viewer behavior and, depending on deployment config, potentially load executable code into the same browser context as OHIF. The validation does block obvious arbitrary URLs and path traversal, so this is not an immediate “any URL can execute code” issue. But the security boundary becomes the configured customization prefix and whoever can publish files there. If that directory/CDN is writable by the wrong party, this could become XSS-equivalent: token/session access, DICOM metadata exposure, UI manipulation, report tampering, or authenticated API abuse from the victim’s browser.

My current view is that this level of runtime configurability may be better kept in downstream forks or deployment-specific builds, where the deploying team can own the threat model and hosting controls explicitly. I’m not sure it should become a default upstream capability.

If the CDN is writable by the wrong party, it doesn't matter what you do, they can replace the entire OHIF source control. At that point you are completely open.

What about adding a user configuration option to specifically and manually add customization prefixes rather than allowing it to be done via customization? That way we can default to one customization deploy somewhere that we control for the demonstration deployments, making note that is intended for demo purposes only, and same-host http /customization/ prefix path options so that we can deploy with a fixed deployment?

It is clear the advisory board wants SOMETHING that allows dynamic loading. I agree it needs to be controlled, but it also has to be external to the build process of OHIF, otherwise we will never meet the goals of allowing OHIF to be customized by non-developers.

Some other things we could consider:

  1. Allow JSON customizations from controlled locations for full setup
  2. Allow JS customizations to be loaded from given path names, but require a SHA sum to match. The list of valid SHA sums could come from a fixed list of valid/verified items
  3. Allow users to "add" new locations for SHA sum validators

That value of this is clearly extremely high given how many people on the meeting wanted something better. The only question becomes how to make it reasonably safe. It isn't fully safe, but neither is OHIF in the current configuration.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants