117 lines
3.6 KiB
TypeScript
117 lines
3.6 KiB
TypeScript
import type * as http from 'node:http';
|
|
import type { TRouteHandler } from './server.interfaces.js';
|
|
import type { NonceManager } from './server.classes.nonce.js';
|
|
import { AcmeServerError } from './server.classes.jws.verifier.js';
|
|
|
|
interface IRoute {
|
|
method: string;
|
|
pattern: string;
|
|
segments: string[];
|
|
handler: TRouteHandler;
|
|
}
|
|
|
|
/**
|
|
* Minimal HTTP router for the ACME server.
|
|
* Supports parameterized paths like /order/:id.
|
|
*/
|
|
export class AcmeRouter {
|
|
private routes: IRoute[] = [];
|
|
private nonceManager: NonceManager;
|
|
|
|
constructor(nonceManager: NonceManager) {
|
|
this.nonceManager = nonceManager;
|
|
}
|
|
|
|
addRoute(method: string, pattern: string, handler: TRouteHandler): void {
|
|
this.routes.push({
|
|
method: method.toUpperCase(),
|
|
pattern,
|
|
segments: pattern.split('/').filter(Boolean),
|
|
handler,
|
|
});
|
|
}
|
|
|
|
async handle(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
|
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
|
const method = (req.method || 'GET').toUpperCase();
|
|
const pathSegments = url.pathname.split('/').filter(Boolean);
|
|
|
|
// Always add a fresh nonce to every response
|
|
res.setHeader('Replay-Nonce', this.nonceManager.generate());
|
|
res.setHeader('Cache-Control', 'no-store');
|
|
|
|
// Find matching route
|
|
for (const route of this.routes) {
|
|
if (route.method !== method) continue;
|
|
const params = this.matchPath(route.segments, pathSegments);
|
|
if (params === null) continue;
|
|
|
|
try {
|
|
const body = method === 'POST' ? await this.parseBody(req) : undefined;
|
|
await route.handler(req, res, params, body);
|
|
} catch (err) {
|
|
this.sendError(res, err);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// No route found
|
|
this.sendError(res, new AcmeServerError(404, 'urn:ietf:params:acme:error:malformed', 'Not found'));
|
|
}
|
|
|
|
private matchPath(
|
|
routeSegments: string[],
|
|
pathSegments: string[],
|
|
): Record<string, string> | null {
|
|
if (routeSegments.length !== pathSegments.length) return null;
|
|
const params: Record<string, string> = {};
|
|
for (let i = 0; i < routeSegments.length; i++) {
|
|
if (routeSegments[i].startsWith(':')) {
|
|
params[routeSegments[i].slice(1)] = pathSegments[i];
|
|
} else if (routeSegments[i] !== pathSegments[i]) {
|
|
return null;
|
|
}
|
|
}
|
|
return params;
|
|
}
|
|
|
|
private parseBody(req: http.IncomingMessage): Promise<any> {
|
|
return new Promise((resolve, reject) => {
|
|
const chunks: Buffer[] = [];
|
|
req.on('data', (chunk: Buffer) => chunks.push(chunk));
|
|
req.on('end', () => {
|
|
const raw = Buffer.concat(chunks).toString('utf-8');
|
|
if (!raw) {
|
|
resolve(undefined);
|
|
return;
|
|
}
|
|
try {
|
|
resolve(JSON.parse(raw));
|
|
} catch {
|
|
reject(new AcmeServerError(400, 'urn:ietf:params:acme:error:malformed', 'Invalid JSON body'));
|
|
}
|
|
});
|
|
req.on('error', reject);
|
|
});
|
|
}
|
|
|
|
private sendError(res: http.ServerResponse, err: unknown): void {
|
|
if (err instanceof AcmeServerError) {
|
|
res.writeHead(err.status, { 'Content-Type': 'application/problem+json' });
|
|
res.end(JSON.stringify({
|
|
type: err.type,
|
|
detail: err.detail,
|
|
status: err.status,
|
|
}));
|
|
} else {
|
|
const message = err instanceof Error ? err.message : 'Internal server error';
|
|
res.writeHead(500, { 'Content-Type': 'application/problem+json' });
|
|
res.end(JSON.stringify({
|
|
type: 'urn:ietf:params:acme:error:serverInternal',
|
|
detail: message,
|
|
status: 500,
|
|
}));
|
|
}
|
|
}
|
|
}
|