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 })); }); }