Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 46s
Forgejo Android APK / Build signed APK (push) Successful in 2m6s
Forgejo Docker Build / Build Docker image (push) Successful in 19s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
A figure outlives the deck it was drawn for: the deck gets replaced, the diagram is still good. Until now a generated image could only be seen in the resource it was made for, and there was no way to find one again or to delete it. Library → Images is a grid of every finished image the account owns, across all three workflows, newest first. GET /api/generated-images is scoped by owner_id in the statement rather than filtered after, returns only finished jobs — an unfinished one is a broken frame in a gallery — and pages by keyset, because a gallery that grows while you scroll repeats or skips rows under OFFSET. Most of this already existed. Thumbnails were already rendered at 256 and 640 by sharp and already served by ?w=, with their own checksum so the client's tamper check passes on a derived copy; hydrateImage already handles auth, the account boundary and caching. The tiles ask for the 256px preview, so thirty of them cost a few kB each rather than thirty full-size downloads, and the prompt is decrypted for the caption because it is the only human-readable label an image has. Deleting needed new work. The storage interface had no remove at all, so a delete that dropped the row would have left the object and both previews in the bucket — paid for, and still readable by anything with credentials. Storage now removes all three keys, and the bytes go before the row: a row pointing at a missing object is a broken image in a gallery, while an object without its row is only wasted space, and unreachable storage refuses the delete outright rather than reporting a success that left the picture behind. THUMB_WIDTHS now has one definition, in generatedImageStorage. Two copies drift, and the drift that matters is a width that gets written and never deleted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
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 \} = storageUtil;/);
|
|
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');
|
|
});
|