Protected profiles: recovery email and Discord contacts with reset codes (Gmail SMTP, Discord relay)
This commit is contained in:
135
server/bun.lock
Normal file
135
server/bun.lock
Normal 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=="],
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
152
server/profile-recovery.js
Normal file
152
server/profile-recovery.js
Normal 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 };
|
||||
}
|
||||
77
server/profile-recovery.test.js
Normal file
77
server/profile-recovery.test.js
Normal 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');
|
||||
});
|
||||
@@ -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: <new password/key> | null, kind: 'password'|'key' }
|
||||
// Adds, changes or (secret: null) removes protection. Changing or removing an
|
||||
|
||||
Reference in New Issue
Block a user