Protected profiles: recovery email and Discord contacts with reset codes (Gmail SMTP, Discord relay)

This commit is contained in:
Jonathan Sykes
2026-10-15 17:28:10 +08:00
parent dbcee5d5f8
commit ca47528581
8 changed files with 477 additions and 6 deletions

View File

@@ -29,6 +29,12 @@ services:
# Unlocks /admin (API tokens, lyric/chapter history + restore). Set it in # Unlocks /admin (API tokens, lyric/chapter history + restore). Set it in
# Dokploy's Environment tab — never commit it. Unset = admin disabled. # Dokploy's Environment tab — never commit it. Unset = admin disabled.
ADMIN_PASSWORD: "${ADMIN_PASSWORD:-}" ADMIN_PASSWORD: "${ADMIN_PASSWORD:-}"
# Password reset codes for protected profiles (server/profile-recovery.js).
SMTP_USER: "${SMTP_USER:-}"
SMTP_PASS: "${SMTP_PASS:-}"
SMTP_FROM: "${SMTP_FROM:-}"
DISCORD_RELAY_URL: "${DISCORD_RELAY_URL:-}"
DISCORD_RELAY_TOKEN: "${DISCORD_RELAY_TOKEN:-}"
# Phone remote: "1" = a phone may only pair with a screen on the same # Phone remote: "1" = a phone may only pair with a screen on the same
# network (same public IP as seen by the server). Off by default. # network (same public IP as seen by the server). Off by default.
REMOTE_SAME_NETWORK: "${REMOTE_SAME_NETWORK:-0}" REMOTE_SAME_NETWORK: "${REMOTE_SAME_NETWORK:-0}"

View File

@@ -868,6 +868,7 @@ function askProfileSecret(name, kind, wrong) {
showModal('🔒 Protected profile', body, [ showModal('🔒 Protected profile', body, [
{ label: 'Cancel', onClick: () => done(null) }, { label: 'Cancel', onClick: () => done(null) },
{ label: 'Import file', onClick: () => { closeModal(); resolve(null); importProfileCredentials(); } }, { label: 'Import file', onClick: () => { closeModal(); resolve(null); importProfileCredentials(); } },
{ label: 'Forgot?', onClick: async () => { closeModal(); resolve(await resetProfileSecretFlow(name)); } },
{ label: 'Open', primary: true, onClick: () => { const v = ($('profileSecretInput').value || '').trim(); if (v) done(v); } }, { label: 'Open', primary: true, onClick: () => { const v = ($('profileSecretInput').value || '').trim(); if (v) done(v); } },
]); ]);
setTimeout(() => $('profileSecretInput') && $('profileSecretInput').focus(), 50); setTimeout(() => $('profileSecretInput') && $('profileSecretInput').focus(), 50);
@@ -1500,7 +1501,8 @@ function protectProfileFlow() {
${prot ? 'Change or remove the protection on' : 'Protect'} <strong>${escapeHtml(data.profile.name)}</strong>. ${prot ? 'Change or remove the protection on' : 'Protect'} <strong>${escapeHtml(data.profile.name)}</strong>.
Once protected, the name alone no longer opens it — a device also needs the Once protected, the name alone no longer opens it — a device also needs the
password, the profile key, or the credentials file. <strong>If you lose all of password, the profile key, or the credentials file. <strong>If you lose all of
them the profile can't be opened.</strong> them the profile can't be opened</strong> — unless you add a recovery email or
Discord under Recovery… afterwards.
</p> </p>
<input id="profilePwInput" type="password" autocomplete="new-password" placeholder="New password (8+ characters)" style="margin-bottom:8px" /> <input id="profilePwInput" type="password" autocomplete="new-password" placeholder="New password (8+ characters)" style="margin-bottom:8px" />
<input id="profilePwInput2" type="password" autocomplete="new-password" placeholder="Repeat the password" />`; <input id="profilePwInput2" type="password" autocomplete="new-password" placeholder="Repeat the password" />`;
@@ -1525,6 +1527,7 @@ function protectProfileFlow() {
} catch (e) { toast('⚠ ' + e.message); } } catch (e) { toast('⚠ ' + e.message); }
} }, } },
]; ];
if (prot) actions.splice(1, 0, { label: 'Recovery…', onClick: () => { closeModal(); recoveryContactsFlow(); } });
if (prot) actions.splice(1, 0, { label: 'Remove', danger: true, onClick: async () => { if (prot) actions.splice(1, 0, { label: 'Remove', danger: true, onClick: async () => {
try { await setProfileSecretOnServer(null, null); closeModal(); toast('Protection removed — the name alone opens this profile again'); } try { await setProfileSecretOnServer(null, null); closeModal(); toast('Protection removed — the name alone opens this profile again'); }
catch (e) { toast('⚠ ' + e.message); } catch (e) { toast('⚠ ' + e.message); }
@@ -1533,6 +1536,98 @@ function protectProfileFlow() {
setTimeout(() => $('profilePwInput') && $('profilePwInput').focus(), 50); setTimeout(() => $('profilePwInput') && $('profilePwInput').focus(), 50);
} }
// ---- Password reset: recovery email / Discord ----
// The server sends a one-time code to the contact saved here; the code plus a
// new password replaces the profile's secret (server/profile-recovery.js).
async function recoveryContactsFlow() {
const name = data.profile && data.profile.name;
if (!name || !data.profile.protected) return;
let cur;
try {
const res = await fetch(`/api/profile/recovery?name=${encodeURIComponent(name)}`, { headers: profileHeaders(name) });
cur = await res.json();
if (!res.ok || !cur.ok) throw new Error(cur.error || 'could not load the recovery contacts');
} catch (e) { toast('⚠ ' + e.message); return; }
const off = (k) => (cur.available && cur.available[k]) ? '' : ' <em style="color:var(--text-dim)">(not switched on for this server yet)</em>';
const body = document.createElement('div');
body.innerHTML = `
<p style="margin:0 0 12px;color:var(--text-2);font-size:13px;line-height:1.5">
If you forget the password of <strong>${escapeHtml(name)}</strong>, a reset code can be sent to one of these.
</p>
<label style="display:block;font-size:12px;color:var(--text-2);margin:0 0 4px">Email${off('email')}</label>
<input id="recEmail" type="email" autocomplete="email" placeholder="you@example.com" value="${escapeHtml(cur.email || '')}" style="margin-bottom:12px" />
<label style="display:block;font-size:12px;color:var(--text-2);margin:0 0 4px">Discord user id${off('discord')}</label>
<input id="recDiscord" inputmode="numeric" autocomplete="off" placeholder="e.g. 123456789012345678" value="${escapeHtml(cur.discord || '')}" />
<p style="margin:8px 0 0;color:var(--text-dim);font-size:12px;line-height:1.5">
Discord: Settings → Advanced → Developer Mode, then tap your name → Copy User ID. You must share a server with the YT Player bot for its message to reach you.
</p>`;
showModal('🛟 Recovery contacts', body, [
{ label: 'Cancel', onClick: closeModal },
{ label: 'Save', primary: true, onClick: async () => {
try {
const res = await fetch('/api/profile/recovery', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, current: ProfileSecret.getFor(name), email: $('recEmail').value.trim(), discord: $('recDiscord').value.trim() }) });
const j = await res.json().catch(() => ({}));
if (!res.ok || !j.ok) throw new Error(j.error || 'could not save');
closeModal(); toast(j.email || j.discord ? 'Recovery contacts saved ✓' : 'Recovery contacts removed');
} catch (e) { toast('⚠ ' + e.message); }
} },
]);
}
// Forgot password: pick where the code goes, then enter it with a new password.
// Resolves the new password (so the caller can open the profile) or null.
async function resetProfileSecretFlow(name) {
let info = null;
try { info = await (await fetch(`/api/profile/info?name=${encodeURIComponent(name)}`)).json(); } catch { /* offline */ }
const rec = info && info.recovery;
if (!rec || (!rec.email && !rec.discord)) {
toast(`⚠ ${name} has no recovery email or Discord set — it opens only with its password, key or credentials file`, { duration: 6000 });
return null;
}
const via = await new Promise((resolve) => {
const body = document.createElement('div');
body.innerHTML = `<p style="margin:0;color:var(--text-2);font-size:13px;line-height:1.5">
Send a reset code for <strong>${escapeHtml(name)}</strong>. It is valid for 15 minutes.</p>`;
showModal('🛟 Reset password', body, [
{ label: 'Cancel', onClick: () => { closeModal(); resolve(null); } },
...(rec.discord ? [{ label: 'Discord DM', primary: !rec.email, onClick: () => { closeModal(); resolve('discord'); } }] : []),
...(rec.email ? [{ label: `Email ${rec.emailHint || ''}`.trim(), primary: true, onClick: () => { closeModal(); resolve('email'); } }] : []),
]);
});
if (!via) return null;
try {
const res = await fetch('/api/profile/reset/request', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, via }) });
const j = await res.json().catch(() => ({}));
if (!res.ok || !j.ok) throw new Error(j.error || 'could not send the code');
toast(`Code sent to ${j.hint} ✓`, { duration: 4000 });
} catch (e) { toast('⚠ ' + e.message, { duration: 6000 }); return null; }
return new Promise((resolve) => {
const body = document.createElement('div');
body.innerHTML = `
<p style="margin:0 0 12px;color:var(--text-2);font-size:13px;line-height:1.5">Enter the 8-digit code and a new password for <strong>${escapeHtml(name)}</strong>.</p>
<input id="resetCode" inputmode="numeric" autocomplete="one-time-code" placeholder="1234-5678" style="margin-bottom:8px" />
<input id="resetPw" type="password" autocomplete="new-password" placeholder="New password (8+ characters)" style="margin-bottom:8px" />
<input id="resetPw2" type="password" autocomplete="new-password" placeholder="Repeat the password" />`;
showModal('🛟 Reset password', body, [
{ label: 'Cancel', onClick: () => { closeModal(); resolve(null); } },
{ label: 'Set password', primary: true, onClick: async () => {
const code = $('resetCode').value, a = $('resetPw').value, b = $('resetPw2').value;
if (a.length < 8) { toast('⚠ Use at least 8 characters'); return; }
if (a !== b) { toast('⚠ The two passwords differ'); return; }
try {
const res = await fetch('/api/profile/reset/confirm', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, code, secret: a }) });
const j = await res.json().catch(() => ({}));
if (!res.ok || !j.ok) throw new Error(j.error || 'could not reset');
ProfileSecret.set(name, a, 'password');
closeModal(); toast('Password changed ✓', { duration: 4000 }); resolve(a);
} catch (e) { toast('⚠ ' + e.message, { duration: 5000 }); }
} },
]);
setTimeout(() => $('resetCode') && $('resetCode').focus(), 50);
});
}
// A small JSON file that signs another device into this profile. // A small JSON file that signs another device into this profile.
async function exportProfileCredentials() { async function exportProfileCredentials() {
const name = data.profile && data.profile.name; const name = data.profile && data.profile.name;

135
server/bun.lock Normal file
View File

@@ -0,0 +1,135 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "ytplayer-server",
"dependencies": {
"@hono/node-server": "^1.14.0",
"@libsql/client": "^0.14.0",
"hono": "^4.7.10",
"nodemailer": "^6",
"qrcode": "^1.5.4",
},
},
},
"packages": {
"@hono/node-server": ["@hono/node-server@1.19.17", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ=="],
"@libsql/client": ["@libsql/client@0.14.0", "", { "dependencies": { "@libsql/core": "^0.14.0", "@libsql/hrana-client": "^0.7.0", "js-base64": "^3.7.5", "libsql": "^0.4.4", "promise-limit": "^2.7.0" } }, "sha512-/9HEKfn6fwXB5aTEEoMeFh4CtG0ZzbncBb1e++OCdVpgKZ/xyMsIVYXm0w7Pv4RUel803vE6LwniB3PqD72R0Q=="],
"@libsql/core": ["@libsql/core@0.14.0", "", { "dependencies": { "js-base64": "^3.7.5" } }, "sha512-nhbuXf7GP3PSZgdCY2Ecj8vz187ptHlZQ0VRc751oB2C1W8jQUXKKklvt7t1LJiUTQBVJuadF628eUk+3cRi4Q=="],
"@libsql/darwin-arm64": ["@libsql/darwin-arm64@0.4.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-yOL742IfWUlUevnI5PdnIT4fryY3LYTdLm56bnY0wXBw7dhFcnjuA7jrH3oSVz2mjZTHujxoITgAE7V6Z+eAbg=="],
"@libsql/darwin-x64": ["@libsql/darwin-x64@0.4.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-ezc7V75+eoyyH07BO9tIyJdqXXcRfZMbKcLCeF8+qWK5nP8wWuMcfOVywecsXGRbT99zc5eNra4NEx6z5PkSsA=="],
"@libsql/hrana-client": ["@libsql/hrana-client@0.7.0", "", { "dependencies": { "@libsql/isomorphic-fetch": "^0.3.1", "@libsql/isomorphic-ws": "^0.1.5", "js-base64": "^3.7.5", "node-fetch": "^3.3.2" } }, "sha512-OF8fFQSkbL7vJY9rfuegK1R7sPgQ6kFMkDamiEccNUvieQ+3urzfDFI616oPl8V7T9zRmnTkSjMOImYCAVRVuw=="],
"@libsql/isomorphic-fetch": ["@libsql/isomorphic-fetch@0.3.1", "", {}, "sha512-6kK3SUK5Uu56zPq/Las620n5aS9xJq+jMBcNSOmjhNf/MUvdyji4vrMTqD7ptY7/4/CAVEAYDeotUz60LNQHtw=="],
"@libsql/isomorphic-ws": ["@libsql/isomorphic-ws@0.1.5", "", { "dependencies": { "@types/ws": "^8.5.4", "ws": "^8.13.0" } }, "sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg=="],
"@libsql/linux-arm64-gnu": ["@libsql/linux-arm64-gnu@0.4.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-WlX2VYB5diM4kFfNaYcyhw5y+UJAI3xcMkEUJZPtRDEIu85SsSFrQ+gvoKfcVh76B//ztSeEX2wl9yrjF7BBCA=="],
"@libsql/linux-arm64-musl": ["@libsql/linux-arm64-musl@0.4.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-6kK9xAArVRlTCpWeqnNMCoXW1pe7WITI378n4NpvU5EJ0Ok3aNTIC2nRPRjhro90QcnmLL1jPcrVwO4WD1U0xw=="],
"@libsql/linux-x64-gnu": ["@libsql/linux-x64-gnu@0.4.7", "", { "os": "linux", "cpu": "x64" }, "sha512-CMnNRCmlWQqqzlTw6NeaZXzLWI8bydaXDke63JTUCvu8R+fj/ENsLrVBtPDlxQ0wGsYdXGlrUCH8Qi9gJep0yQ=="],
"@libsql/linux-x64-musl": ["@libsql/linux-x64-musl@0.4.7", "", { "os": "linux", "cpu": "x64" }, "sha512-nI6tpS1t6WzGAt1Kx1n1HsvtBbZ+jHn0m7ogNNT6pQHZQj7AFFTIMeDQw/i/Nt5H38np1GVRNsFe99eSIMs9XA=="],
"@libsql/win32-x64-msvc": ["@libsql/win32-x64-msvc@0.4.7", "", { "os": "win32", "cpu": "x64" }, "sha512-7pJzOWzPm6oJUxml+PCDRzYQ4A1hTMHAciTAHfFK4fkbDZX33nWPVG7Y3vqdKtslcwAzwmrNDc6sXy2nwWnbiw=="],
"@neon-rs/load": ["@neon-rs/load@0.0.4", "", {}, "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw=="],
"@types/node": ["@types/node@26.6.5", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg=="],
"@types/ws": ["@types/ws@8.18.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w=="],
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
"cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
"decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="],
"detect-libc": ["detect-libc@2.0.2", "", {}, "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw=="],
"dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
"find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
"formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"hono": ["hono@4.13.13", "", {}, "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"js-base64": ["js-base64@3.9.4", "", {}, "sha512-PtOMXpEGuP0RRiRXsjzHzl44dMHxSu2CPvAhinupR1tBa88me+1DPqsobl7eupn5UukjLkln1ZnAOAOXOrYG0Q=="],
"libsql": ["libsql@0.4.7", "", { "dependencies": { "@neon-rs/load": "^0.0.4", "detect-libc": "2.0.2" }, "optionalDependencies": { "@libsql/darwin-arm64": "0.4.7", "@libsql/darwin-x64": "0.4.7", "@libsql/linux-arm64-gnu": "0.4.7", "@libsql/linux-arm64-musl": "0.4.7", "@libsql/linux-x64-gnu": "0.4.7", "@libsql/linux-x64-musl": "0.4.7", "@libsql/win32-x64-msvc": "0.4.7" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ] }, "sha512-T9eIRCs6b0J1SHKYIvD8+KCJMcWZ900iZyxdnSCdqxN12Z1ijzT+jY5nrk72Jw4B0HGzms2NgpryArlJqvc3Lw=="],
"locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
"node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
"nodemailer": ["nodemailer@6.10.1", "", {}, "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA=="],
"p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
"p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
"p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="],
"promise-limit": ["promise-limit@2.7.0", "", {}, "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw=="],
"qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="],
"set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
"which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="],
"wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
"ws": ["ws@8.22.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg=="],
"y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="],
"yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
"yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
}
}

View File

@@ -316,7 +316,7 @@ export async function initDb() {
)`); )`);
// Optional profile protection: a password or generated key, stored only as a // Optional profile protection: a password or generated key, stored only as a
// slow salted hash (Bun.password / argon2id). NULL = the name alone opens it. // slow salted hash (Bun.password / argon2id). NULL = the name alone opens it.
for (const col of ['secret_hash TEXT', 'secret_kind TEXT']) { for (const col of ['secret_hash TEXT', 'secret_kind TEXT', 'recovery_email TEXT', 'recovery_discord TEXT']) {
try { await db.execute(`ALTER TABLE profiles ADD COLUMN ${col}`); } catch { /* already there */ } try { await db.execute(`ALTER TABLE profiles ADD COLUMN ${col}`); } catch { /* already there */ }
} }
} }
@@ -763,12 +763,13 @@ export async function createProfile(name, dataJson) {
export async function getProfile(name) { export async function getProfile(name) {
const r = await db.execute({ const r = await db.execute({
sql: 'SELECT data, updated_at, secret_hash, secret_kind FROM profiles WHERE name = ?', sql: 'SELECT data, updated_at, secret_hash, secret_kind, recovery_email, recovery_discord FROM profiles WHERE name = ?',
args: [name], args: [name],
}); });
const row = r.rows[0]; const row = r.rows[0];
if (!row) return null; if (!row) return null;
return { data: row.data, updatedAt: Number(row.updated_at), secretHash: row.secret_hash || null, secretKind: row.secret_kind || null }; return { data: row.data, updatedAt: Number(row.updated_at), secretHash: row.secret_hash || null, secretKind: row.secret_kind || null,
recoveryEmail: row.recovery_email || null, recoveryDiscord: row.recovery_discord || null };
} }
export async function setProfileSecret(name, hash, kind) { export async function setProfileSecret(name, hash, kind) {

View File

@@ -6,12 +6,13 @@
"scripts": { "scripts": {
"start": "bun server.js", "start": "bun server.js",
"dev": "bun --hot server.js", "dev": "bun --hot server.js",
"test": "bun test ./recommendations.test.js && bun test --timeout 60000 ./media-cache.test.js && bun test ./notes.test.js && bun test ./audit-log.test.js && bun test ./request-monitor.test.js && bun test ./admin-tools.test.js && bun test ./transcriptions.test.js && bun test ./admin-analytics.test.js && bun test ./remote.test.js && bun test ./party.test.js && bun test ./uploads.test.js && bun test ./innertube.test.js && bun test ./related.test.js && bun test ./ytdlp-pool.test.js && bun test ./warm-queue.test.js && bun test ./p2p-db.test.js && bun test ./p2p-admin.test.js && bun test ./p2p-admit.test.js && bun test ./p2p-retention.test.js && bun test ./p2p-routes.test.js && bun test ./p2p-hub.test.js && bun test --timeout 60000 ./p2p-intake.test.js && bun test ./flags.test.js && bun test ./asset-manifest.test.js && bun test ./static-delivery.test.js && bun test ./video-catalog.test.js && bun test ./search-cache.test.js && bun test --timeout 60000 ./deep-search.test.js" "test": "bun test ./recommendations.test.js && bun test --timeout 60000 ./media-cache.test.js && bun test ./notes.test.js && bun test ./audit-log.test.js && bun test ./request-monitor.test.js && bun test ./admin-tools.test.js && bun test ./transcriptions.test.js && bun test ./admin-analytics.test.js && bun test ./remote.test.js && bun test ./party.test.js && bun test ./uploads.test.js && bun test ./innertube.test.js && bun test ./related.test.js && bun test ./ytdlp-pool.test.js && bun test ./warm-queue.test.js && bun test ./p2p-db.test.js && bun test ./p2p-admin.test.js && bun test ./p2p-admit.test.js && bun test ./p2p-retention.test.js && bun test ./p2p-routes.test.js && bun test ./p2p-hub.test.js && bun test --timeout 60000 ./p2p-intake.test.js && bun test ./flags.test.js && bun test ./asset-manifest.test.js && bun test ./static-delivery.test.js && bun test ./video-catalog.test.js && bun test ./search-cache.test.js && bun test --timeout 60000 ./deep-search.test.js && bun test ./profile-recovery.test.js"
}, },
"dependencies": { "dependencies": {
"@hono/node-server": "^1.14.0", "@hono/node-server": "^1.14.0",
"@libsql/client": "^0.14.0", "@libsql/client": "^0.14.0",
"hono": "^4.7.10", "hono": "^4.7.10",
"nodemailer": "^6",
"qrcode": "^1.5.4" "qrcode": "^1.5.4"
} }
} }

152
server/profile-recovery.js Normal file
View File

@@ -0,0 +1,152 @@
// Password reset for protected profiles.
//
// A protected profile can store a recovery email and/or a Discord user id.
// "Forgot password" sends a one-time code there (Gmail SMTP / Discord bot DM);
// the code plus a new password replaces the profile's secret. Both channels
// are off until their credentials are in the environment:
// SMTP_USER, SMTP_PASS (Gmail app password), optional SMTP_HOST/PORT/FROM
// DISCORD_RELAY_URL + DISCORD_RELAY_TOKEN (discord-relay on the VPS), or DISCORD_BOT_TOKEN
//
// GET /api/profile/recovery?name= current contacts (needs the secret)
// POST /api/profile/recovery { name, current, email, discord }
// POST /api/profile/reset/request { name, via: 'email'|'discord' }
// POST /api/profile/reset/confirm { name, code, secret }
import { createHash, randomInt, timingSafeEqual } from 'node:crypto';
const CODE_TTL = 15 * 60_000, CODE_TRIES = 5;
const EMAIL_RE = /^[^\s@]{1,64}@[^\s@]{1,190}\.[^\s@]{2,}$/;
const DISCORD_RE = /^\d{15,21}$/;
const sha = (s) => createHash('sha256').update(s).digest();
const normCode = (s) => String(s || '').replace(/\D/g, '');
export function maskEmail(email) {
if (!email) return null;
const [user, domain] = String(email).split('@');
return `${user.slice(0, 1)}${'•'.repeat(Math.max(2, Math.min(6, user.length - 1)))}@${domain}`;
}
export function createSenders(env = process.env) {
const senders = {};
if (env.SMTP_USER && env.SMTP_PASS) {
let transport = null;
senders.email = async (to, subject, text) => {
if (!transport) {
const { default: nodemailer } = await import('nodemailer');
const port = Number(env.SMTP_PORT || 465);
transport = nodemailer.createTransport({ host: env.SMTP_HOST || 'smtp.gmail.com', port, secure: port === 465,
auth: { user: env.SMTP_USER, pass: String(env.SMTP_PASS).replace(/\s+/g, '') } });
}
await transport.sendMail({ from: env.SMTP_FROM || `YT Player <${env.SMTP_USER}>`, to, subject, text });
};
}
// Discord DMs go through the discord-relay service on the VPS (it holds the
// bot token and the gateway connection); DISCORD_BOT_TOKEN talks to Discord
// directly instead.
if (env.DISCORD_RELAY_URL && env.DISCORD_RELAY_TOKEN) {
senders.discord = async (userId, text) => {
const res = await fetch(`${env.DISCORD_RELAY_URL.replace(/\/+$/, '')}/v1/dm`, {
method: 'POST', signal: AbortSignal.timeout(15000),
headers: { Authorization: `Bearer ${env.DISCORD_RELAY_TOKEN}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: userId, content: text }),
});
if (!res.ok) { const j = await res.json().catch(() => ({})); throw new Error(j.error || `Discord relay answered ${res.status}`); }
};
} else if (env.DISCORD_BOT_TOKEN) {
senders.discord = async (userId, text) => {
const headers = { Authorization: `Bot ${env.DISCORD_BOT_TOKEN}`, 'Content-Type': 'application/json' };
const ch = await fetch('https://discord.com/api/v10/users/@me/channels', { method: 'POST', headers, body: JSON.stringify({ recipient_id: userId }) });
if (!ch.ok) throw new Error(`Discord could not open a DM (${ch.status}) — the user must share a server with the bot`);
const { id } = await ch.json();
const msg = await fetch(`https://discord.com/api/v10/channels/${id}/messages`, { method: 'POST', headers, body: JSON.stringify({ content: text }) });
if (!msg.ok) throw new Error(`Discord refused the message (${msg.status}) — the user may block DMs from server members`);
};
}
return senders;
}
export function registerProfileRecoveryRoutes(app, { db, getProfile, setProfileSecret, profileGate, senders = createSenders(), now = Date.now }) {
const ready = db.execute(`CREATE TABLE IF NOT EXISTS profile_resets (
name TEXT PRIMARY KEY, code_hash TEXT NOT NULL, via TEXT NOT NULL,
expires_at INTEGER NOT NULL, attempts INTEGER NOT NULL DEFAULT 0)`);
const sent = new Map(); // name → [timestamps] for request throttling
const channels = (row) => ({
email: !!(row?.recoveryEmail && senders.email),
discord: !!(row?.recoveryDiscord && senders.discord),
emailHint: senders.email ? maskEmail(row?.recoveryEmail) : null,
});
const body = async (c) => { try { return await c.req.json(); } catch { return null; } };
const nameOf = (v) => String(v || '').trim().toLowerCase();
app.get('/api/profile/recovery', async (c) => {
const name = nameOf(c.req.query('name'));
const row = name ? await getProfile(name) : null;
if (!row) return c.json({ ok: false, error: 'profile not found' }, 404);
const denied = await profileGate(c, name, row, c.req.header('x-profile-secret')); if (denied) return denied;
return c.json({ ok: true, email: row.recoveryEmail || '', discord: row.recoveryDiscord || '',
available: { email: !!senders.email, discord: !!senders.discord } }, 200, { 'Cache-Control': 'no-store' });
});
app.post('/api/profile/recovery', async (c) => {
const b = await body(c); if (!b) return c.json({ ok: false, error: 'invalid JSON' }, 400);
const name = nameOf(b.name);
const row = name ? await getProfile(name) : null;
if (!row) return c.json({ ok: false, error: 'profile not found' }, 404);
if (!row.secretHash) return c.json({ ok: false, error: 'protect the profile first' }, 400);
const denied = await profileGate(c, name, row, b.current || c.req.header('x-profile-secret')); if (denied) return denied;
const email = String(b.email || '').trim().toLowerCase(), discord = String(b.discord || '').trim();
if (email && !EMAIL_RE.test(email)) return c.json({ ok: false, error: 'that email address does not look right' }, 400);
if (discord && !DISCORD_RE.test(discord)) return c.json({ ok: false, error: 'a Discord user id is 15–21 digits (Discord → Settings → Advanced → Developer Mode, then right-click your name → Copy User ID)' }, 400);
await db.execute({ sql: 'UPDATE profiles SET recovery_email = ?, recovery_discord = ? WHERE name = ?', args: [email || null, discord || null, name] });
return c.json({ ok: true, email, discord });
});
app.post('/api/profile/reset/request', async (c) => {
await ready;
const b = await body(c); if (!b) return c.json({ ok: false, error: 'invalid JSON' }, 400);
const name = nameOf(b.name), via = b.via === 'discord' ? 'discord' : 'email';
const row = name ? await getProfile(name) : null;
if (!row || !row.secretHash) return c.json({ ok: false, error: 'profile not found or not protected' }, 404);
if (!channels(row)[via]) return c.json({ ok: false, error: `no ${via === 'email' ? 'recovery email' : 'Discord account'} is set up for this profile` }, 400);
const t = now(), recent = (sent.get(name) || []).filter((x) => t - x < 3600_000);
if (recent.length && t - recent[recent.length - 1] < 60_000) return c.json({ ok: false, error: 'a code was just sent — wait a minute before asking again' }, 429);
if (recent.length >= 5) return c.json({ ok: false, error: 'too many reset codes this hour — try again later' }, 429);
const code = String(randomInt(0, 100_000_000)).padStart(8, '0');
const pretty = `${code.slice(0, 4)}-${code.slice(4)}`;
const text = `Your YT Player reset code for the profile "${name}" is ${pretty}. It expires in 15 minutes.\n\nIf you did not ask for this, ignore it — the password has not changed.`;
try {
if (via === 'email') await senders.email(row.recoveryEmail, `YT Player reset code: ${pretty}`, text);
else await senders.discord(row.recoveryDiscord, text);
} catch (err) {
console.error('[profile reset]', via, err.message);
return c.json({ ok: false, error: via === 'discord' ? err.message : 'the email could not be sent — try again later' }, 502);
}
recent.push(t); sent.set(name, recent); if (sent.size > 5000) sent.clear();
await db.execute({ sql: `INSERT INTO profile_resets(name, code_hash, via, expires_at, attempts) VALUES(?,?,?,?,0)
ON CONFLICT(name) DO UPDATE SET code_hash = excluded.code_hash, via = excluded.via, expires_at = excluded.expires_at, attempts = 0`,
args: [name, sha(code).toString('hex'), via, t + CODE_TTL] });
return c.json({ ok: true, via, hint: via === 'email' ? maskEmail(row.recoveryEmail) : 'your Discord DMs' });
});
app.post('/api/profile/reset/confirm', async (c) => {
await ready;
const b = await body(c); if (!b) return c.json({ ok: false, error: 'invalid JSON' }, 400);
const name = nameOf(b.name), code = normCode(b.code), secret = String(b.secret || '');
const reset = (await db.execute({ sql: 'SELECT * FROM profile_resets WHERE name = ?', args: [name] })).rows[0];
const drop = () => db.execute({ sql: 'DELETE FROM profile_resets WHERE name = ?', args: [name] });
if (!reset) return c.json({ ok: false, error: 'ask for a reset code first' }, 400);
if (Number(reset.expires_at) < now()) { await drop(); return c.json({ ok: false, error: 'that code has expired — ask for a new one' }, 400); }
if (Number(reset.attempts) >= CODE_TRIES) { await drop(); return c.json({ ok: false, error: 'too many wrong codes — ask for a new one' }, 429); }
const ok = code.length === 8 && timingSafeEqual(sha(code), Buffer.from(String(reset.code_hash), 'hex'));
if (!ok) {
await db.execute({ sql: 'UPDATE profile_resets SET attempts = attempts + 1 WHERE name = ?', args: [name] });
return c.json({ ok: false, wrongCode: true, error: `wrong code — ${CODE_TRIES - Number(reset.attempts) - 1} tries left` }, 401);
}
if (secret.length < 8 || secret.length > 200) return c.json({ ok: false, error: 'password must be 8–200 characters' }, 400);
await setProfileSecret(name, await Bun.password.hash(secret), 'password');
await drop();
return c.json({ ok: true, name, kind: 'password' });
});
return { channels };
}

View File

@@ -0,0 +1,77 @@
import { expect, test, beforeAll } from 'bun:test';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Hono } from 'hono';
const root = mkdtempSync(join(tmpdir(), 'ytp-recovery-'));
process.env.DB_PATH = join(root, 'test.db');
const { initDb, db, createProfile, getProfile, setProfileSecret } = await import('./db.js');
const { registerProfileRecoveryRoutes, maskEmail, createSenders } = await import('./profile-recovery.js');
const outbox = [];
const senders = {
email: async (to, subject, text) => { outbox.push({ via: 'email', to, text }); },
discord: async (to, text) => { outbox.push({ via: 'discord', to, text }); },
};
// Same contract as server.js profileGate: null when the secret opens the profile.
const profileGate = async (c, _name, row, provided) => {
if (!row.secretHash) return null;
return provided && await Bun.password.verify(String(provided), row.secretHash) ? null : c.json({ ok: false, needSecret: true }, 401);
};
let clock = 1_000_000;
const app = new Hono();
const recovery = registerProfileRecoveryRoutes(app, { db, getProfile, setProfileSecret, profileGate, senders, now: () => clock });
const post = async (path, body) => { const r = await app.request(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); return { status: r.status, json: await r.json() }; };
const codeFrom = (text) => text.match(/(\d{4})-(\d{4})/).slice(1).join('');
beforeAll(async () => {
await initDb();
await createProfile('psalm', '{}');
await setProfileSecret('psalm', await Bun.password.hash('old-password'), 'password');
});
test('recovery contacts need the current secret and valid values', async () => {
expect((await post('/api/profile/recovery', { name: 'psalm', current: 'nope', email: 'a@b.co' })).status).toBe(401);
expect((await post('/api/profile/recovery', { name: 'psalm', current: 'old-password', email: 'not-an-email' })).status).toBe(400);
expect((await post('/api/profile/recovery', { name: 'psalm', current: 'old-password', discord: '12' })).status).toBe(400);
const ok = await post('/api/profile/recovery', { name: 'psalm', current: 'old-password', email: 'Leader@Church.org', discord: '123456789012345678' });
expect(ok.json).toMatchObject({ ok: true, email: 'leader@church.org', discord: '123456789012345678' });
expect(recovery.channels(await getProfile('psalm'))).toEqual({ email: true, discord: true, emailHint: 'l•••••@church.org' });
});
test('a reset code sent by email replaces the password, once', async () => {
const sent = await post('/api/profile/reset/request', { name: 'psalm', via: 'email' });
expect(sent.json).toMatchObject({ ok: true, via: 'email' });
const mail = outbox.pop();
expect(mail.to).toBe('leader@church.org');
const code = codeFrom(mail.text);
expect((await post('/api/profile/reset/confirm', { name: 'psalm', code: '00000000', secret: 'brand-new-pw' })).status).toBe(401);
expect((await post('/api/profile/reset/confirm', { name: 'psalm', code, secret: 'short' })).status).toBe(400);
const done = await post('/api/profile/reset/confirm', { name: 'psalm', code: `${code.slice(0, 4)}-${code.slice(4)}`, secret: 'brand-new-pw' });
expect(done.json).toMatchObject({ ok: true, kind: 'password' });
expect(await Bun.password.verify('brand-new-pw', (await getProfile('psalm')).secretHash)).toBe(true);
expect((await post('/api/profile/reset/confirm', { name: 'psalm', code, secret: 'another-one' })).status).toBe(400);
});
test('requests are throttled, codes expire and run out of tries', async () => {
clock += 3600_000;
expect((await post('/api/profile/reset/request', { name: 'psalm', via: 'discord' })).json.ok).toBe(true);
expect(outbox.pop()).toMatchObject({ via: 'discord', to: '123456789012345678' });
expect((await post('/api/profile/reset/request', { name: 'psalm', via: 'discord' })).status).toBe(429);
for (let i = 0; i < 5; i++) await post('/api/profile/reset/confirm', { name: 'psalm', code: '11111111', secret: 'brand-new-pw2' });
expect((await post('/api/profile/reset/confirm', { name: 'psalm', code: '11111111', secret: 'brand-new-pw2' })).status).toBe(429);
clock += 61_000;
await post('/api/profile/reset/request', { name: 'psalm', via: 'email' });
const code = codeFrom(outbox.pop().text);
clock += 16 * 60_000;
expect((await post('/api/profile/reset/confirm', { name: 'psalm', code, secret: 'brand-new-pw2' })).json.error).toMatch(/expired/);
});
test('unprotected profiles and unconfigured channels are refused', async () => {
await createProfile('open', '{}');
expect((await post('/api/profile/reset/request', { name: 'open', via: 'email' })).status).toBe(404);
expect(createSenders({})).toEqual({});
expect(Object.keys(createSenders({ SMTP_USER: 'a', SMTP_PASS: 'b', DISCORD_RELAY_URL: 'http://x', DISCORD_RELAY_TOKEN: 't' })).sort()).toEqual(['discord', 'email']);
expect(maskEmail('a@b.co')).toBe('a••@b.co');
});

View File

@@ -70,6 +70,7 @@ import QRCode from 'qrcode';
import { createYtdlpPool } from './ytdlp-pool.js'; import { createYtdlpPool } from './ytdlp-pool.js';
import { createWarmQueue } from './warm-queue.js'; import { createWarmQueue } from './warm-queue.js';
import { registerDeepSearchRoutes } from './deep-search.js'; import { registerDeepSearchRoutes } from './deep-search.js';
import { registerProfileRecoveryRoutes } from './profile-recovery.js';
import { dirname, join as pathJoin } from 'node:path'; import { dirname, join as pathJoin } from 'node:path';
import { createRequestMonitor, registerRequestMonitorRoutes } from './request-monitor.js'; import { createRequestMonitor, registerRequestMonitorRoutes } from './request-monitor.js';
import { registerP2pAdminRoutes } from './p2p-admin.js'; import { registerP2pAdminRoutes } from './p2p-admin.js';
@@ -1881,10 +1882,13 @@ app.get('/api/profile/info', async (c) => {
const name = (c.req.query('name') || '').trim().toLowerCase(); const name = (c.req.query('name') || '').trim().toLowerCase();
if (!name) return c.json({ ok: false, error: 'missing name' }, 400); if (!name) return c.json({ ok: false, error: 'missing name' }, 400);
const row = await getProfile(name); const row = await getProfile(name);
return c.json({ ok: true, exists: !!row, protected: !!(row && row.secretHash), kind: row ? row.secretKind : null }, return c.json({ ok: true, exists: !!row, protected: !!(row && row.secretHash), kind: row ? row.secretKind : null,
recovery: row && row.secretHash ? profileRecovery.channels(row) : null },
200, { 'Cache-Control': 'no-store' }); 200, { 'Cache-Control': 'no-store' });
}); });
const profileRecovery = registerProfileRecoveryRoutes(app, { db: notesDb.db, getProfile, setProfileSecret, profileGate });
// POST /api/profile/secret // POST /api/profile/secret
// Body: { name, current?, secret: <new password/key> | null, kind: 'password'|'key' } // Body: { name, current?, secret: <new password/key> | null, kind: 'password'|'key' }
// Adds, changes or (secret: null) removes protection. Changing or removing an // Adds, changes or (secret: null) removes protection. Changing or removing an