pediatric-ai-scribe-v3/public/js/generatedImages.js

123 lines
6.9 KiB
JavaScript

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. ' : '') + 'Full conversation unchanged.' : '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'); const status = document.createElement('p');
status.setAttribute('role', 'status');
const context = document.createElement('p'); context.textContent = imageContextLabel(job);
card.append(status, context); 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);
context.textContent = imageContextLabel(data);
if (data.status === 'done') { onDone(card, data); return; }
if (data.status === 'error') return;
} catch (error) {
if (!validSharingOwner(ticket) || error.name === 'AbortError') return;
status.textContent = 'Image status unavailable. Reopen image history to resume; no new paid job was started.';
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'); });
});