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 authored and GitHub committed 2026-07-06 09:35:26 -04:00
1 parent 2800f82e3c
commit e0f42d4f1d
18 files changed
+446 -130

No files matched your search

@@ -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
@@ -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);
});
});
@@ -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;
};
}
@@ -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) =>
@@ -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({
@@ -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,
+2 -8
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 }];
@@ -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
@@ -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),
-44
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;
@@ -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;