pediatric-ai-scribe-v3/src/utils/generatedImageStorage.js
Daniel 2f7233f317 perf: store real image previews in MinIO, generated at creation and on demand
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
2026-09-10 06:39:30 +02:00

98 lines
6.2 KiB
JavaScript

// Private, bounded assets. The provider download uses a pinned lookup, not a URL precheck alone.
const fs = require('fs');
const https = require('https');
const dns = require('dns').promises;
const crypto = require('crypto');
const { isPrivateIp } = require('./urlSafety');
const MAX_BYTES = 16 * 1024 * 1024;
function inspect(bytes, declared) {
if (!Buffer.isBuffer(bytes) || !bytes.length || bytes.length > MAX_BYTES) throw new Error('Invalid image size');
const mime = bytes.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex')) ? 'image/png' :
bytes.subarray(0, 3).equals(Buffer.from('ffd8ff', 'hex')) ? 'image/jpeg' :
bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP' ? 'image/webp' : null;
if (!mime || (declared && declared.split(';')[0].trim().toLowerCase() !== mime)) throw new Error('Unsupported image content');
return { bytes, mime, checksum: crypto.createHash('sha256').update(bytes).digest('hex') };
}
function decodeBase64(value) {
if (typeof value !== 'string' || value.length > Math.ceil(MAX_BYTES / 3) * 4 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) throw new Error('Invalid image base64');
const bytes = Buffer.from(value, 'base64');
if (bytes.toString('base64') !== value) throw new Error('Noncanonical image base64');
return inspect(bytes);
}
async function download(url, { lookup = dns.lookup, request = https.get, signal } = {}) {
const u = new URL(url);
if (u.protocol !== 'https:' || u.username || u.password || (u.port && u.port !== '443')) throw new Error('Unsafe image URL');
const hostname = u.hostname.replace(/^\[|\]$/g, '');
const addresses = await lookup(hostname, { all: true });
if (!addresses.length || addresses.some(a => isPrivateIp(a.address))) throw new Error('Unsafe image address');
const pinned = addresses[0];
return new Promise((resolve, reject) => {
// No proxy, redirects or authorization. TLS still verifies the original hostname.
const req = request(u, { agent: false, signal, timeout: 30000, lookup: (_host, options, cb) => {
cb(null, options.all ? [pinned] : pinned.address, pinned.family);
}, headers: { Accept: 'image/png, image/jpeg, image/webp' } }, res => {
if (res.statusCode !== 200 || !res.headers['content-type'] || Number(res.headers['content-length']) > MAX_BYTES) {
res.destroy(); reject(new Error('Image download rejected')); return;
}
let size = 0; const chunks = [];
res.on('data', chunk => {
size += chunk.length;
if (size > MAX_BYTES) { res.destroy(new Error('Image exceeds byte limit')); return; }
chunks.push(chunk);
});
res.on('error', reject);
res.on('end', () => { try { resolve(inspect(Buffer.concat(chunks), res.headers['content-type'])); } catch (e) { reject(e); } });
});
req.on('timeout', () => req.destroy(new Error('Image download timeout')));
req.on('error', reject);
});
}
// Derived previews are addressed by asset id and width, so a request can only
// ever reach a preview of the asset it already has permission to read.
function thumbKey(id, width) { return 'thumbs/' + id + '/' + width; }
function createStorage(env = process.env) {
const { S3Client, HeadBucketCommand, HeadObjectCommand, PutObjectCommand, GetObjectCommand, DeleteObjectCommand } = require('@aws-sdk/client-s3');
for (const key of ['ENDPOINT', 'ACCESS_KEY_FILE', 'SECRET_KEY_FILE']) if (!env['GENERATED_IMAGES_S3_' + key]) throw new Error('Generated image storage is not configured');
const client = new S3Client({ endpoint: env.GENERATED_IMAGES_S3_ENDPOINT, region: env.GENERATED_IMAGES_S3_REGION || 'us-east-1', forcePathStyle: true,
credentials: { accessKeyId: fs.readFileSync(env.GENERATED_IMAGES_S3_ACCESS_KEY_FILE, 'utf8').trim(), secretAccessKey: fs.readFileSync(env.GENERATED_IMAGES_S3_SECRET_KEY_FILE, 'utf8').trim() },
maxAttempts: 2, requestHandler: { connectionTimeout: 3000, requestTimeout: 15000 } });
const Bucket = env.GENERATED_IMAGES_S3_BUCKET || 'generated-images';
return {
async ready() {
await client.send(new HeadBucketCommand({ Bucket }));
// Fail closed for a read-only credential too, before any image-provider call.
const Key = 'checks/' + crypto.randomUUID();
await client.send(new PutObjectCommand({ Bucket, Key, Body: Buffer.from('storage-check'), ContentType: 'application/octet-stream' }));
try { await client.send(new HeadObjectCommand({ Bucket, Key })); } // Requires object read permission too.
finally { await client.send(new DeleteObjectCommand({ Bucket, Key })); }
},
async put(id, image) { await client.send(new PutObjectCommand({ Bucket, Key: 'assets/' + id, Body: image.bytes, ContentType: image.mime,
ChecksumSHA256: Buffer.from(image.checksum, 'hex').toString('base64'), Metadata: { sha256: image.checksum } })); },
// Derived previews live beside the originals in the same bucket, under their
// own prefix, so nothing about credentials, lifecycle or backup changes.
async putThumb(id, width, bytes, mime) {
await client.send(new PutObjectCommand({ Bucket, Key: thumbKey(id, width), Body: bytes, ContentType: mime }));
},
async getThumb(id, width) {
try {
const response = await client.send(new GetObjectCommand({ Bucket, Key: thumbKey(id, width) }));
const chunks = [];
for await (const chunk of response.Body) chunks.push(chunk);
return { bytes: Buffer.concat(chunks), mime: response.ContentType || 'image/webp' };
} catch (e) {
if (e && (e.name === 'NoSuchKey' || e.$metadata?.httpStatusCode === 404)) return null;
throw e;
}
},
async get(id) {
const response = await client.send(new GetObjectCommand({ Bucket, Key: 'assets/' + id }));
if (response.ContentLength > MAX_BYTES) { response.Body.destroy(); throw new Error('Invalid stored image size'); }
const chunks = []; let size = 0;
for await (const chunk of response.Body) { size += chunk.length; if (size > MAX_BYTES) { response.Body.destroy(); throw new Error('Invalid stored image size'); } chunks.push(chunk); }
return inspect(Buffer.concat(chunks), response.ContentType);
},
close() { client.destroy(); }
};
}
module.exports = { MAX_BYTES, inspect, decodeBase64, download, createStorage };