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
75 lines
4.3 KiB
JavaScript
75 lines
4.3 KiB
JavaScript
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const read = f => fs.readFileSync(path.join(__dirname, '..', f), 'utf8');
|
|
|
|
// Generated assets are served `private, no-store`, which is correct for a
|
|
// clinical app on a shared workstation. The cost was that every gallery render
|
|
// re-downloaded every image — 26 images at ~279kB to draw 56px tiles.
|
|
test('assets are still never written to disk', () => {
|
|
const route = read('src/routes/generatedImages.js');
|
|
assert.match(route, /Cache-Control', 'private, no-store'/,
|
|
'the no-store posture is unchanged; caching is in memory for the session only');
|
|
});
|
|
|
|
test('the cache is scoped to the account that fetched it', () => {
|
|
const js = read('public/js/generatedImages.js');
|
|
// A cache keyed only by URL would let the next account read the previous
|
|
// one's bytes out of memory.
|
|
assert.match(js, /function cacheKey\(src, ticket\) \{ return String\(ticket && ticket\.ticket\)/,
|
|
'entries are keyed by owner ticket as well as asset');
|
|
assert.match(js, /clearImageCache\(\); \/\/ cached bytes must not outlive the account/,
|
|
'and dropped when the account boundary moves');
|
|
assert.match(js, /assertImageOwner\(ticket\)/, 'ownership is asserted on the way in');
|
|
});
|
|
|
|
test('concurrent tiles share one request instead of racing', () => {
|
|
const js = read('public/js/generatedImages.js');
|
|
assert.match(js, /if \(inflight\.has\(key\)\) return inflight\.get\(key\);/);
|
|
assert.match(js, /\.finally\(\(\) => inflight\.delete\(key\)\)/, 'and the slot is released either way');
|
|
});
|
|
|
|
test('a tile fetches a server preview, not the original', () => {
|
|
const js = read('public/js/generatedImages.js');
|
|
assert.match(js, /const edge = Number\(img\.getAttribute\('data-image-thumb'\)\) \|\| 0;/);
|
|
// The server stores real previews now, so a tile downloads a few kB instead of
|
|
// pulling ~280kB and shrinking it in the browser.
|
|
assert.match(js, /'w=' \+ edge/, 'the tile asks the server for the preview');
|
|
assert.match(js, /\.catch\(\(\) => cachedThumbnail\(src, edge, ticket\)\)/,
|
|
'client downscaling remains the fallback');
|
|
// A browser without OffscreenCanvas still shows the image rather than nothing.
|
|
assert.match(js, /if \(typeof createImageBitmap !== 'function' \|\| typeof OffscreenCanvas !== 'function'\) return blob;/);
|
|
|
|
const assistant = read('public/js/clinicalAssistant.js');
|
|
assert.match(assistant, /data-image-thumb="256"/, 'the 56px gallery asks for a small copy');
|
|
});
|
|
|
|
test('a preview cannot widen access or be asked for arbitrary sizes', () => {
|
|
const utils = read('src/utils/generatedImages.js');
|
|
const route = read('src/routes/generatedImages.js');
|
|
// An open width parameter would let a caller drive arbitrary resizes.
|
|
assert.match(utils, /const THUMB_WIDTHS = Object\.freeze\(\[256, 640\]\);/);
|
|
assert.match(utils, /return THUMB_WIDTHS\.includes\(width\) \? width : null;/);
|
|
// Permission is checked against the ORIGINAL before any preview is served.
|
|
assert.match(route, /await images\.service\(\)\.asset\(req\.params\.id, req\.user\); \/\/ authorise/);
|
|
// Previews carry their own checksum; the original's would be rejected.
|
|
assert.match(route, /createHash\('sha256'\)\.update\(thumb\.bytes\)/);
|
|
assert.match(route, /Cache-Control', 'private, no-store'/, 'and they are no-store like the original');
|
|
|
|
const client = read('public/js/generatedImages.js');
|
|
assert.match(client, /\\?w=\(\?:256\|640\)/, 'the client accepts only those two widths as asset URLs');
|
|
});
|
|
|
|
test('previews are rendered once and stored beside the original', () => {
|
|
const utils = read('src/utils/generatedImages.js');
|
|
assert.match(utils, /const cached = await getStorage\(\)\.getThumb\(id, width\);\s*\n\s*if \(cached\) return cached;/,
|
|
'a stored preview is reused rather than re-rendered');
|
|
assert.match(utils, /await warmThumbs\(job\.id\);/, 'and rendered when the job completes');
|
|
// Neither caching nor warming may break the thing they are optimising.
|
|
assert.match(utils, /catch \(_\) \{ \/\* served anyway; the next request retries the write \*\/ \}/);
|
|
assert.match(utils, /catch \(_\) \{ \/\* previews are an optimisation; never fail the job for one \*\/ \}/);
|
|
|
|
const storage = read('src/utils/generatedImageStorage.js');
|
|
assert.match(storage, /return 'thumbs\/' \+ id \+ '\/' \+ width;/, 'same bucket, own prefix');
|
|
});
|