diff --git a/docker-compose.yml b/docker-compose.yml index db7564a..f510626 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,6 +29,12 @@ services: # Unlocks /admin (API tokens, lyric/chapter history + restore). Set it in # Dokploy's Environment tab β never commit it. Unset = admin disabled. 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 # network (same public IP as seen by the server). Off by default. REMOTE_SAME_NETWORK: "${REMOTE_SAME_NETWORK:-0}" diff --git a/frontend/app.js b/frontend/app.js index bbafd6a..c9549b1 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -868,6 +868,7 @@ function askProfileSecret(name, kind, wrong) { showModal('π Protected profile', body, [ { label: 'Cancel', onClick: () => done(null) }, { 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); } }, ]); setTimeout(() => $('profileSecretInput') && $('profileSecretInput').focus(), 50); @@ -1500,7 +1501,8 @@ function protectProfileFlow() { ${prot ? 'Change or remove the protection on' : 'Protect'} ${escapeHtml(data.profile.name)}. Once protected, the name alone no longer opens it β a device also needs the password, the profile key, or the credentials file. If you lose all of - them the profile can't be opened. + them the profile can't be opened β unless you add a recovery email or + Discord under Recoveryβ¦ afterwards.
`; @@ -1525,6 +1527,7 @@ function protectProfileFlow() { } 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 () => { try { await setProfileSecretOnServer(null, null); closeModal(); toast('Protection removed β the name alone opens this profile again'); } catch (e) { toast('β ' + e.message); } @@ -1533,6 +1536,98 @@ function protectProfileFlow() { 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]) ? '' : ' (not switched on for this server yet)'; + const body = document.createElement('div'); + body.innerHTML = ` ++ If you forget the password of ${escapeHtml(name)}, a reset code can be sent to one of these. +
+ + + + ++ 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. +
`; + 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 = `+ Send a reset code for ${escapeHtml(name)}. It is valid for 15 minutes.
`; + 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 = ` +Enter the 8-digit code and a new password for ${escapeHtml(name)}.
+ + + `; + 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. async function exportProfileCredentials() { const name = data.profile && data.profile.name; diff --git a/server/bun.lock b/server/bun.lock new file mode 100644 index 0000000..c4c4b73 --- /dev/null +++ b/server/bun.lock @@ -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=="], + } +} diff --git a/server/db.js b/server/db.js index fd31bd2..3b053b3 100644 --- a/server/db.js +++ b/server/db.js @@ -316,7 +316,7 @@ export async function initDb() { )`); // Optional profile protection: a password or generated key, stored only as a // 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 */ } } } @@ -763,12 +763,13 @@ export async function createProfile(name, dataJson) { export async function getProfile(name) { 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], }); const row = r.rows[0]; 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) { diff --git a/server/package.json b/server/package.json index 0a58eb6..5c3858f 100644 --- a/server/package.json +++ b/server/package.json @@ -6,12 +6,13 @@ "scripts": { "start": "bun 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": { "@hono/node-server": "^1.14.0", "@libsql/client": "^0.14.0", "hono": "^4.7.10", + "nodemailer": "^6", "qrcode": "^1.5.4" } } diff --git a/server/profile-recovery.js b/server/profile-recovery.js new file mode 100644 index 0000000..b384d22 --- /dev/null +++ b/server/profile-recovery.js @@ -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 }; +} diff --git a/server/profile-recovery.test.js b/server/profile-recovery.test.js new file mode 100644 index 0000000..0cc2c27 --- /dev/null +++ b/server/profile-recovery.test.js @@ -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'); +}); diff --git a/server/server.js b/server/server.js index a9eaf13..84aaf51 100644 --- a/server/server.js +++ b/server/server.js @@ -70,6 +70,7 @@ import QRCode from 'qrcode'; import { createYtdlpPool } from './ytdlp-pool.js'; import { createWarmQueue } from './warm-queue.js'; import { registerDeepSearchRoutes } from './deep-search.js'; +import { registerProfileRecoveryRoutes } from './profile-recovery.js'; import { dirname, join as pathJoin } from 'node:path'; import { createRequestMonitor, registerRequestMonitorRoutes } from './request-monitor.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(); if (!name) return c.json({ ok: false, error: 'missing name' }, 400); 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' }); }); +const profileRecovery = registerProfileRecoveryRoutes(app, { db: notesDb.db, getProfile, setProfileSecret, profileGate }); + // POST /api/profile/secret // Body: { name, current?, secret: