feat: store editor images in upload bind

This commit is contained in:
2026-08-06 15:52:54 +00:00
parent 9046a131f2
commit dc0e1a6da9
8 changed files with 91 additions and 27 deletions
+1
View File
@@ -77,6 +77,7 @@ export function loadConfig() {
uploadClientSecrets: clientSecrets('IMAGE_UPLOAD_CLIENT_SECRET_FILES'),
maxBodyBytes: Number(text('MAX_BODY_BYTES', '1048576')),
maxUploadBytes: Number(text('MAX_UPLOAD_BYTES', '51200')),
maxContentUploadBytes: Number(text('MAX_CONTENT_UPLOAD_BYTES', '1048576')),
uploadRoot: text('IMAGE_UPLOAD_ROOT', '/var/lib/image-hook/uploads'),
uploadStatePath: text('IMAGE_UPLOAD_STATE_PATH', '/var/lib/image-hook/upload-state.json'),
};
+13 -9
View File
@@ -38,7 +38,7 @@ function parseJson(body) {
function hasImageSignature(body, extension) {
if (extension === 'png') return body.length >= 8 && body.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex'));
if (extension === 'jpg') return body.length >= 3 && body[0] === 0xff && body[1] === 0xd8 && body[2] === 0xff;
if (extension === 'jpg' || extension === 'jpeg') return body.length >= 3 && body[0] === 0xff && body[1] === 0xd8 && body[2] === 0xff;
if (extension === 'gif') return body.length >= 6 && ['GIF87a', 'GIF89a'].includes(body.subarray(0, 6).toString('ascii'));
if (extension === 'webp') return body.length >= 12 && body.subarray(0, 4).toString('ascii') === 'RIFF'
&& body.subarray(8, 12).toString('ascii') === 'WEBP';
@@ -149,23 +149,26 @@ export async function createApp(config = loadConfig(), dependencies = {}) {
});
return json(response, 200, { ok: true, ...result });
}
if (request.method === 'PUT' && url.pathname.startsWith('/v1/uploads/user-icons/')) {
if (request.method === 'PUT' && url.pathname.startsWith('/v1/uploads/')) {
const client = request.headers['x-image-client'];
const expires = request.headers['x-image-expires'];
const requestId = request.headers['x-image-request-id'];
const contentType = request.headers['content-type']?.toLowerCase() ?? '';
const knownClient = typeof client === 'string' && Object.hasOwn(config.uploadClientSecrets, client);
const match = url.pathname.match(/^\/v1\/uploads\/user-icons\/([a-z0-9][a-z0-9_-]{1,31})\/([a-f0-9]{32})\.(avif|webp|jpg|png|gif)$/);
if (!match || match[1] !== client) {
const match = url.pathname.match(/^\/v1\/uploads\/(user-icons|content)\/([a-z0-9][a-z0-9_-]{1,31})\/([a-f0-9]{32})\.(avif|webp|jpe?g|png|gif)$/);
if (!match || match[2] !== client) {
throw new DeploymentError('Invalid upload path', 400);
}
const mimeByExtension = {
avif: 'image/avif', webp: 'image/webp', jpg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
avif: 'image/avif', webp: 'image/webp', jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
};
if (contentType !== mimeByExtension[match[3]]) {
if (contentType !== mimeByExtension[match[4]]) {
throw new DeploymentError('Content-Type does not match upload path', 415);
}
const body = await readBody(request, config.maxUploadBytes);
const body = await readBody(
request,
match[1] === 'user-icons' ? config.maxUploadBytes : config.maxContentUploadBytes,
);
const signatureValid = verifyUploadSignature({
secret: knownClient ? config.uploadClientSecrets[client] : 'invalid-client-secret'.padEnd(32, '!'),
expires,
@@ -178,13 +181,14 @@ export async function createApp(config = loadConfig(), dependencies = {}) {
if (!knownClient || !signatureValid) {
return json(response, 401, { ok: false, reason: 'invalid or expired upload grant' });
}
if (!hasImageSignature(body, match[3])) {
if (!hasImageSignature(body, match[4])) {
throw new DeploymentError('Body is not the declared image format', 400);
}
const result = await uploadStore.store({
requestKey: `${client}:${requestId}`,
category: match[1],
client,
filename: `${match[2]}.${match[3]}`,
filename: `${match[3]}.${match[4]}`,
body,
});
const urls = config.publicBases.map((base) => `${base}/${result.path}`);
+8 -5
View File
@@ -22,14 +22,17 @@ export class UploadStore {
}
}
store({ requestKey, client, filename, body }) {
store({ requestKey, category, client, filename, body }) {
const operation = this.queue.then(async () => {
if (!/^[a-z0-9][a-z0-9_-]{1,31}$/.test(client)
|| !/^[a-f0-9]{32}\.(?:avif|webp|jpg|png|gif)$/.test(filename)) {
if (!['user-icons', 'content'].includes(category)
|| !/^[a-z0-9][a-z0-9_-]{1,31}$/.test(client)
|| !/^[a-f0-9]{32}\.(?:avif|webp|jpe?g|png|gif)$/.test(filename)) {
throw new DeploymentError('Invalid upload path', 400);
}
const relativePath = `${client}/${filename}`;
const path = `icons/users/${relativePath}`;
const relativePath = `${category}/${client}/${filename}`;
const path = category === 'user-icons'
? `icons/users/${client}/${filename}`
: `uploads/${client}/${filename}`;
const digest = createHash('sha256').update(body).digest('hex');
const previous = this.uploads.find((upload) => upload.key === requestKey);
if (previous) {
+27
View File
@@ -61,6 +61,7 @@ test('upload endpoint accepts a short-lived body-bound grant and rejects replay
const config = {
maxBodyBytes: 4096,
maxUploadBytes: 51200,
maxContentUploadBytes: 1048576,
syncClientSecrets: { core: 's'.repeat(32) },
uploadClientSecrets: { core2026: secret },
publicBases: ['https://sam-image.hided.net', 'https://sam.hided.net/image'],
@@ -86,6 +87,7 @@ test('upload endpoint accepts a short-lived body-bound grant and rejects replay
assert.equal(accepted.status, 201);
assert.deepEqual(calls[0], {
requestKey: `core2026:${requestId}`,
category: 'user-icons',
client: 'core2026',
filename: `${'a'.repeat(32)}.png`,
body,
@@ -95,6 +97,31 @@ test('upload endpoint accepts a short-lived body-bound grant and rejects replay
`https://sam.hided.net/image/icons/users/core2026/${'a'.repeat(32)}.png`,
]);
const contentPath = `/v1/uploads/content/core2026/${'b'.repeat(32)}.webp`;
const contentBody = Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('WEBP'), Buffer.alloc(4)]);
const contentRequestId = 'content-request-1234';
const contentSignature = uploadSignature(secret, {
expires,
requestId: contentRequestId,
pathname: contentPath,
contentType: 'image/webp',
body: contentBody,
});
const contentResponse = await fetch(`http://127.0.0.1:${address.port}${contentPath}`, {
method: 'PUT',
headers: {
'content-type': 'image/webp',
'x-image-client': 'core2026',
'x-image-expires': expires,
'x-image-request-id': contentRequestId,
'x-image-signature': contentSignature,
},
body: contentBody,
});
assert.equal(contentResponse.status, 201);
assert.equal(calls[1].category, 'content');
assert.equal(calls[1].filename, `${'b'.repeat(32)}.webp`);
const tampered = await fetch(`http://127.0.0.1:${address.port}${pathname}`, {
method: 'PUT', headers, body: Buffer.from('89504e470d0a1a0affffffff', 'hex'),
});
+16 -4
View File
@@ -13,19 +13,20 @@ test('stores uploads only in the bind directory and persists replay state', asyn
const filename = `${'a'.repeat(32)}.png`;
const store = new UploadStore(config);
await store.initialize();
const first = await store.store({ requestKey: 'core2026:request-1', client: 'core2026', filename, body });
const first = await store.store({ requestKey: 'core2026:request-1', category: 'user-icons', client: 'core2026', filename, body });
assert.deepEqual(first, { duplicate: false, path: `icons/users/core2026/${filename}` });
assert.equal(await readFile(join(root, 'uploads', 'core2026', filename), 'utf8'), 'immutable image bytes');
assert.equal(await readFile(join(root, 'uploads', 'user-icons', 'core2026', filename), 'utf8'), 'immutable image bytes');
const restarted = new UploadStore(config);
await restarted.initialize();
assert.deepEqual(
await restarted.store({ requestKey: 'core2026:request-1', client: 'core2026', filename, body }),
await restarted.store({ requestKey: 'core2026:request-1', category: 'user-icons', client: 'core2026', filename, body }),
{ duplicate: true, path: `icons/users/core2026/${filename}` },
);
await assert.rejects(
restarted.store({
requestKey: 'core2026:request-1',
category: 'user-icons',
client: 'core2026',
filename: `${'b'.repeat(32)}.png`,
body,
@@ -33,7 +34,18 @@ test('stores uploads only in the bind directory and persists replay state', asyn
/already used/,
);
await assert.rejects(
restarted.store({ requestKey: 'core2026:request-2', client: '../escape', filename, body }),
restarted.store({ requestKey: 'core2026:request-2', category: 'user-icons', client: '../escape', filename, body }),
/Invalid upload path/,
);
const contentFilename = `${'c'.repeat(32)}.webp`;
assert.deepEqual(
await restarted.store({
requestKey: 'core2026:content-1',
category: 'content',
client: 'core2026',
filename: contentFilename,
body: Buffer.from('content image'),
}),
{ duplicate: false, path: `uploads/core2026/${contentFilename}` },
);
});