forked from devsam/image
218 lines
9.9 KiB
JavaScript
218 lines
9.9 KiB
JavaScript
import { createServer } from 'node:http';
|
|
import { readFile } from 'node:fs/promises';
|
|
import { loadConfig } from './config.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);
|
|
response.writeHead(status, {
|
|
'content-type': 'application/json; charset=utf-8',
|
|
'content-length': Buffer.byteLength(body),
|
|
'cache-control': 'no-store',
|
|
});
|
|
response.end(body);
|
|
}
|
|
|
|
async function readBody(request, limit) {
|
|
const chunks = [];
|
|
let size = 0;
|
|
for await (const chunk of request) {
|
|
size += chunk.length;
|
|
if (size > limit) {
|
|
throw new DeploymentError('Request body too large', 413);
|
|
}
|
|
chunks.push(chunk);
|
|
}
|
|
return Buffer.concat(chunks);
|
|
}
|
|
|
|
function parseJson(body) {
|
|
try {
|
|
return JSON.parse(body.toString('utf8'));
|
|
} catch {
|
|
throw new DeploymentError('Invalid JSON body', 400);
|
|
}
|
|
}
|
|
|
|
function hasImageSignature(body, extension) {
|
|
if (extension === 'png') return body.length >= 8 && body.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex'));
|
|
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';
|
|
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');
|
|
try {
|
|
if (request.method === 'GET' && url.pathname === '/healthz') {
|
|
return json(response, 200, { ok: true });
|
|
}
|
|
if (request.method === 'GET' && url.pathname === '/v1/status') {
|
|
return json(response, 200, service.publicStatus());
|
|
}
|
|
if (request.method === 'GET' && url.pathname === '/v1/inventory') {
|
|
const inventory = JSON.parse(await readFile(`${config.repositoryPath}/hook/inventory.v2.json`, 'utf8'));
|
|
return json(response, 200, inventory);
|
|
}
|
|
if (request.method === 'POST' && url.pathname === '/v1/hooks/gitea') {
|
|
if (!request.headers['content-type']?.toLowerCase().startsWith('application/json')) {
|
|
throw new DeploymentError('Content-Type must be application/json', 415);
|
|
}
|
|
const body = await readBody(request, config.maxBodyBytes);
|
|
if (!verifyHexHmac(config.webhookSecret, body, request.headers['x-gitea-signature'])) {
|
|
return json(response, 401, { ok: false, reason: 'invalid signature' });
|
|
}
|
|
if (request.headers['x-gitea-event'] !== 'push') {
|
|
return json(response, 202, { ok: true, ignored: true, reason: 'unsupported event' });
|
|
}
|
|
const payload = parseJson(body);
|
|
if (payload.repository?.full_name !== config.repositoryFullName) {
|
|
return json(response, 403, { ok: false, reason: 'unexpected repository' });
|
|
}
|
|
if (typeof payload.ref !== 'string' || !payload.ref.startsWith('refs/heads/')) {
|
|
return json(response, 202, { ok: true, ignored: true, reason: 'non-branch ref' });
|
|
}
|
|
if (typeof payload.after !== 'string' || !/^[0-9a-f]{40,64}$/i.test(payload.after)) {
|
|
throw new DeploymentError('Invalid target commit', 400);
|
|
}
|
|
const result = await service.deployWebhook({
|
|
deliveryId: request.headers['x-gitea-delivery'],
|
|
branch: payload.ref.slice('refs/heads/'.length),
|
|
after: payload.after,
|
|
});
|
|
return json(response, 200, { ok: true, ...result });
|
|
}
|
|
if (request.method === 'POST' && url.pathname === '/v1/admin/deploy') {
|
|
if (!request.headers['content-type']?.toLowerCase().startsWith('application/json')) {
|
|
throw new DeploymentError('Content-Type must be application/json', 415);
|
|
}
|
|
const body = await readBody(request, config.maxBodyBytes);
|
|
const timestamp = request.headers['x-image-timestamp'];
|
|
const requestId = request.headers['x-image-request-id'];
|
|
if (!verifyAdminSignature({
|
|
secret: config.adminSecret,
|
|
timestamp,
|
|
requestId,
|
|
body,
|
|
supplied: request.headers['x-image-signature'],
|
|
})) {
|
|
return json(response, 401, { ok: false, reason: 'invalid admin signature' });
|
|
}
|
|
const payload = parseJson(body);
|
|
const result = await service.deployAdmin({ requestId, branch: payload.branch, expectedCommit: payload.commit });
|
|
return json(response, 200, { ok: true, ...result });
|
|
}
|
|
if (request.method === 'POST' && url.pathname === '/v1/sync') {
|
|
if (!request.headers['content-type']?.toLowerCase().startsWith('application/json')) {
|
|
throw new DeploymentError('Content-Type must be application/json', 415);
|
|
}
|
|
const body = await readBody(request, config.maxBodyBytes);
|
|
const client = request.headers['x-image-client'];
|
|
const timestamp = request.headers['x-image-timestamp'];
|
|
const requestId = request.headers['x-image-request-id'];
|
|
const knownClient = typeof client === 'string'
|
|
&& Object.hasOwn(config.syncClientSecrets, client);
|
|
const signatureValid = verifyAdminSignature({
|
|
secret: knownClient ? config.syncClientSecrets[client] : 'invalid-client-secret'.padEnd(32, '!'),
|
|
timestamp,
|
|
requestId,
|
|
body,
|
|
supplied: request.headers['x-image-signature'],
|
|
});
|
|
if (!knownClient || !signatureValid) {
|
|
return json(response, 401, { ok: false, reason: 'invalid sync signature' });
|
|
}
|
|
const payload = parseJson(body);
|
|
if (!payload || Array.isArray(payload) || typeof payload !== 'object'
|
|
|| Object.keys(payload).some((key) => key !== 'commit')) {
|
|
throw new DeploymentError('Sync body may only contain commit', 400);
|
|
}
|
|
if (payload.commit !== undefined
|
|
&& (typeof payload.commit !== 'string' || !/^[0-9a-f]{40,64}$/i.test(payload.commit))) {
|
|
throw new DeploymentError('Invalid target commit', 400);
|
|
}
|
|
const result = await service.deploySync({
|
|
requestKey: `${client}:${requestId}`,
|
|
expectedCommit: payload.commit,
|
|
});
|
|
return json(response, 200, { ok: true, ...result });
|
|
}
|
|
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|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', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
|
|
};
|
|
if (contentType !== mimeByExtension[match[4]]) {
|
|
throw new DeploymentError('Content-Type does not match upload path', 415);
|
|
}
|
|
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,
|
|
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[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[3]}.${match[4]}`,
|
|
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);
|
|
console.error(JSON.stringify({ level: 'error', message: error.message, at: new Date().toISOString() }));
|
|
return json(response, error instanceof DeploymentError ? error.status : 500, {
|
|
ok: false,
|
|
reason: error instanceof DeploymentError ? error.message : 'internal error',
|
|
});
|
|
}
|
|
});
|
|
|
|
return { server, service, uploadStore };
|
|
}
|
|
|
|
if (process.argv[1] === new URL(import.meta.url).pathname) {
|
|
const config = loadConfig();
|
|
const { server } = await createApp(config);
|
|
server.listen(config.port, '0.0.0.0', () => {
|
|
console.log(JSON.stringify({ level: 'info', message: 'image hook listening', port: config.port }));
|
|
});
|
|
}
|