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
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Not found · reze-test</title>
<link rel="stylesheet" href="/style.css">
<script src="/theme.js"></script>
</head>
<body>
<main class="error">
<div class="topbar"><button id="theme-toggle" class="theme-toggle" type="button" hidden>Dark mode</button></div>
<h2>Error 404</h2>
<h1>Not found</h1>
<p>There's nothing at this address.</p>
<a href="/">Back to reze-test</a>
</main>
</body>
</html>
+379
View File
@@ -0,0 +1,379 @@
(function () {
'use strict';
var STORAGE_KEY = 'opts';
var DEFAULTS = {
mode: 'memorable',
words: 4,
digits: 2,
digitSeparator: '',
separator: '-',
capitalization: 'none',
list: 'eff-large',
length: 20,
useLower: true,
useUpper: true,
useDigits: true,
useSymbols: false,
customSymbols: '',
excludeAmbiguous: false,
requireEach: true,
count: 1
};
var FIELDS = [
{ key: 'words', id: 'words', type: 'int', min: 2, max: 10 },
{ key: 'digits', id: 'digits', type: 'int', min: 0, max: 4 },
{ key: 'digitSeparator', id: 'digitSeparator', type: 'str' },
{ key: 'separator', id: 'separator', type: 'str' },
{ key: 'capitalization', id: 'capitalization', type: 'str' },
{ key: 'list', id: 'list', type: 'str' },
{ key: 'length', id: 'length', type: 'int', min: 8, max: 64 },
{ key: 'count', id: 'count', type: 'int', min: 1, max: 10 },
{ key: 'customSymbols', id: 'customSymbols', type: 'str' },
{ key: 'useLower', id: 'useLower', type: 'bool' },
{ key: 'useUpper', id: 'useUpper', type: 'bool' },
{ key: 'useDigits', id: 'useDigits', type: 'bool' },
{ key: 'useSymbols', id: 'useSymbols', type: 'bool' },
{ key: 'excludeAmbiguous', id: 'excludeAmbiguous', type: 'bool' },
{ key: 'requireEach', id: 'requireEach', type: 'bool' }
];
var STRENGTH_LABELS = ['very weak', 'weak', 'fair', 'strong', 'very strong'];
var STRENGTH_THRESHOLDS = [28, 40, 60, 80];
function byId(id) {
return document.getElementById(id);
}
function clampInt(value, fallback, min, max) {
var n = parseInt(value, 10);
if (isNaN(n)) return fallback;
if (n < min) return min;
if (n > max) return max;
return n;
}
function loadState() {
var state = {};
var key;
for (key in DEFAULTS) {
if (Object.prototype.hasOwnProperty.call(DEFAULTS, key)) state[key] = DEFAULTS[key];
}
try {
var raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
var saved = JSON.parse(raw);
if (saved && typeof saved === 'object') {
for (key in saved) {
if (Object.prototype.hasOwnProperty.call(state, key)) state[key] = saved[key];
}
}
}
} catch (e) {}
return state;
}
function saveState(state) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch (e) {}
}
function readState() {
var state = {};
var i;
var field;
var el;
for (i = 0; i < FIELDS.length; i += 1) {
field = FIELDS[i];
el = byId(field.id);
if (!el) continue;
if (field.type === 'bool') {
state[field.key] = el.checked;
} else if (field.type === 'int') {
state[field.key] = clampInt(el.value, DEFAULTS[field.key], field.min, field.max);
} else {
state[field.key] = el.value;
}
}
var checked = document.querySelector('input[name="mode"]:checked');
state.mode = checked ? checked.value : DEFAULTS.mode;
return state;
}
function applyState(state) {
var i;
var field;
var el;
for (i = 0; i < FIELDS.length; i += 1) {
field = FIELDS[i];
el = byId(field.id);
if (!el) continue;
if (field.type === 'bool') {
el.checked = Boolean(state[field.key]);
} else if (field.type === 'int') {
el.value = clampInt(state[field.key], DEFAULTS[field.key], field.min, field.max);
} else {
el.value = state[field.key] === undefined || state[field.key] === null ? '' : state[field.key];
}
}
var radios = document.querySelectorAll('input[name="mode"]');
for (i = 0; i < radios.length; i += 1) {
radios[i].checked = radios[i].value === state.mode;
}
}
function updatePanels(state) {
var memorable = byId('panel-memorable');
var random = byId('panel-random');
if (memorable) memorable.hidden = state.mode !== 'memorable';
if (random) random.hidden = state.mode !== 'random';
}
function updateLengthOut() {
var length = byId('length');
var out = byId('length-out');
if (length && out) out.textContent = length.value;
}
function strengthLevel(bits) {
var i;
for (i = 0; i < STRENGTH_THRESHOLDS.length; i += 1) {
if (bits < STRENGTH_THRESHOLDS[i]) return i;
}
return STRENGTH_LABELS.length - 1;
}
function buildStrength(bits) {
var level = strengthLevel(bits);
var wrap = document.createElement('span');
wrap.className = 'strength';
wrap.setAttribute('role', 'img');
wrap.setAttribute('aria-label', 'strength: ' + STRENGTH_LABELS[level]);
var i;
for (i = 0; i < STRENGTH_LABELS.length; i += 1) {
var seg = document.createElement('i');
if (i <= level) seg.className = 'on';
wrap.appendChild(seg);
}
return wrap;
}
function showError(message) {
var box = byId('error');
if (!box) return;
if (message) {
box.textContent = message;
box.hidden = false;
} else {
box.textContent = '';
box.hidden = true;
}
}
function copyWithFallback(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(text);
}
return new Promise(function (resolve, reject) {
var area = document.createElement('textarea');
area.value = text;
area.setAttribute('readonly', 'readonly');
area.style.position = 'absolute';
area.style.left = '-9999px';
document.body.appendChild(area);
area.select();
var ok = false;
try {
ok = document.execCommand('copy');
} catch (e) {
ok = false;
}
document.body.removeChild(area);
if (ok) {
resolve();
} else {
reject(new Error('copy failed'));
}
});
}
function copyPassword(value, button) {
var original = button.getAttribute('data-label') || 'Copy';
copyWithFallback(value).then(function () {
button.textContent = 'Copied';
}, function () {
button.textContent = 'Failed';
}).then(function () {
window.setTimeout(function () {
button.textContent = original;
}, 1200);
});
}
function renderPasswords(data) {
var container = byId('results');
if (!container) return;
container.textContent = '';
var list = (data && data.passwords) || [];
for (let i = 0; i < list.length; i += 1) {
const item = list[i];
const row = document.createElement('div');
row.className = 'pw';
const value = document.createElement('pre');
value.className = 'pw-value';
value.textContent = item.value;
const meta = document.createElement('div');
meta.className = 'pw-meta';
const entropy = document.createElement('span');
entropy.className = 'pw-entropy';
entropy.textContent = item.entropy + ' bits';
const level = strengthLevel(item.entropy);
const label = document.createElement('span');
label.className = 'pw-strength-label';
label.textContent = STRENGTH_LABELS[level];
const copy = document.createElement('button');
copy.type = 'button';
copy.className = 'button copy';
copy.textContent = 'Copy';
copy.setAttribute('data-label', 'Copy');
copy.setAttribute('aria-label', 'Copy password');
copy.addEventListener('click', function () {
copyPassword(item.value, copy);
});
meta.appendChild(entropy);
meta.appendChild(buildStrength(item.entropy));
meta.appendChild(label);
meta.appendChild(copy);
row.appendChild(value);
row.appendChild(meta);
container.appendChild(row);
}
}
function payloadFor(state) {
if (state.mode === 'random') {
return {
mode: 'random',
length: state.length,
useLower: state.useLower,
useUpper: state.useUpper,
useDigits: state.useDigits,
useSymbols: state.useSymbols,
customSymbols: state.customSymbols,
excludeAmbiguous: state.excludeAmbiguous,
requireEach: state.requireEach,
count: state.count
};
}
return {
mode: 'memorable',
words: state.words,
digits: state.digits,
digitSeparator: state.digitSeparator,
separator: state.separator,
capitalization: state.capitalization,
list: state.list,
count: state.count
};
}
function generate() {
var state = readState();
saveState(state);
showError('');
fetch('/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payloadFor(state))
}).then(function (response) {
return response.json().then(function (data) {
if (!response.ok) {
throw new Error(data && data.error ? data.error : 'request failed with status ' + response.status);
}
return data;
});
}).then(function (data) {
renderPasswords(data);
}, function (err) {
showError(err && err.message ? err.message : 'request failed');
});
}
function loadMeta() {
fetch('/api/meta', { headers: { Accept: 'application/json' } }).then(function (response) {
return response.json();
}).then(function (data) {
var lists = (data && data.lists) || [];
var i;
for (i = 0; i < lists.length; i += 1) {
var option = document.querySelector('#list option[value="' + lists[i].id + '"]');
if (option) option.textContent = lists[i].name + ' (' + lists[i].size + ' words)';
}
}, function () {});
}
function init() {
var state = loadState();
applyState(state);
updatePanels(state);
updateLengthOut();
var i;
var field;
for (i = 0; i < FIELDS.length; i += 1) {
field = FIELDS[i];
(function (f) {
var el = byId(f.id);
if (!el) return;
var handler = function () {
var current = readState();
saveState(current);
updatePanels(current);
updateLengthOut();
};
el.addEventListener('change', handler);
el.addEventListener('input', handler);
})(field);
}
var radios = document.querySelectorAll('input[name="mode"]');
for (i = 0; i < radios.length; i += 1) {
radios[i].addEventListener('change', function () {
var current = readState();
saveState(current);
updatePanels(current);
});
}
var generateBtn = byId('generate');
if (generateBtn) generateBtn.addEventListener('click', generate);
var regenerateBtn = byId('regenerate');
if (regenerateBtn) regenerateBtn.addEventListener('click', generate);
loadMeta();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
+117
View File
@@ -0,0 +1,117 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>reze-test</title>
<meta name="description" content="Password generator: diceware-style passphrases or fully random characters. Nothing is stored.">
<link rel="stylesheet" href="/style.css">
<script src="/theme.js"></script>
</head>
<body>
<main>
<div class="topbar"><button id="theme-toggle" class="theme-toggle" type="button" hidden>Dark mode</button></div>
<h1>reze-test</h1>
<p class="lead">Password generator. Diceware-style passphrases or fully random characters &mdash; generated locally on the server with a CSPRNG. Nothing you generate is stored.</p>
<h2>Mode</h2>
<div class="radios">
<label class="radio"><input type="radio" name="mode" value="memorable" checked> Memorable</label>
<label class="radio"><input type="radio" name="mode" value="random"> Random</label>
</div>
<section id="panel-memorable">
<h2>Passphrase</h2>
<div class="field">
<label for="words">Words per passphrase</label>
<input type="number" id="words" min="2" max="10" step="1" value="4">
</div>
<div class="field">
<label for="digits">Digits per word</label>
<input type="number" id="digits" min="0" max="4" step="1" value="2">
</div>
<div class="field">
<label for="digitSeparator">Digit separator</label>
<select id="digitSeparator">
<option value="" selected>none</option>
<option value="-">- (hyphen)</option>
</select>
</div>
<div class="field">
<label for="separator">Group separator</label>
<select id="separator">
<option value="-">- (hyphen)</option>
<option value="_">_ (underscore)</option>
<option value=".">. (dot)</option>
<option value=" ">space</option>
<option value="">none</option>
</select>
</div>
<div class="field">
<label for="capitalization">Capitalization</label>
<select id="capitalization">
<option value="none" selected>none</option>
<option value="title">Title case</option>
<option value="upper">UPPERCASE</option>
</select>
</div>
<div class="field">
<label for="list">Word set</label>
<select id="list">
<option value="eff-large">EFF Large Word List</option>
<option value="eff-short">EFF Short Word List (2.0)</option>
</select>
</div>
</section>
<section id="panel-random" hidden>
<h2>Random</h2>
<div class="field">
<label for="length">Length</label>
<div class="range-row">
<input type="range" id="length" min="8" max="64" step="1" value="20">
<output id="length-out" for="length">20</output>
</div>
</div>
<div class="checks">
<label class="check"><input type="checkbox" id="useLower" checked> lowercase</label>
<label class="check"><input type="checkbox" id="useUpper" checked> uppercase</label>
<label class="check"><input type="checkbox" id="useDigits" checked> digits</label>
<label class="check"><input type="checkbox" id="useSymbols"> symbols</label>
<label class="check"><input type="checkbox" id="excludeAmbiguous"> exclude ambiguous characters</label>
<label class="check"><input type="checkbox" id="requireEach" checked> require at least one of each selected set</label>
</div>
<div class="field">
<label for="customSymbols">Custom symbols</label>
<input type="text" id="customSymbols" value="" placeholder="!@#$%^&amp;*()-_=+[]{};:,.&lt;&gt;?" autocomplete="off" spellcheck="false">
</div>
</section>
<h2>Output</h2>
<div class="field">
<label for="count">How many passwords</label>
<input type="number" id="count" min="1" max="10" step="1" value="1">
</div>
<div class="actions">
<button id="generate" class="button primary" type="button">Generate</button>
<button id="regenerate" class="button" type="button">Regenerate</button>
</div>
<p id="error" class="error-line" hidden></p>
<div id="results"></div>
</main>
<script src="/app.js"></script>
</body>
</html>
+240
View File
@@ -0,0 +1,240 @@
:root {
--bg: #fafafa;
--fg: #1a1a1a;
--muted: #666;
--line: #ddd;
--code-bg: #f0f0f0;
--link: #0b5cad;
color-scheme: light dark;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #111;
--fg: #e6e6e6;
--muted: #999;
--line: #333;
--code-bg: #1c1c1c;
--link: #c4a7ff;
}
}
:root[data-theme="dark"] {
--bg: #111;
--fg: #e6e6e6;
--muted: #999;
--line: #333;
--code-bg: #1c1c1c;
--link: #c4a7ff;
color-scheme: dark;
}
:root[data-theme="light"] { color-scheme: light; }
[hidden] { display: none !important; }
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--fg);
font: 16px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
main {
max-width: 42rem;
margin: 0 auto;
padding: 3rem 1.25rem;
}
h1 { font-size: 1.75rem; margin: 0 0 2rem; }
h2 {
font-size: .8rem;
letter-spacing: .08em;
text-transform: uppercase;
color: var(--muted);
margin: 2.5rem 0 .75rem;
}
a { color: var(--link); text-decoration: none; }
a:hover { text-decoration: underline; }
.topbar { display: flex; justify-content: flex-end; margin: -1.5rem 0 1.5rem; }
.theme-toggle {
padding: .25rem .7rem;
background: transparent;
color: var(--fg);
border: 1px solid var(--line);
border-radius: 6px;
font-family: inherit;
font-size: .85rem;
line-height: 1.4;
cursor: pointer;
}
.theme-toggle:hover { border-color: var(--muted); }
.theme-toggle:focus-visible { outline: 2px solid var(--link); outline-offset: 2px; }
dl {
display: grid;
grid-template-columns: max-content 1fr;
gap: .5rem 1.5rem;
margin: 0;
}
dt { color: var(--muted); }
dd { margin: 0; overflow-wrap: anywhere; }
.key { margin: 0 0 1.25rem; }
.key p { margin: 0 0 .35rem; color: var(--muted); font-size: .9rem; }
pre {
margin: 0;
padding: .75rem 1rem;
background: var(--code-bg);
border: 1px solid var(--line);
border-radius: 6px;
overflow-x: auto;
font: .85rem/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
main.error h2 { margin-top: 0; }
main.error h1 { margin-bottom: .75rem; }
main.error p { color: var(--muted); margin: 0 0 2rem; }
.lead { margin: -1.5rem 0 0; color: var(--muted); }
.radios { display: flex; flex-wrap: wrap; gap: .5rem 1.25rem; }
.radio,
.check {
display: flex;
align-items: center;
gap: .5rem;
font-size: .95rem;
}
.radio input,
.check input {
width: 1rem;
height: 1rem;
margin: 0;
accent-color: var(--link);
}
.checks { display: grid; gap: .4rem; margin-bottom: 1rem; }
.field { margin-bottom: 1rem; }
.field > label {
display: block;
margin-bottom: .3rem;
color: var(--muted);
font-size: .9rem;
}
input[type="text"],
input[type="number"],
select {
width: 100%;
max-width: 24rem;
padding: .4rem .55rem;
background: var(--bg);
color: var(--fg);
border: 1px solid var(--line);
border-radius: 6px;
font: inherit;
font-size: .95rem;
}
select { cursor: pointer; }
input[type="range"] {
width: 100%;
max-width: 24rem;
accent-color: var(--link);
}
.range-row { display: flex; align-items: center; gap: .75rem; }
.range-row output {
min-width: 2.5rem;
color: var(--muted);
font: .85rem/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
input:focus-visible,
select:focus-visible,
button:focus-visible {
outline: 2px solid var(--link);
outline-offset: 2px;
}
.button {
padding: .45rem .9rem;
background: var(--bg);
color: var(--fg);
border: 1px solid var(--line);
border-radius: 6px;
font-family: inherit;
font-size: .9rem;
line-height: 1.4;
cursor: pointer;
}
.button:hover { border-color: var(--muted); }
.button.primary {
background: var(--link);
color: #fff;
border-color: var(--link);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .button.primary { color: #111; }
}
:root[data-theme="dark"] .button.primary { color: #111; }
.actions { display: flex; flex-wrap: wrap; gap: .6rem; margin: 1.25rem 0; }
.error-line {
margin: 0 0 1.25rem;
padding-left: .6rem;
border-left: 2px solid var(--link);
color: var(--fg);
font-size: .9rem;
}
.pw { margin-bottom: 1.5rem; }
.pw:last-child { margin-bottom: 0; }
.pw-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: .5rem .9rem;
margin-top: .5rem;
color: var(--muted);
font-size: .85rem;
}
.pw-entropy { font: .85rem/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.strength { display: inline-flex; gap: 3px; }
.strength i {
display: inline-block;
width: 14px;
height: 6px;
border-radius: 3px;
background: var(--line);
}
.strength i.on { background: var(--link); }
.pw-strength-label { text-transform: capitalize; }
@media (max-width: 30rem) {
dl { grid-template-columns: 1fr; gap: 0; }
dd { margin-bottom: .75rem; }
input[type="text"],
input[type="number"],
select,
input[type="range"] { max-width: 100%; }
.actions .button { flex: 1 1 auto; }
}
+32
View File
@@ -0,0 +1,32 @@
(function () {
var root = document.documentElement;
var media = window.matchMedia('(prefers-color-scheme: dark)');
try {
var saved = localStorage.getItem('theme');
if (saved === 'light' || saved === 'dark') root.dataset.theme = saved;
} catch (e) {}
function current() {
return root.dataset.theme || (media.matches ? 'dark' : 'light');
}
function label(btn) {
var next = current() === 'dark' ? 'light' : 'dark';
btn.textContent = next === 'dark' ? 'Dark mode' : 'Light mode';
btn.setAttribute('aria-label', 'Switch to ' + next + ' mode');
}
document.addEventListener('DOMContentLoaded', function () {
var btn = document.getElementById('theme-toggle');
if (!btn) return;
label(btn);
btn.hidden = false;
btn.addEventListener('click', function () {
root.dataset.theme = current() === 'dark' ? 'light' : 'dark';
try { localStorage.setItem('theme', root.dataset.theme); } catch (e) {}
label(btn);
});
media.addEventListener('change', function () { label(btn); });
});
})();
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff