import { init as coreInit, imageRetrievalPoolManager, Enums as CoreEnums } from '@cornerstonejs/core'; import dicomImageLoader from '@cornerstonejs/dicom-image-loader'; import { dpopHeaderFor } from '../../utils/dpopRequestHeader'; export interface DicomAuthProvider { getToken: () => Promise; getOrganizationId: () => string | undefined; getActingAs: () => string | undefined; } // Held in a module-level slot, read at REQUEST time rather than captured at init time. Cornerstone // initialises once per page, but a full study scroll issues 250 frame requests spread over minutes - // long enough for the access token to expire. Baking the token, or even the provider closure, into the // options object at init would mean every request after the first refresh carries a stale credential // and 401s partway through a scroll. let authProvider: DicomAuthProvider | undefined; export const configureDicomAuth = (provider: DicomAuthProvider): void => { authProvider = provider; }; // The frame proxy returns base64 text rather than binary, because the REST API has no BinaryMediaTypes // configured and adding it would change request handling for every route on the API. Cornerstone's // xhrRequest hands the raw response to `beforeProcessing` and uses whatever ArrayBuffer it returns, so // this is where the text becomes bytes. export const base64ToArrayBuffer = (value: string): ArrayBuffer => { const binary = atob(value); const bytes = new Uint8Array(binary.length); for (let index = 0; index < binary.length; index++) { bytes[index] = binary.charCodeAt(index); } return bytes.buffer; }; // THE EXACT URL THE FRAME REQUEST WILL USE, recorded where cornerstone hands it to us. // // A DPoP proof is bound to the method and the URL, so signing for anything other than what the request // actually asks for is REFUSED - and refused as a binding failure, which reads like an attack rather // than like a bug. `beforeSend` is given an imageId, and an imageId is not the URL: `getPixelData` // appends `retrieveOptions.urlArguments` and can rewrite `/frames/` via `framesPath` before calling // `xhrRequest`. They happen to be equal today, which is exactly what makes deriving one from the other // a defect waiting for a configuration change nobody connects to a wave of 403s. // // `open` is handed the real URL, with the same xhr object `beforeSend` receives, immediately before // it - verified in the installed source (imageLoader/internal/xhrRequest.js). That is the only place in // the loader's own API where the request URL is available, so it is where we take it from. const requestUrls = new WeakMap(); /** * The URL this call is about to request, from whichever transport is asking. * * Two transports call `beforeSend` and they disagree about the arguments. `xhrRequest` passes the * xhr and an **imageId**; `streamRequest` passes **null** and the **URL**. Telling them apart by the * xhr rather than by the string is deliberate - sniffing the string would silently accept a `wadors:` * imageId as a URL, and `new URL('wadors:https://...')` parses without throwing, yielding a proof bound * to a path that does not exist. * * Throws rather than dropping the proof. An unsigned frame request from a bound session is a 403 with * nothing in it that names this file, and while a session is UNBOUND it would succeed - so the quiet * path hides the breakage until the day unbound tokens stop being admitted, and then breaks imaging for * everyone at once the failure this loader is being fixed for. */ const requestUrlFor = (xhr: XMLHttpRequest | null, imageIdOrUrl: string): string => { const candidate = xhr === null ? imageIdOrUrl : requestUrls.get(xhr); if (!candidate || !/^https?:\/\//i.test(candidate)) { throw new Error( 'dicomLoader: cannot tell which URL this frame request will use, so no proof can be bound to it' ); } return candidate; }; let initialised: Promise | undefined; const doInit = async (): Promise => { await coreInit(); dicomImageLoader.internal.setOptions({ // Cornerstone's own default is `xhr.open('get', url, true)` and this must stay equivalent to it - // the hook exists here to see the URL, not to change the request. Overriding it is the supported // way to do that; the alternative is reading it back off the xhr, and XMLHttpRequest exposes no // such property until a response arrives. open: (xhr: XMLHttpRequest, url: string) => { requestUrls.set(xhr, url); xhr.open('get', url, true); }, beforeSend: async ( xhr: XMLHttpRequest | null, imageIdOrUrl: string, defaultHeaders: Record ) => { if (!authProvider) { throw new Error('dicomLoader: configureDicomAuth must run before any frame is requested'); } // Resolved BEFORE the token is fetched, so a loader we cannot sign for fails on its own terms // rather than after spending a refresh. const url = requestUrlFor(xhr, imageIdOrUrl); const token = await authProvider.getToken(); const organizationId = authProvider.getOrganizationId(); const actingAs = authProvider.getActingAs(); const headers: Record = { ...defaultHeaders, Authorization: `Bearer ${token}`, // One proof per request, signed for THIS url. The frames are fetched by cornerstone's // own binary loader rather than by `fetchWithAuth`, so this is a transport that never had // the interceptor - the authorizer refuses a bound token arriving without a proof, which // is why a study opened in a bound session returned 403 on every frame while its metadata // (which does go through the shared client) returned 200. ...await dpopHeaderFor('GET', url) }; if (organizationId) { headers['X-Organization-ID'] = organizationId; headers['X-Organization-Context'] = 'wordpress'; } if (actingAs) { headers['X-Acting-As'] = actingAs; } return headers; }, // xhr.response, NEVER xhr.responseText. Cornerstone's xhrRequest sets // `xhr.responseType = 'arraybuffer'` (verified in the installed source, xhrRequest.js:18), and // reading `.responseText` on such a request throws InvalidStateError - it is only readable when // responseType is '' or 'text'. So the base64 arrives here as an ArrayBuffer of ASCII bytes: // decode those to a string first, then base64-decode that. beforeProcessing: async (xhr: XMLHttpRequest): Promise => { const body = xhr.response as ArrayBuffer | null; if (!body) return new ArrayBuffer(0); return base64ToArrayBuffer(new TextDecoder().decode(new Uint8Array(body))); } }); await dicomImageLoader.init(); // BOUND THE PREFETCH BURST, NOT THE INTERACTIVE PATH. The retrieval pool ships with a cap of // 200 simultaneous requests for every request type. Leaving Prefetch at 200 means opening a study // fires a 200-wide burst of Lambda invocations - on a route that currently performs two HealthLake // reads per call - which spikes account concurrency and cost for frames the user may never reach. // // Interaction is deliberately left alone: the slice being looked at must still jump the queue. imageRetrievalPoolManager.setMaxSimultaneousRequests(CoreEnums.RequestType.Prefetch, 12); // DO NOT ENABLE RANGE RETRIEVAL ON THIS LOADER. `getPixelData` picks its transport from // `retrieveOptions`: `rangeIndex` routes to `rangeRequest`, which never calls `beforeSend` at all, so // the request would carry no Authorization header and no proof - it does not lose the binding, it // loses the credential. `streaming` routes to `streamRequest`, which does call `beforeSend` (with a // null xhr and the URL in place of the imageId - `requestUrlFor` handles that shape). Both were // verified in the installed source; a cornerstone upgrade is a reason to re-read them. dicomImageLoader.wadors.register(); }; // Idempotent by promise, not by boolean: two viewports mounting in the same tick must await the SAME // initialisation rather than race two of them, and a boolean flag set before the await would let the // second caller proceed against a half-initialised loader. export const initDicomLoader = (): Promise => { initialised ??= doInit(); return initialised; };