forked from devsam/image
feat: add signed bind-backed user icon uploads
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
import { createServer } from 'node:http';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { loadConfig } from './config.mjs';
|
||||
import { verifyAdminSignature, verifyHexHmac } from './auth.mjs';
|
||||
import { verifyAdminSignature, verifyHexHmac, verifyUploadSignature } from './auth.mjs';
|
||||
import { DeploymentError, GitService } from './git-service.mjs';
|
||||
import { UploadStore } from './upload-store.mjs';
|
||||
|
||||
function json(response, status, value) {
|
||||
const body = JSON.stringify(value);
|
||||
@@ -35,9 +36,22 @@ 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 === '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';
|
||||
if (extension === 'avif') return body.length >= 16 && body.subarray(4, 8).toString('ascii') === 'ftyp'
|
||||
&& (body.subarray(8, 64).includes(Buffer.from('avif')) || body.subarray(8, 64).includes(Buffer.from('avis')));
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function createApp(config = loadConfig(), dependencies = {}) {
|
||||
const service = dependencies.service ?? new GitService(config);
|
||||
const uploadStore = dependencies.uploadStore ?? new UploadStore(config);
|
||||
await service.initialize();
|
||||
await uploadStore.initialize();
|
||||
|
||||
const server = createServer(async (request, response) => {
|
||||
const url = new URL(request.url, 'http://image-hook');
|
||||
@@ -135,6 +149,47 @@ 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/')) {
|
||||
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) {
|
||||
throw new DeploymentError('Invalid upload path', 400);
|
||||
}
|
||||
const mimeByExtension = {
|
||||
avif: 'image/avif', webp: 'image/webp', jpg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
|
||||
};
|
||||
if (contentType !== mimeByExtension[match[3]]) {
|
||||
throw new DeploymentError('Content-Type does not match upload path', 415);
|
||||
}
|
||||
const body = await readBody(request, config.maxUploadBytes);
|
||||
const signatureValid = verifyUploadSignature({
|
||||
secret: knownClient ? config.uploadClientSecrets[client] : 'invalid-client-secret'.padEnd(32, '!'),
|
||||
expires,
|
||||
requestId,
|
||||
pathname: url.pathname,
|
||||
contentType,
|
||||
body,
|
||||
supplied: request.headers['x-image-signature'],
|
||||
});
|
||||
if (!knownClient || !signatureValid) {
|
||||
return json(response, 401, { ok: false, reason: 'invalid or expired upload grant' });
|
||||
}
|
||||
if (!hasImageSignature(body, match[3])) {
|
||||
throw new DeploymentError('Body is not the declared image format', 400);
|
||||
}
|
||||
const result = await uploadStore.store({
|
||||
requestKey: `${client}:${requestId}`,
|
||||
client,
|
||||
filename: `${match[2]}.${match[3]}`,
|
||||
body,
|
||||
});
|
||||
const urls = config.publicBases.map((base) => `${base}/${result.path}`);
|
||||
return json(response, 201, { ok: true, ...result, urls });
|
||||
}
|
||||
return json(response, 404, { ok: false, reason: 'not found' });
|
||||
} catch (error) {
|
||||
await service.recordError(error).catch(() => undefined);
|
||||
@@ -146,7 +201,7 @@ export async function createApp(config = loadConfig(), dependencies = {}) {
|
||||
}
|
||||
});
|
||||
|
||||
return { server, service };
|
||||
return { server, service, uploadStore };
|
||||
}
|
||||
|
||||
if (process.argv[1] === new URL(import.meta.url).pathname) {
|
||||
|
||||
Reference in New Issue
Block a user