Gallery tiles are 56px but were downloading the full ~280kB original. Previews are now rendered with sharp and stored beside the originals in the same MinIO bucket under a thumbs/ prefix, so nothing about credentials, lifecycle or backup changes. Measured on live assets: 216-294kB originals become 13-19kB at 256px, about 16x smaller; 640px is about 4x. Both paths, as asked: - Rendered when a job completes, so the first viewer never waits for a resize. A preview failure never unmakes a finished job. - Rendered on demand for anything that has none — the existing 26 images work immediately with no backfill required, and the result is stored for next time. Boundaries that matter more than the speed: - Only 256 and 640 are honoured. An open width parameter would let a caller drive arbitrary resizes. - Permission is checked against the ORIGINAL before a preview is served, so a preview can never widen who can see an image. - Previews carry their own SHA-256 and owner headers, because the client verifies both on every asset; sending the original's checksum would be rejected as tampering, which is that check working correctly. - Still private, no-store. The client asset pattern was widened to exactly ?w=256 and ?w=640 and nothing else. Client-side downscaling stays as the fallback when a preview cannot be produced. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018e1PLqrKgAM9jQhFKRnbLd
192 lines
10 KiB
JavaScript
192 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.
|
|
// 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 = 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'); });
|
|
});
|