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
98 lines
6.2 KiB
JavaScript
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 };
|