fix(dicom-pdf): enhance PDF loading with authentication support

- move authenticated rendered media loading into the datasource\n- route DICOM video display sets through Cornerstone video viewports\n- add a 3.12 to 3.13 migration note for the removed DICOM-video viewport namespace
This commit is contained in:
Alireza 2026-07-06 09:35:26 -04:00 committed by GitHub
parent 2800f82e3c
commit e0f42d4f1d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 446 additions and 130 deletions

View File

@ -18,6 +18,7 @@ import getDirectURL from '../utils/getDirectURL';
import { fixBulkDataURI } from './utils/fixBulkDataURI';
import { HeadersInterface } from '@ohif/core/src/types/RequestHeaders';
import { getGetThumbnailSrc, ThumbnailContext } from './retrieveThumbnail';
import { getRenderedURL } from './retrieveRendered';
const { DicomMetaDictionary, DicomDict } = dcmjs.data;
@ -108,7 +109,6 @@ export type BulkDataURIConfig = {
relativeResolution?: 'studies' | 'series';
};
/**
* The header options are the options passed into the generateWadoHeader
* command. This takes an extensible set of attributes to allow future enhancements.
@ -291,6 +291,14 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) {
params
);
},
renderedURL: (params, options) => {
return getRenderedURL({
config: dicomWebConfig,
getAuthorizationHeader,
retrieve: implementation.retrieve,
userAuthenticationService,
})(params, options);
},
/**
* Provide direct access to the dicom web client for certain use cases
* where the dicom web client is used by an external library such as the

View File

@ -0,0 +1,147 @@
import { fetchRenderedURL, getRenderedURL, isTrustedWadoURL } from './retrieveRendered';
describe('retrieveRendered', () => {
const originalFetch = global.fetch;
const originalCreateObjectURL = URL.createObjectURL;
const originalRevokeObjectURL = URL.revokeObjectURL;
beforeEach(() => {
global.fetch = jest.fn();
URL.createObjectURL = jest.fn(() => 'blob:rendered') as jest.Mock;
URL.revokeObjectURL = jest.fn() as jest.Mock;
jest.spyOn(console, 'warn').mockImplementation(() => {});
});
afterEach(() => {
jest.restoreAllMocks();
global.fetch = originalFetch;
URL.createObjectURL = originalCreateObjectURL;
URL.revokeObjectURL = originalRevokeObjectURL;
});
it('returns null when there is no resolved URL', async () => {
const result = await fetchRenderedURL({
url: undefined,
wadoRoot: 'https://example.com/dicomweb',
headers: { Authorization: 'Bearer token' },
});
expect(result).toEqual({ url: null });
expect(global.fetch).not.toHaveBeenCalled();
});
it('returns the direct URL without fetching when there is no authorization header', async () => {
const result = await fetchRenderedURL({
url: 'https://example.com/dicomweb/rendered',
wadoRoot: 'https://example.com/dicomweb',
headers: {},
});
expect(result).toEqual({ url: 'https://example.com/dicomweb/rendered' });
expect(global.fetch).not.toHaveBeenCalled();
});
it('does not attach auth headers to untrusted absolute URLs', async () => {
const result = await fetchRenderedURL({
url: 'https://untrusted.example.com/rendered',
wadoRoot: 'https://example.com/dicomweb',
headers: { Authorization: 'Bearer token' },
});
expect(result).toEqual({ url: 'https://untrusted.example.com/rendered' });
expect(global.fetch).not.toHaveBeenCalled();
});
it('fetches trusted URLs with auth and returns a revocable object URL', async () => {
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
blob: jest.fn().mockResolvedValueOnce(new Blob(['pdf'], { type: 'application/pdf' })),
});
const result = await fetchRenderedURL({
url: 'https://example.com/dicomweb/studies/1/rendered',
wadoRoot: 'https://example.com/dicomweb',
headers: { Authorization: 'Bearer token' },
});
expect(global.fetch).toHaveBeenCalledWith(
'https://example.com/dicomweb/studies/1/rendered',
expect.objectContaining({
method: 'GET',
headers: { Authorization: 'Bearer token' },
})
);
expect(result.url).toBe('blob:rendered');
result.revoke?.();
result.revoke?.();
expect(URL.revokeObjectURL).toHaveBeenCalledTimes(1);
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:rendered');
});
it('returns null and triggers unauthenticated handling on 401/403 responses', async () => {
const handleUnauthenticated = jest.fn();
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: false,
status: 401,
});
const result = await fetchRenderedURL({
url: 'https://example.com/dicomweb/studies/1/rendered',
wadoRoot: 'https://example.com/dicomweb',
headers: { Authorization: 'Bearer token' },
userAuthenticationService: { handleUnauthenticated },
});
expect(result).toEqual({ url: null });
expect(handleUnauthenticated).toHaveBeenCalledTimes(1);
});
it('revokes an object URL created after the request was aborted', async () => {
const abortController = new AbortController();
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
blob: jest.fn().mockImplementationOnce(async () => {
abortController.abort();
return new Blob(['pdf'], { type: 'application/pdf' });
}),
});
const result = await fetchRenderedURL({
url: 'https://example.com/dicomweb/studies/1/rendered',
wadoRoot: 'https://example.com/dicomweb',
headers: { Authorization: 'Bearer token' },
signal: abortController.signal,
});
expect(result).toEqual({ url: null });
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:rendered');
});
it('resolves directURL through the datasource retrieve API', async () => {
const renderedURL = getRenderedURL({
config: { wadoRoot: 'https://example.com/dicomweb' },
getAuthorizationHeader: () => ({}),
retrieve: {
directURL: jest.fn().mockResolvedValueOnce('https://example.com/dicomweb/rendered'),
},
});
await expect(renderedURL({ instance: {} })).resolves.toEqual({
url: 'https://example.com/dicomweb/rendered',
});
});
it('trusts URLs that share the configured WADO origin', () => {
expect(
isTrustedWadoURL('/dicomweb/studies/1/rendered', `${window.location.origin}/dicomweb`)
).toBe(true);
expect(
isTrustedWadoURL(
'https://other.example.com/dicomweb/studies/1/rendered',
'https://example.com/dicomweb'
)
).toBe(false);
});
});

View File

@ -0,0 +1,141 @@
import { HeadersInterface } from '@ohif/core/src/types/RequestHeaders';
type RetrieveApi = {
directURL: (params: unknown) => unknown;
};
type RenderedURLConfig = {
wadoRoot?: string;
};
type UserAuthenticationService = {
handleUnauthenticated?: () => unknown;
};
type GetRenderedURLDeps = {
config: RenderedURLConfig;
getAuthorizationHeader: () => HeadersInterface;
retrieve: RetrieveApi;
userAuthenticationService?: UserAuthenticationService;
};
type RenderedURLOptions = {
signal?: AbortSignal;
};
type FetchRenderedURLOptions = RenderedURLOptions & {
url?: string | null;
wadoRoot?: string;
headers?: HeadersInterface;
userAuthenticationService?: UserAuthenticationService;
};
export type RenderedURLResult = {
url: string | null;
revoke?: () => void;
};
export function getRenderedURL({
config,
getAuthorizationHeader,
retrieve,
userAuthenticationService,
}: GetRenderedURLDeps) {
return async function renderedURL(
params: unknown,
options: RenderedURLOptions = {}
): Promise<RenderedURLResult> {
const resolvedUrl = (await retrieve.directURL(params)) as string | undefined | null;
return fetchRenderedURL({
url: resolvedUrl,
wadoRoot: config.wadoRoot,
headers: getAuthorizationHeader(),
signal: options.signal,
userAuthenticationService,
});
};
}
export async function fetchRenderedURL({
url,
wadoRoot,
headers,
signal,
userAuthenticationService,
}: FetchRenderedURLOptions): Promise<RenderedURLResult> {
if (!url || signal?.aborted) {
return { url: null };
}
if (!headers?.Authorization || !isTrustedWadoURL(url, wadoRoot)) {
return { url };
}
try {
const response = await fetch(url, {
method: 'GET',
headers: headers as Record<string, string>,
signal,
});
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
userAuthenticationService?.handleUnauthenticated?.();
}
console.warn(`rendered media fetch failed with status ${response.status}`);
return { url: null };
}
const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob);
if (signal?.aborted) {
URL.revokeObjectURL(objectUrl);
return { url: null };
}
return {
url: objectUrl,
revoke: createRevokeOnce(objectUrl),
};
} catch (error) {
if ((error as { name?: string })?.name === 'AbortError') {
return { url: null };
}
console.warn('rendered media fetch failed', error);
return { url: null };
}
}
export function isTrustedWadoURL(url: string, wadoRoot?: string): boolean {
if (!wadoRoot) {
return false;
}
try {
const parsedUrl = new URL(url, window.location.href);
const parsedWadoRoot = new URL(wadoRoot, window.location.href);
const isHttpUrl = parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:';
const isHttpWadoRoot =
parsedWadoRoot.protocol === 'http:' || parsedWadoRoot.protocol === 'https:';
return isHttpUrl && isHttpWadoRoot && parsedUrl.origin === parsedWadoRoot.origin;
} catch {
return false;
}
}
function createRevokeOnce(objectUrl: string) {
let revoked = false;
return () => {
if (revoked) {
return;
}
URL.revokeObjectURL(objectUrl);
revoked = true;
};
}

View File

@ -52,6 +52,7 @@ function createDicomWebProxyApi(dicomWebProxyConfig, servicesManager: AppTypes.S
retrieve: {
getGetThumbnailSrc: (...args) => dicomWebDelegate.retrieve.getGetThumbnailSrc(...args),
directURL: (...args) => dicomWebDelegate.retrieve.directURL(...args),
renderedURL: (...args) => dicomWebDelegate.retrieve.renderedURL(...args),
series: {
metadata: async (...args) => dicomWebDelegate.retrieve.series.metadata(...args),
},
@ -62,7 +63,8 @@ function createDicomWebProxyApi(dicomWebProxyConfig, servicesManager: AppTypes.S
reject: {
series: (...args) => dicomWebDelegate?.reject?.series?.(...args),
},
deleteStudyMetadataPromise: (...args) => dicomWebDelegate?.deleteStudyMetadataPromise?.(...args),
deleteStudyMetadataPromise: (...args) =>
dicomWebDelegate?.deleteStudyMetadataPromise?.(...args),
getImageIdsForDisplaySet: (...args) => dicomWebDelegate?.getImageIdsForDisplaySet?.(...args),
getImageIdsForInstance: (...args) => dicomWebDelegate?.getImageIdsForInstance?.(...args),
getConfig: (...args) =>

View File

@ -242,6 +242,24 @@ function createMergeDataSourceApi(
defaultDataSourceName,
extensionManager,
}),
renderedURL: (...args: unknown[]) => {
const [dataSource] = extensionManager.getDataSources(defaultDataSourceName);
const renderedURL = get(dataSource, 'retrieve.renderedURL');
if (renderedURL) {
return renderedURL.apply(dataSource, args);
}
const directURL = get(dataSource, 'retrieve.directURL');
if (!directURL) {
return Promise.resolve({ url: null });
}
return Promise.resolve(directURL.apply(dataSource, [args[0]])).then(url => ({
url: (url as string | undefined | null) || null,
}));
},
series: {
metadata: (...args: unknown[]) =>
callForAllDataSourcesAsync({

View File

@ -14,12 +14,17 @@ const _getDisplaySetsFromSeries = (instances, servicesManager, extensionManager)
const { Modality, SOPInstanceUID } = instance;
const { SeriesDescription = 'PDF', MIMETypeOfEncapsulatedDocument } = instance;
const { SeriesNumber, SeriesDate, SeriesInstanceUID, StudyInstanceUID, SOPClassUID } = instance;
const renderedUrl = dataSource.retrieve.directURL({
const renderedUrlParams = {
instance,
tag: 'EncapsulatedDocument',
defaultType: MIMETypeOfEncapsulatedDocument || 'application/pdf',
singlepart: 'pdf',
});
};
const renderedUrl = dataSource.retrieve.directURL(renderedUrlParams);
const getRenderedUrl = dataSource.retrieve.renderedURL
? options =>
dataSource.retrieve.renderedURL({ ...renderedUrlParams, url: renderedUrl }, options)
: undefined;
const displaySet = {
//plugin: id,
@ -36,6 +41,7 @@ const _getDisplaySetsFromSeries = (instances, servicesManager, extensionManager)
referencedImages: null,
measurements: null,
renderedUrl: renderedUrl,
getRenderedUrl,
instances: [instance],
thumbnailSrc: null,
isDerivedDisplaySet: true,

View File

@ -28,15 +28,9 @@ const dicomPDFExtension = {
* @param {object} [configuration={}]
* @param {object|array} [configuration.csToolsConfig] - Passed directly to `initCornerstoneTools`
*/
getViewportModule({ servicesManager, extensionManager }) {
getViewportModule() {
const ExtendedOHIFCornerstonePdfViewport = props => {
return (
<OHIFCornerstonePdfViewport
servicesManager={servicesManager}
extensionManager={extensionManager}
{...props}
/>
);
return <OHIFCornerstonePdfViewport {...props} />;
};
return [{ name: 'dicom-pdf', component: ExtendedOHIFCornerstonePdfViewport }];

View File

@ -33,14 +33,43 @@ function OHIFCornerstonePdfViewport({ displaySets, viewportId = 'pdf-viewport' }
}
const { renderedUrl } = displaySets[0];
const { getRenderedUrl } = displaySets[0];
useEffect(() => {
let isCancelled = false;
let revokeUrl;
const abortController = new AbortController();
const load = async () => {
setUrl(await renderedUrl);
try {
const result = getRenderedUrl
? await getRenderedUrl({ signal: abortController.signal })
: { url: await renderedUrl };
if (isCancelled) {
result?.revoke?.();
return;
}
revokeUrl = result?.revoke;
setUrl(result?.url || null);
} catch (error) {
console.warn('Failed to load PDF', error);
if (!isCancelled) {
setUrl(null);
}
return;
}
};
load();
}, [renderedUrl]);
return () => {
isCancelled = true;
abortController.abort();
revokeUrl?.();
};
}, [renderedUrl, getRenderedUrl]);
return (
<div

View File

@ -57,12 +57,16 @@ const _getDisplaySetsFromSeries = (instances, servicesManager, extensionManager)
const { Modality, SOPInstanceUID, SeriesDescription = 'VIDEO', imageId } = instance;
const { SeriesNumber, SeriesDate, SeriesInstanceUID, StudyInstanceUID, NumberOfFrames, url } =
instance;
const videoUrl = dataSource.retrieve.directURL({
const videoUrlParams = {
instance,
singlepart: 'video',
tag: 'PixelData',
url,
});
};
const videoUrl = dataSource.retrieve.directURL(videoUrlParams);
const getVideoUrl = dataSource.retrieve.renderedURL
? options => dataSource.retrieve.renderedURL({ ...videoUrlParams, url: videoUrl }, options)
: undefined;
const displaySet = {
//plugin: id,
Modality,
@ -76,6 +80,8 @@ const _getDisplaySetsFromSeries = (instances, servicesManager, extensionManager)
SOPClassHandlerId,
referencedImages: null,
measurements: null,
videoUrl,
getVideoUrl,
viewportType: csEnums.ViewportType.VIDEO,
instances: [instance],
getThumbnailSrc: dataSource.retrieve.getGetThumbnailSrc?.(instance),

View File

@ -1,19 +1,6 @@
import React from 'react';
import getSopClassHandlerModule from './getSopClassHandlerModule';
import { id } from './id';
const Component = React.lazy(() => {
return import(/* webpackPrefetch: true */ './viewports/OHIFCornerstoneVideoViewport');
});
const OHIFCornerstoneVideoViewport = props => {
return (
<React.Suspense fallback={<div>Loading...</div>}>
<Component {...props} />
</React.Suspense>
);
};
/**
*
*/
@ -23,38 +10,7 @@ const dicomVideoExtension = {
*/
id,
/**
*
*
* @param {object} [configuration={}]
* @param {object|array} [configuration.csToolsConfig] - Passed directly to `initCornerstoneTools`
*/
getViewportModule({ servicesManager, extensionManager }) {
const ExtendedOHIFCornerstoneVideoViewport = props => {
return (
<OHIFCornerstoneVideoViewport
servicesManager={servicesManager}
extensionManager={extensionManager}
{...props}
/>
);
};
return [{ name: 'dicom-video', component: ExtendedOHIFCornerstoneVideoViewport }];
},
getSopClassHandlerModule,
};
function _getToolAlias(toolName) {
let toolAlias = toolName;
switch (toolName) {
case 'EllipticalRoi':
toolAlias = 'SREllipticalRoi';
break;
}
return toolAlias;
}
export default dicomVideoExtension;

View File

@ -1,55 +0,0 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
function OHIFCornerstoneVideoViewport({ displaySets }) {
if (displaySets && displaySets.length > 1) {
throw new Error(
'OHIFCornerstoneVideoViewport: only one display set is supported for dicom video right now'
);
}
const { videoUrl } = displaySets[0];
const mimeType = 'video/mp4';
const [url, setUrl] = useState(null);
useEffect(() => {
const load = async () => {
setUrl(await videoUrl);
};
load();
}, [videoUrl]);
// Need to copies of the source to fix a firefox bug
return (
<div className="bg-primary-black h-full w-full">
<video
src={url}
controls
controlsList="nodownload"
preload="auto"
className="h-full w-full"
crossOrigin="anonymous"
>
<source
src={url}
type={mimeType}
/>
<source
src={url}
type={mimeType}
/>
Video src/type not supported:{' '}
<a href={url}>
{url} of type {mimeType}
</a>
</video>
</div>
);
}
OHIFCornerstoneVideoViewport.propTypes = {
displaySets: PropTypes.arrayOf(PropTypes.object).isRequired,
};
export default OHIFCornerstoneVideoViewport;

View File

@ -26,7 +26,6 @@ const dicomsr = {
const dicomvideo = {
sopClassHandler: '@ohif/extension-dicom-video.sopClassHandlerModule.dicom-video',
viewport: '@ohif/extension-dicom-video.viewportModule.dicom-video',
};
const dicompdf = {
@ -141,11 +140,7 @@ function modeFactory({ modeConfiguration }) {
viewports: [
{
namespace: cs3d.viewport,
displaySetsToDisplay: [ohif.sopClassHandler],
},
{
namespace: dicomvideo.viewport,
displaySetsToDisplay: [dicomvideo.sopClassHandler],
displaySetsToDisplay: [ohif.sopClassHandler, dicomvideo.sopClassHandler],
},
{
namespace: dicompdf.viewport,

View File

@ -33,7 +33,6 @@ const dicomsr = {
const dicomvideo = {
sopClassHandler: '@ohif/extension-dicom-video.sopClassHandlerModule.dicom-video',
viewport: '@ohif/extension-dicom-video.viewportModule.dicom-video',
};
const dicompdf = {
@ -254,10 +253,6 @@ function modeFactory() {
namespace: dicomsr.viewport,
displaySetsToDisplay: [dicomsr.sopClassHandler, dicomsr.sopClassHandler3D],
},
{
namespace: dicomvideo.viewport,
displaySetsToDisplay: [dicomvideo.sopClassHandler],
},
{
namespace: dicompdf.viewport,
displaySetsToDisplay: [dicompdf.sopClassHandler],

View File

@ -44,7 +44,6 @@ export const dicomsr = {
export const dicomvideo = {
sopClassHandler: '@ohif/extension-dicom-video.sopClassHandlerModule.dicom-video',
viewport: '@ohif/extension-dicom-video.viewportModule.dicom-video',
};
export const dicomecg = {

View File

@ -17,7 +17,6 @@ export const cornerstone = {
const dicomvideo = {
sopClassHandler: '@ohif/extension-dicom-video.sopClassHandlerModule.dicom-video',
viewport: '@ohif/extension-dicom-video.viewportModule.dicom-video',
};
const dicompdf = {
@ -107,7 +106,7 @@ function modeFactory({ modeConfiguration }) {
],
},
{
namespace: dicomvideo.viewport,
namespace: cornerstone.viewport,
displaySetsToDisplay: [dicomvideo.sopClassHandler],
},
{

View File

@ -32,7 +32,6 @@ const dicomsr = {
const dicomvideo = {
sopClassHandler: '@ohif/extension-dicom-video.sopClassHandlerModule.dicom-video',
viewport: '@ohif/extension-dicom-video.viewportModule.dicom-video',
};
const dicompdf = {

View File

@ -0,0 +1,74 @@
---
sidebar_position: 8
sidebar_label: DICOM video viewport
title: DICOM video viewport migration
---
# DICOM video viewport migration
The `@ohif/extension-dicom-video` extension no longer provides its own viewport module in 3.13. DICOM video rendering is handled by the Cornerstone viewport, which supports Cornerstone3D video viewports directly.
The DICOM video SOP class handler remains available and should still be used to identify and build video display sets:
```ts
'@ohif/extension-dicom-video.sopClassHandlerModule.dicom-video'
```
## What changed
The following viewport namespace has been removed:
```ts
'@ohif/extension-dicom-video.viewportModule.dicom-video'
```
Use the Cornerstone viewport namespace instead:
```ts
'@ohif/extension-cornerstone.viewportModule.cornerstone'
```
## Migration
If your mode has a dedicated DICOM video viewport entry, replace it with the Cornerstone viewport or add the video SOP class handler to an existing Cornerstone viewport entry.
**Before (3.12):**
```ts
const dicomvideo = {
sopClassHandler: '@ohif/extension-dicom-video.sopClassHandlerModule.dicom-video',
viewport: '@ohif/extension-dicom-video.viewportModule.dicom-video',
};
viewports: [
{
namespace: '@ohif/extension-cornerstone.viewportModule.cornerstone',
displaySetsToDisplay: [ohif.sopClassHandler],
},
{
namespace: dicomvideo.viewport,
displaySetsToDisplay: [dicomvideo.sopClassHandler],
},
];
```
**After (3.13):**
```ts
const dicomvideo = {
sopClassHandler: '@ohif/extension-dicom-video.sopClassHandlerModule.dicom-video',
};
viewports: [
{
namespace: '@ohif/extension-cornerstone.viewportModule.cornerstone',
displaySetsToDisplay: [ohif.sopClassHandler, dicomvideo.sopClassHandler],
},
];
```
If your mode uses a custom viewport that wraps `OHIFCornerstoneViewport` (for example, a tracked Cornerstone viewport), route DICOM video display sets through that wrapper instead of the removed DICOM video viewport namespace.
## Why
Keeping video rendering in the Cornerstone viewport avoids a separate custom React video viewport path and keeps video behavior aligned with Cornerstone3D viewport services, tools, annotations, and presentation state handling.

View File

@ -23,5 +23,8 @@ The largest changes in 3.13 are infrastructure-level:
is now **24**.
- **[SegmentationService](./segmentation-service.md)** — the
`removeSegmentationRepresentations` method was renamed.
- **[DICOM video viewport](./dicom-video.md)** — the
`@ohif/extension-dicom-video.viewportModule.dicom-video` namespace was
removed; route DICOM video display sets through the Cornerstone viewport.
<DocCardList />