import { captureSharingOwner, assertSharingOwner, validSharingOwner } from './assistant/sharing.js'; // Authenticated assets: stable URLs are persisted; transient display URLs are realm-owned only. 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)?$/; 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; } 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'); const blob = await privateImageBlob(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 = workflow === 'learning_hub' ? '/api/admin/learning/image/jobs/' : '/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', () => { 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'); }); });