Six fixes, each with a test: 1. Character pools are built as arrays of code points, so astral characters (emoji) are never split into lone surrogates. Entropy counts code points. 2. Boolean options are parsed strictly: true/false, 1/0, "true"/"false", "1"/"0", "yes"/"no", "on"/"off" (case-insensitive). Anything else is a 400 naming the option instead of a silent truthy fallback. 3. customSymbols is validated: at most 64 code points, duplicates dropped, control characters and whitespace rejected with a 400. 4. Random mode entropy with requireEach now counts only the passwords that contain a character of every enabled class (inclusion-exclusion). The value is an upper bound and is labelled as such in the API (entropyApproximate/entropyNote) and in the UI. 5. docker-compose.yml uses expose instead of ports, so it no longer collides with Dokploy's own port 3000. 6. SIGTERM/SIGINT shut the server down gracefully: close the listener, let in-flight requests finish, force-exit after 5s. Measured at ~60ms. The server now exports createServer() so the API can be tested over HTTP without binding a fixed port.
345 lines
9.0 KiB
JavaScript
345 lines
9.0 KiB
JavaScript
import { createServer as createHttpServer } from 'node:http';
|
|
import { readFileSync, statSync, realpathSync } from 'node:fs';
|
|
import { extname, resolve, sep } from 'node:path';
|
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
|
import {
|
|
entropyIsApproximate,
|
|
estimateEntropy,
|
|
generateMemorizable,
|
|
generateRandom,
|
|
} from './src/generator.js';
|
|
import { listMeta } from './src/words.js';
|
|
|
|
const PUBLIC_DIR = resolve(fileURLToPath(new URL('./public/', import.meta.url)));
|
|
const NOT_FOUND_PAGE = resolve(PUBLIC_DIR, '404.html');
|
|
const MAX_BODY_BYTES = 64 * 1024;
|
|
const PORT = Number.parseInt(process.env.PORT || '3000', 10);
|
|
const HOST = '0.0.0.0';
|
|
const SHUTDOWN_GRACE_MS = 5000;
|
|
|
|
const SECURITY_HEADERS = {
|
|
'X-Content-Type-Options': 'nosniff',
|
|
'Referrer-Policy': 'no-referrer',
|
|
'X-Frame-Options': 'DENY',
|
|
'Content-Security-Policy': "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'self'",
|
|
};
|
|
|
|
const CONTENT_TYPES = {
|
|
'.html': 'text/html; charset=utf-8',
|
|
'.css': 'text/css; charset=utf-8',
|
|
'.js': 'text/javascript; charset=utf-8',
|
|
'.txt': 'text/plain; charset=utf-8',
|
|
'.json': 'application/json; charset=utf-8',
|
|
'.svg': 'image/svg+xml',
|
|
'.png': 'image/png',
|
|
'.ico': 'image/x-icon',
|
|
};
|
|
|
|
function sendBuffer(req, res, status, body, headers) {
|
|
res.writeHead(status, {
|
|
...SECURITY_HEADERS,
|
|
'Content-Length': body.length,
|
|
...headers,
|
|
});
|
|
if (req.method === 'HEAD') {
|
|
res.end();
|
|
return;
|
|
}
|
|
res.end(body);
|
|
}
|
|
|
|
function sendJson(req, res, status, payload) {
|
|
const body = Buffer.from(JSON.stringify(payload));
|
|
sendBuffer(req, res, status, body, {
|
|
'Content-Type': 'application/json; charset=utf-8',
|
|
'Cache-Control': 'no-store',
|
|
});
|
|
}
|
|
|
|
function sendError(req, res, status, message) {
|
|
sendJson(req, res, status, { error: message });
|
|
}
|
|
|
|
function sendNotFoundPage(req, res) {
|
|
let body;
|
|
try {
|
|
body = readFileSync(NOT_FOUND_PAGE);
|
|
} catch {
|
|
body = Buffer.from('<!doctype html><title>Not found</title><h1>Not found</h1>');
|
|
}
|
|
sendBuffer(req, res, 404, body, {
|
|
'Content-Type': 'text/html; charset=utf-8',
|
|
'Cache-Control': 'no-store',
|
|
});
|
|
}
|
|
|
|
function contentTypeFor(filePath) {
|
|
return CONTENT_TYPES[extname(filePath).toLowerCase()] || 'application/octet-stream';
|
|
}
|
|
|
|
function resolveStaticPath(pathname) {
|
|
let decoded;
|
|
try {
|
|
decoded = decodeURIComponent(pathname);
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (decoded.includes('\0')) return null;
|
|
|
|
const relative = decoded === '/' ? 'index.html' : decoded.replace(/^\/+/, '');
|
|
const target = resolve(PUBLIC_DIR, relative);
|
|
if (target !== PUBLIC_DIR && !target.startsWith(PUBLIC_DIR + sep)) {
|
|
return null;
|
|
}
|
|
return target;
|
|
}
|
|
|
|
function serveStatic(req, res, pathname) {
|
|
const target = resolveStaticPath(pathname);
|
|
if (!target) {
|
|
sendNotFoundPage(req, res);
|
|
return;
|
|
}
|
|
|
|
let stats;
|
|
let real;
|
|
try {
|
|
stats = statSync(target);
|
|
real = realpathSync(target);
|
|
} catch {
|
|
sendNotFoundPage(req, res);
|
|
return;
|
|
}
|
|
|
|
if (!stats.isFile() || (real !== PUBLIC_DIR && !real.startsWith(PUBLIC_DIR + sep))) {
|
|
sendNotFoundPage(req, res);
|
|
return;
|
|
}
|
|
|
|
let body;
|
|
try {
|
|
body = readFileSync(real);
|
|
} catch {
|
|
sendNotFoundPage(req, res);
|
|
return;
|
|
}
|
|
|
|
sendBuffer(req, res, 200, body, {
|
|
'Content-Type': contentTypeFor(real),
|
|
'Cache-Control': 'no-cache',
|
|
});
|
|
}
|
|
|
|
function readJsonBody(req) {
|
|
return new Promise((resolvePromise, rejectPromise) => {
|
|
const chunks = [];
|
|
let size = 0;
|
|
let tooLarge = false;
|
|
|
|
req.on('data', (chunk) => {
|
|
size += chunk.length;
|
|
if (size > MAX_BODY_BYTES) {
|
|
tooLarge = true;
|
|
chunks.length = 0;
|
|
if (size > MAX_BODY_BYTES * 8) {
|
|
req.destroy();
|
|
}
|
|
return;
|
|
}
|
|
chunks.push(chunk);
|
|
});
|
|
|
|
req.on('error', (err) => {
|
|
rejectPromise(Object.assign(err, { statusCode: 400 }));
|
|
});
|
|
|
|
req.on('end', () => {
|
|
if (tooLarge) {
|
|
rejectPromise(Object.assign(new Error('request body too large (limit 64 KB)'), { statusCode: 413 }));
|
|
return;
|
|
}
|
|
const raw = Buffer.concat(chunks).toString('utf8');
|
|
if (raw.trim() === '') {
|
|
resolvePromise({});
|
|
return;
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(raw);
|
|
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
rejectPromise(Object.assign(new Error('request body must be a JSON object'), { statusCode: 400 }));
|
|
return;
|
|
}
|
|
resolvePromise(parsed);
|
|
} catch {
|
|
rejectPromise(Object.assign(new Error('malformed JSON body'), { statusCode: 400 }));
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function countOption(body) {
|
|
const raw = body.count === undefined || body.count === null ? 1 : Number(body.count);
|
|
if (!Number.isInteger(raw) || raw < 1 || raw > 10) {
|
|
throw Object.assign(new Error('count must be an integer between 1 and 10'), { statusCode: 400 });
|
|
}
|
|
return raw;
|
|
}
|
|
|
|
async function handleGenerate(req, res, body) {
|
|
const mode = body.mode === undefined || body.mode === null ? 'memorable' : String(body.mode);
|
|
if (mode !== 'memorable' && mode !== 'random') {
|
|
sendError(req, res, 400, `unknown mode "${mode}"`);
|
|
return;
|
|
}
|
|
|
|
let count;
|
|
try {
|
|
count = countOption(body);
|
|
} catch (err) {
|
|
sendError(req, res, err.statusCode || 400, err.message);
|
|
return;
|
|
}
|
|
|
|
let entropy;
|
|
let approximate;
|
|
try {
|
|
entropy = estimateEntropy(mode, body);
|
|
approximate = entropyIsApproximate(mode, body);
|
|
} catch (err) {
|
|
sendError(req, res, 400, err.message);
|
|
return;
|
|
}
|
|
|
|
const passwords = [];
|
|
for (let i = 0; i < count; i += 1) {
|
|
let value;
|
|
try {
|
|
value = mode === 'memorable' ? generateMemorizable(body) : generateRandom(body);
|
|
} catch (err) {
|
|
sendError(req, res, 400, err.message);
|
|
return;
|
|
}
|
|
passwords.push({ value, mode, entropy, entropyApproximate: approximate });
|
|
}
|
|
|
|
sendJson(req, res, 200, {
|
|
mode,
|
|
count,
|
|
passwords,
|
|
entropy,
|
|
entropyApproximate: approximate,
|
|
entropyNote: approximate
|
|
? 'upper bound: the generator samples the valid passwords non-uniformly'
|
|
: 'exact: every character is drawn uniformly from the enabled sets',
|
|
});
|
|
}
|
|
|
|
async function handleApi(req, res, pathname) {
|
|
if (pathname === '/api/meta') {
|
|
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
sendError(req, res, 405, 'method not allowed');
|
|
return;
|
|
}
|
|
sendJson(req, res, 200, { lists: listMeta() });
|
|
return;
|
|
}
|
|
|
|
if (pathname === '/api/generate') {
|
|
if (req.method !== 'POST') {
|
|
sendError(req, res, 405, 'method not allowed');
|
|
return;
|
|
}
|
|
let body;
|
|
try {
|
|
body = await readJsonBody(req);
|
|
} catch (err) {
|
|
sendError(req, res, err.statusCode || 400, err.message);
|
|
return;
|
|
}
|
|
await handleGenerate(req, res, body);
|
|
return;
|
|
}
|
|
|
|
sendError(req, res, 404, 'not found');
|
|
}
|
|
|
|
function createRequestHandler() {
|
|
return (req, res) => {
|
|
let pathname = '/';
|
|
try {
|
|
pathname = new URL(req.url || '/', 'http://localhost').pathname;
|
|
} catch {
|
|
sendError(req, res, 400, 'bad request');
|
|
return;
|
|
}
|
|
|
|
if (pathname === '/healthz') {
|
|
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
sendError(req, res, 405, 'method not allowed');
|
|
return;
|
|
}
|
|
sendJson(req, res, 200, { status: 'ok' });
|
|
return;
|
|
}
|
|
|
|
if (pathname.startsWith('/api/')) {
|
|
handleApi(req, res, pathname).catch((err) => {
|
|
sendError(req, res, 500, err && err.message ? err.message : 'internal error');
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
sendError(req, res, 405, 'method not allowed');
|
|
return;
|
|
}
|
|
|
|
serveStatic(req, res, pathname);
|
|
};
|
|
}
|
|
|
|
/** Creates the HTTP server without binding it, so it can be used in tests. */
|
|
export function createServer() {
|
|
return createHttpServer(createRequestHandler());
|
|
}
|
|
|
|
/**
|
|
* Starts the server and installs graceful shutdown: SIGTERM/SIGINT stop
|
|
* accepting connections, let the in-flight ones finish, and force an exit
|
|
* after a 5 second grace period so container stops are quick.
|
|
*/
|
|
export function startServer({ port = PORT, host = HOST } = {}) {
|
|
const server = createServer();
|
|
|
|
const shutdown = (signal) => {
|
|
console.log(`reze-test: ${signal} received, shutting down`);
|
|
server.close(() => {
|
|
console.log('reze-test: connections closed, exiting');
|
|
process.exit(0);
|
|
});
|
|
if (typeof server.closeIdleConnections === 'function') {
|
|
server.closeIdleConnections();
|
|
}
|
|
const forced = setTimeout(() => {
|
|
console.error('reze-test: shutdown timed out, forcing exit');
|
|
process.exit(1);
|
|
}, SHUTDOWN_GRACE_MS);
|
|
forced.unref();
|
|
};
|
|
|
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
|
|
server.listen(port, host, () => {
|
|
console.log(`reze-test listening on http://${host}:${port}`);
|
|
});
|
|
|
|
return server;
|
|
}
|
|
|
|
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
if (isMain) {
|
|
startServer();
|
|
}
|