import { captureSharingOwner, assertSharingOwner, validSharingOwner } from './assistant/sharing.js'; // Authenticated assets: stable URLs are persisted; transient display URLs are realm-owned only. // Only the exact shapes the server serves: the asset, its download, or one of // the two allow-listed preview widths. Anything else is not a private asset URL. const ASSET = /^\/api\/generated-images\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?:\?download=1|\?w=(?:256|640))?$/; const urls = new Map(); export function assetPath(src) { return ASSET.test(String(src || '')); } export function captureImageOwner() { try { return captureSharingOwner(); } catch (_) { throw new DOMException('Verified account required', 'AbortError'); } } export function assertImageOwner(owner) { assertSharingOwner(owner); } export async function imageJson(url, options = {}, ticket = captureImageOwner()) { assertImageOwner(ticket); const response = await fetch(url, { ...options, credentials: 'same-origin', signal: ticket.signal, headers: window.getAuthHeaders() }); assertImageOwner(ticket); const data = await response.json(); assertImageOwner(ticket); if (!response.ok || !data.success) throw new Error(data.error || 'Image request failed'); return data; } export async function privateImageBlob(src, ticket = captureImageOwner()) { if (!assetPath(src)) throw new Error('Invalid private image reference'); assertImageOwner(ticket); const headers = { ...window.getAuthHeaders() }; delete headers['Content-Type']; const response = await fetch(src, { headers, credentials: 'same-origin', signal: ticket.signal, redirect: 'error' }); assertImageOwner(ticket); const mime = response.headers.get('content-type'); const size = Number(response.headers.get('content-length')); const checksum = response.headers.get('x-image-sha256'); if (!response.ok || !['image/png','image/jpeg','image/webp'].includes(mime) || !Number.isInteger(size) || size < 1 || size > 16 * 1024 * 1024 || response.headers.get('x-image-owner') !== String(ticket.ticket) || !/^[a-f0-9]{64}$/.test(checksum || '')) throw new Error('Private image headers failed verification'); const reader = response.body.getReader(); const chunks = []; let count = 0; while (true) { const part = await reader.read(); assertImageOwner(ticket); if (part.done) break; count += part.value.length; if (count > size) { await reader.cancel(); throw new Error('Private image exceeds declared size'); } chunks.push(part.value); } const blob = new Blob(chunks, { type: mime }); const bytes = await blob.arrayBuffer(); const actual = Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)), b => b.toString(16).padStart(2, '0')).join(''); assertImageOwner(ticket); if (count !== size || actual !== checksum) throw new Error('Private image checksum failed'); return blob; } export async function imageDataUrl(src, ticket = captureImageOwner()) { assertImageOwner(ticket); if (!assetPath(src)) return src; // Legacy saved bitmaps/URLs stay compatible. const blob = await privateImageBlob(src, ticket); assertImageOwner(ticket); const data = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = () => reject(reader.error); reader.readAsDataURL(blob); }); assertImageOwner(ticket); return data; } // ── Session image cache ───────────────────────────────────────────────────── // Assets are served `private, no-store`, which is right for a clinical app on a // shared workstation — but it means every gallery render re-downloaded every // image. This keeps decoded blobs in MEMORY ONLY for the session, keyed by asset // and by the owning ticket, so nothing is written to disk and a different // account can never read another's entry. Cleared on any account boundary // change, alongside the object URLs. const blobCache = new Map(); // key -> Blob (full size) const thumbCache = new Map(); // key -> Blob (downscaled) const inflight = new Map(); // key -> Promise, so N tiles fetch once function cacheKey(src, ticket) { return String(ticket && ticket.ticket) + '\u0000' + String(src); } export function clearImageCache() { blobCache.clear(); thumbCache.clear(); inflight.clear(); } export async function cachedImageBlob(src, ticket = captureImageOwner()) { assertImageOwner(ticket); const key = cacheKey(src, ticket); const hit = blobCache.get(key); if (hit) return hit; // Several tiles asking for the same asset share one request instead of racing. if (inflight.has(key)) return inflight.get(key); const pending = privateImageBlob(src, ticket) .then(blob => { assertImageOwner(ticket); blobCache.set(key, blob); return blob; }) .finally(() => inflight.delete(key)); inflight.set(key, pending); return pending; } // A gallery tile is 56px but was decoding a full 300kB image. Downscaling once // and reusing the result keeps memory and decode cost proportional to what is // actually shown. Reusable anywhere a small preview is wanted. export async function cachedThumbnail(src, edge = 256, ticket = captureImageOwner()) { assertImageOwner(ticket); const key = cacheKey(src, ticket) + '\u0000' + edge; const hit = thumbCache.get(key); if (hit) return hit; const blob = await cachedImageBlob(src, ticket); assertImageOwner(ticket); if (typeof createImageBitmap !== 'function' || typeof OffscreenCanvas !== 'function') return blob; try { const bitmap = await createImageBitmap(blob); const scale = Math.min(1, edge / Math.max(bitmap.width, bitmap.height)); if (scale === 1) { bitmap.close(); thumbCache.set(key, blob); return blob; } const canvas = new OffscreenCanvas(Math.round(bitmap.width * scale), Math.round(bitmap.height * scale)); const context = canvas.getContext('2d'); context.drawImage(bitmap, 0, 0, canvas.width, canvas.height); bitmap.close(); const small = await canvas.convertToBlob({ type: 'image/webp', quality: 0.82 }); assertImageOwner(ticket); thumbCache.set(key, small); return small; } catch (_) { return blob; // any decode failure just uses the original } } export function transientImageUrl(blob, ticket = captureImageOwner()) { assertImageOwner(ticket); const url = URL.createObjectURL(blob); urls.set(url, ticket); return url; } export function revokeImageUrl(url) { URL.revokeObjectURL(url); urls.delete(url); } export async function hydrateImage(img, src, ticket = captureImageOwner()) { assertImageOwner(ticket); if (!assetPath(src)) return; img.removeAttribute('src'); // A tile marked data-image-thumb gets the downscaled copy; full views get the // original. Both come from the session cache, so a re-render costs nothing. // The server now stores real previews, so a tile fetches a few kB rather than // downloading the original and shrinking it here. cachedThumbnail stays as the // fallback for assets whose preview cannot be produced. const edge = Number(img.getAttribute('data-image-thumb')) || 0; const blob = edge ? await cachedImageBlob(src + (src.indexOf('?') === -1 ? '?' : '&') + 'w=' + edge, ticket) .catch(() => cachedThumbnail(src, edge, ticket)) : await cachedImageBlob(src, ticket); assertImageOwner(ticket); if (img.isConnected) img.src = transientImageUrl(blob, ticket); } export function imageContextLabel(data) { const c = data.context; return c ? 'Image context: ' + c.includedTurns + '/' + c.totalTurns + ' preceding turns included; ' + c.used + '/' + c.limit + ' UTF-16 code units. ' + (c.totalTurns > c.includedTurns ? 'Older turns omitted from image input only. ' : '') : 'Image context metadata unavailable for this older job.'; } export function renderImageJobs(container, jobs, workflow, onDone) { const ticket = captureImageOwner(); (jobs || []).forEach(job => { if (!/^[0-9a-f-]{36}$/.test(job.jobId || '')) return; const card = document.createElement('section'); card.className = 'assistant-image-card'; const status = document.createElement('p'); status.setAttribute('role', 'status'); card.append(status); container.append(card); const base = { learning_hub: '/api/admin/learning/image/jobs/', my_resources: '/api/my-resources/image/jobs/' }[workflow] || '/api/clinical-assistant/image/jobs/'; async function poll() { if (!validSharingOwner(ticket) || !card.isConnected) return; try { const data = await imageJson(base + job.jobId, {}, ticket); assertImageOwner(ticket); if (!card.isConnected) return; status.textContent = data.error || ('Image: ' + data.status); if (data.status === 'done') { onDone(card, data); return; } if (data.status === 'error') return; } catch (error) { if (!validSharingOwner(ticket) || error.name === 'AbortError') return; // Transient status failures keep polling; the job is durable server-side. status.textContent = 'Image: checking status…'; setTimeout(poll, 2500); return; } setTimeout(poll, 2000); } poll(); }); } // Learning viewer/Marp and assistant images may be inserted by existing renderers. if (typeof MutationObserver !== 'undefined') { const observer = new MutationObserver(records => { records.forEach(record => record.removedNodes.forEach(node => { if (node.nodeType !== 1) return; const images = node.matches('img') ? [node] : node.querySelectorAll('img'); images.forEach(img => { if (!img.isConnected && urls.has(img.src)) revokeImageUrl(img.src); }); })); document.querySelectorAll('img[src^="/api/generated-images/"]').forEach(img => { if (img.closest('.ProseMirror')) return; // NodeView owns display; editor state retains stable src. let owner; try { owner = captureImageOwner(); } catch (_) { img.removeAttribute('src'); return; } hydrateImage(img, img.getAttribute('src'), owner).catch(error => { if (validSharingOwner(owner) && error.name !== 'AbortError') img.alt = 'Private image unavailable'; }); }); }); observer.observe(document.documentElement, { childList: true, subtree: true }); } if (typeof window !== 'undefined') window.addEventListener('account-boundary', () => { clearImageCache(); // cached bytes must not outlive the account that fetched them const obsolete = new Set(); urls.forEach((owner, url) => { if (!validSharingOwner(owner)) { obsolete.add(url); revokeImageUrl(url); } }); document.querySelectorAll('img[src^="blob:"]').forEach(img => { if (obsolete.has(img.src)) img.removeAttribute('src'); }); });