Generated assets are served `private, no-store`, which is right for a clinical app on a shared workstation — but it meant every gallery render re-downloaded every image. Measured: 26 images averaging 279kB, so 7.2MB fetched to draw 56px tiles, on every open of the Create image popup. Two changes, both reusable anywhere in the app: - A session cache holding decoded blobs in MEMORY ONLY, so nothing is written to disk and the no-store posture is unchanged. Entries are keyed by owner ticket as well as asset, and cleared when the account boundary moves, so one account can never read another's bytes out of memory. Concurrent tiles asking for the same asset share one request rather than racing. - Any img carrying data-image-thumb gets a downscaled copy instead of the original, so a 56px tile no longer decodes a 300kB image. The gallery asks for 256px and the in-chat preview for 640px; opening the full view still gets the original. Browsers without OffscreenCanvas, and any decode failure, fall back to the full image rather than showing nothing. This does not reduce the first fetch. Serving genuinely smaller bytes needs server-side resizing, which needs an image library this project does not carry — worth a deliberate decision rather than adding a native dependency in passing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GmpYHPSLGmXGZMyLpn2Lbe
184 lines
10 KiB
JavaScript
184 lines
10 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;
|
|
}
|
|
// ── 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.
|
|
const edge = Number(img.getAttribute('data-image-thumb')) || 0;
|
|
const blob = edge ? await 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 = 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', () => {
|
|
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'); });
|
|
});
|