reze: Add Node.js password generator with memorable and random modes

Public password generator with no auth and no npm dependencies.

- Server-side generation using crypto.randomInt; two levels: diceware-style
  memorable passphrases (42abacus-91abdomen) and fully random characters.
- Configurable word list (EFF Large / EFF Short), digits per word, separators,
  capitalization, length, character class toggles, custom symbols and
  ambiguous-character exclusion.
- Light/dark theme matching druid520/site, entropy estimate and strength bar,
  clipboard copy, options persisted in localStorage.
- Dockerfile for Dokploy plus a Woodpecker deploy hook on trunk.
This commit is contained in:
2026-10-21 09:01:42 +00:00
parent aa56937a20
commit 58f585b691
19 changed files with 10974 additions and 0 deletions
+289
View File
@@ -0,0 +1,289 @@
import { createServer } from 'node:http';
import { readFileSync, statSync, realpathSync } from 'node:fs';
import { extname, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
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 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;
try {
entropy = estimateEntropy(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 });
}
sendJson(req, res, 200, { mode, count, passwords, entropy });
}
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');
}
const server = createServer((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);
});
server.listen(PORT, HOST, () => {
console.log(`reze-test listening on http://${HOST}:${PORT}`);
});