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.
387 lines
11 KiB
JavaScript
387 lines
11 KiB
JavaScript
(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';
|
|
var approximate = Boolean(item.entropyApproximate);
|
|
entropy.textContent = (approximate ? '~' : '') + item.entropy + ' bits';
|
|
if (approximate) {
|
|
entropy.setAttribute('title', 'Upper bound: the generator samples the valid passwords non-uniformly.');
|
|
}
|
|
|
|
const level = strengthLevel(item.entropy);
|
|
const label = document.createElement('span');
|
|
label.className = 'pw-strength-label';
|
|
label.textContent = STRENGTH_LABELS[level] + (approximate ? ' (approx.)' : '');
|
|
|
|
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') {
|
|
var payload = {
|
|
mode: 'random',
|
|
length: state.length,
|
|
useLower: state.useLower,
|
|
useUpper: state.useUpper,
|
|
useDigits: state.useDigits,
|
|
useSymbols: state.useSymbols,
|
|
excludeAmbiguous: state.excludeAmbiguous,
|
|
requireEach: state.requireEach,
|
|
count: state.count
|
|
};
|
|
// The server rejects invalid symbol sets, so only send the custom set
|
|
// when the symbols class is actually in use.
|
|
if (state.useSymbols) payload.customSymbols = state.customSymbols;
|
|
return payload;
|
|
}
|
|
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();
|
|
}
|
|
})();
|