diff --git a/.env b/.env deleted file mode 100644 index dce304b..0000000 --- a/.env +++ /dev/null @@ -1 +0,0 @@ -ADMIN_PASSWORD=TestAdmin123! diff --git a/.gitignore b/.gitignore index 1ab79e6..7215005 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,12 @@ data/backups/ data/hit-stats.json data/visitor-stats.json data/contact-submissions.json +data/totp-secret.json +data/uploads-meta.json + +# Local environment secrets — never commit +.env +.env.* # Editor directories and files .vscode/* diff --git a/data/admin-content-draft.json b/data/admin-content-draft.json index 2d76a58..fad070a 100644 --- a/data/admin-content-draft.json +++ b/data/admin-content-draft.json @@ -23,22 +23,15 @@ "seriesLabel": "Now Playing", "seriesTitle": "Study of Titus: Sound Doctrine", "seriesDescription": "A deep-dive into Paul's letter to Titus — unpacking what it means to build a church and a life on sound doctrine.", - "seriesImageUrl": "/uploads/titus-series-1777568297943.png", + "seriesImageUrl": "/uploads/titus_study_cover-1777916521873.png", "seriesListenUrl": "https://open.spotify.com/show/0Gq1TzoJOdReSZ1gYQi8Xl", "studyGuideTitle": "Companion Study Guide", "studyGuideDescription": "Go deeper in your study with the official Verse by Verse companion guide — now available on Amazon.", + "studyGuideDownloadUrl": "", "studyGuideUrl": "https://a.co/d/01sG2tOJ", "shareHeading": "Help one more person hear the Word this week.", "shareP": "Scan the QR code or text the show link to a friend who needs encouragement today.", - "customLinks": [ - { - "id": "molso9ow", - "label": "test", - "url": "/uploads/discussion_questions_post-1777565348551.png", - "imageUrl": "/uploads/discussion_questions_post-1777565348551.png", - "placement": "resources" - } - ], + "customLinks": [], "customBlocks": [], "archivedSeries": [], "redirects": [ @@ -108,5 +101,5 @@ ] } }, - "updatedAt": "2026-04-30T18:06:00.013Z" + "updatedAt": "2026-05-04T17:42:41.930Z" } \ No newline at end of file diff --git a/data/admin-content.json b/data/admin-content.json index 5666ead..cd924ad 100644 --- a/data/admin-content.json +++ b/data/admin-content.json @@ -23,22 +23,15 @@ "seriesLabel": "Now Playing", "seriesTitle": "Study of Titus: Sound Doctrine", "seriesDescription": "A deep-dive into Paul's letter to Titus — unpacking what it means to build a church and a life on sound doctrine.", - "seriesImageUrl": "/uploads/titus-series-1777568297943.png", + "seriesImageUrl": "/uploads/titus_study_cover-1777916521873.png", "seriesListenUrl": "https://open.spotify.com/show/0Gq1TzoJOdReSZ1gYQi8Xl", "studyGuideTitle": "Companion Study Guide", "studyGuideDescription": "Go deeper in your study with the official Verse by Verse companion guide — now available on Amazon.", + "studyGuideDownloadUrl": "", "studyGuideUrl": "https://a.co/d/01sG2tOJ", "shareHeading": "Help one more person hear the Word this week.", "shareP": "Scan the QR code or text the show link to a friend who needs encouragement today.", - "customLinks": [ - { - "id": "molso9ow", - "label": "test", - "url": "/uploads/discussion_questions_post-1777565348551.png", - "imageUrl": "/uploads/discussion_questions_post-1777565348551.png", - "placement": "resources" - } - ], + "customLinks": [], "customBlocks": [], "archivedSeries": [], "redirects": [ @@ -108,5 +101,5 @@ ] } }, - "updatedAt": "2026-04-30T18:06:00.017Z" + "updatedAt": "2026-05-04T17:42:41.949Z" } \ No newline at end of file diff --git a/data/uploads/discussion_questions_post-1777565348551.png b/data/uploads/discussion_questions_post-1777565348551.png deleted file mode 100644 index 74e82e5..0000000 Binary files a/data/uploads/discussion_questions_post-1777565348551.png and /dev/null differ diff --git a/data/uploads/titus-series-1777568297943.png b/data/uploads/titus-series-1777568297943.png deleted file mode 100644 index a1e353d..0000000 Binary files a/data/uploads/titus-series-1777568297943.png and /dev/null differ diff --git a/data/uploads/titus_study_cover-1777916521873.png b/data/uploads/titus_study_cover-1777916521873.png new file mode 100644 index 0000000..e01ad91 Binary files /dev/null and b/data/uploads/titus_study_cover-1777916521873.png differ diff --git a/package-lock.json b/package-lock.json index 20fdf2e..1c89db5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,9 @@ "version": "0.0.0", "dependencies": { "express": "^5.2.1", + "express-rate-limit": "^8.4.1", + "otplib": "^13.4.0", + "qrcode": "^1.5.4", "react": "^19.2.4", "react-dom": "^19.2.4", "react-markdown": "^10.1.0", @@ -582,6 +585,74 @@ "url": "https://github.com/sponsors/Brooooooklyn" } }, + "node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@otplib/core": { + "version": "13.4.0", + "resolved": "https://registry.npmjs.org/@otplib/core/-/core-13.4.0.tgz", + "integrity": "sha512-JqOGcvZQi2wIkEQo8f3/iAjstavpXy6gouIDMHygjNuH6Q0FjbHOiXMdcE94RwfgDNMABhzwUmvaPsxvgm9NYw==", + "license": "MIT" + }, + "node_modules/@otplib/hotp": { + "version": "13.4.0", + "resolved": "https://registry.npmjs.org/@otplib/hotp/-/hotp-13.4.0.tgz", + "integrity": "sha512-MJjE0x06mn2ptymz5qZmQveb+vWFuaIftqE0b5/TZZqUOK7l97cV8lRTmid5BpAQMwJDNLW6RnYxGeCRiNdekw==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.0", + "@otplib/uri": "13.4.0" + } + }, + "node_modules/@otplib/plugin-base32-scure": { + "version": "13.4.0", + "resolved": "https://registry.npmjs.org/@otplib/plugin-base32-scure/-/plugin-base32-scure-13.4.0.tgz", + "integrity": "sha512-/t9YWJmMbB8bF5z8mXrBZc2FXBe8B/3hG5FhWr9K8cFwFhyxScbPysmZe8s1UTzSA6N+s8Uv8aIfCtVXPNjJWw==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.0", + "@scure/base": "^2.0.0" + } + }, + "node_modules/@otplib/plugin-crypto-noble": { + "version": "13.4.0", + "resolved": "https://registry.npmjs.org/@otplib/plugin-crypto-noble/-/plugin-crypto-noble-13.4.0.tgz", + "integrity": "sha512-KrvE4m7Zv+TT1944HzgqFJWJpKb6AyoxDbvhPStmBqdMlv5Gekb80d66cuFRL08kkPgJ5gXUSb5SFpYeB+bACg==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^2.0.1", + "@otplib/core": "13.4.0" + } + }, + "node_modules/@otplib/totp": { + "version": "13.4.0", + "resolved": "https://registry.npmjs.org/@otplib/totp/-/totp-13.4.0.tgz", + "integrity": "sha512-dK+vl0f0ekzf6mCENRI9AKS2NJUC7OjI3+X8e7QSnhQ2WM7I+i4PGpb3QxKi5hxjTtwVuoZwXR2CFtXdcRtNdQ==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.0", + "@otplib/hotp": "13.4.0", + "@otplib/uri": "13.4.0" + } + }, + "node_modules/@otplib/uri": { + "version": "13.4.0", + "resolved": "https://registry.npmjs.org/@otplib/uri/-/uri-13.4.0.tgz", + "integrity": "sha512-x1ozBa5bPbdZCrrTL/HK21qchiK7jYElTu+0ft22abeEhiLYgH1+SIULvOcVk3CK8YwF4kdcidvkq4ciejucJA==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.0" + } + }, "node_modules/@oxc-project/runtime": { "version": "0.115.0", "resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.115.0.tgz", @@ -864,6 +935,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@scure/base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz", + "integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@stablelib/base64": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", @@ -1355,7 +1435,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -1365,7 +1444,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -1531,6 +1609,15 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001780", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz", @@ -1638,7 +1725,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -1651,7 +1737,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/comma-separated-tokens": { @@ -1801,6 +1886,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -1862,6 +1956,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1893,7 +1993,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/encodeurl": { @@ -2210,6 +2309,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.4.1.tgz", + "integrity": "sha512-NGVYwQSAyEQgzxX1iCM978PP9AdO/hW93gMcF6ZwQCm+rFvLsBH6w4xcXWTcliS8La5EPRN3p9wzItqBwJrfNw==", + "license": "MIT", + "dependencies": { + "ip-address": "10.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/express/node_modules/cookie": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", @@ -2398,7 +2515,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -2665,6 +2781,15 @@ "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", "license": "MIT" }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -2722,7 +2847,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4193,6 +4317,20 @@ "node": ">= 0.8.0" } }, + "node_modules/otplib": { + "version": "13.4.0", + "resolved": "https://registry.npmjs.org/otplib/-/otplib-13.4.0.tgz", + "integrity": "sha512-RUcYcRMCgRWhUE/XabRppXpUwCwaWBNHe5iPXhdvP8wwDGpGpsIf/kxX/ec3zFsOaM1Oq8lEhUqDwk6W7DHkwg==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.4.0", + "@otplib/hotp": "13.4.0", + "@otplib/plugin-base32-scure": "13.4.0", + "@otplib/plugin-crypto-noble": "13.4.0", + "@otplib/totp": "13.4.0", + "@otplib/uri": "13.4.0" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -4225,6 +4363,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -4276,7 +4423,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4322,6 +4468,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/postal-mime": { "version": "2.7.4", "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.4.tgz", @@ -4400,6 +4555,141 @@ "node": ">=6" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/qrcode/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "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" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", @@ -4595,12 +4885,17 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resend": { "version": "6.10.0", "resolved": "https://registry.npmjs.org/resend/-/resend-6.10.0.tgz", @@ -4766,6 +5061,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-cookie-parser": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", @@ -4929,7 +5230,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -4958,7 +5258,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -5451,6 +5750,12 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index 71d6ef6..54836e3 100644 --- a/package.json +++ b/package.json @@ -10,11 +10,13 @@ "build": "tsc -b && vite build", "start": "node --env-file=.env server.js", "lint": "eslint .", - "preview": "vite preview", - "chatbot:eval": "node scripts/evaluate-chatbot.mjs" + "preview": "vite preview" }, "dependencies": { "express": "^5.2.1", + "express-rate-limit": "^8.4.1", + "otplib": "^13.4.0", + "qrcode": "^1.5.4", "react": "^19.2.4", "react-dom": "^19.2.4", "react-markdown": "^10.1.0", diff --git a/server.js b/server.js index a35dfd6..e669db7 100644 --- a/server.js +++ b/server.js @@ -1,9 +1,11 @@ import express from 'express' +import rateLimit from 'express-rate-limit' import { mkdir, readdir, readFile, stat, unlink, writeFile } from 'node:fs/promises' import { createHash, randomUUID } from 'node:crypto' import path from 'node:path' import { fileURLToPath } from 'node:url' import { Resend } from 'resend' +import qrcode from 'qrcode' import { sanitizeSiteContent, escapeHtml, @@ -28,6 +30,17 @@ import { deleteAdminSession, validateAdminPasswordSetup, isAdminPasswordValid, + isTotpEnabled, + loadTotpState, + saveTotpState, + generateTotpSecret, + getTotpUri, + verifyTotpCode, + generateRecoveryCodes, + hashRecoveryCode, + consumeRecoveryCode, + createPendingSession, + consumePendingSession, } from './server/auth.js' const __filename = fileURLToPath(import.meta.url) @@ -40,7 +53,6 @@ const VISITOR_STATS_FILE = path.join(DATA_DIR, 'visitor-stats.json') const CONTACT_SUBMISSIONS_FILE = path.join(DATA_DIR, 'contact-submissions.json') const QUESTIONS_FILE = path.join(DATA_DIR, 'questions.json') const DRAFT_QUESTIONS_FILE = path.join(DATA_DIR, 'questions-draft.json') -const CHATBOT_FILE = path.join(DATA_DIR, 'chatbot-content.json') const BACKUP_DIR = path.join(DATA_DIR, 'backups') const UPLOADS_DIR = path.join(DATA_DIR, 'uploads') const UPLOADS_META_FILE = path.join(DATA_DIR, 'uploads-meta.json') @@ -227,7 +239,7 @@ async function writeUploadsMetadata(metadata) { async function listUploadedAssets() { await mkdir(UPLOADS_DIR, { recursive: true }) const files = await readdir(UPLOADS_DIR) - const imageFiles = files.filter(name => /\.(png|jpe?g|webp|gif)$/i.test(name)).sort() + const imageFiles = files.filter(name => /\.(png|jpe?g|webp|gif|pdf|docx?)$/i.test(name)).sort() const metadata = await readUploadsMetadata() const withStats = await Promise.all(imageFiles.map(async filename => { @@ -1090,7 +1102,7 @@ app.post('/api/admin-assets', requireAdminAuth, async (req, res) => { const ext = inferImageExtensionFromDataUrl(dataUrl) if (!ext) { - res.status(400).json({ message: 'Upload must be a PNG, JPG, WEBP, or GIF data URL.' }) + res.status(400).json({ message: 'Upload must be a PNG, JPG, WEBP, GIF, PDF, DOC, or DOCX data URL.' }) return } @@ -1193,93 +1205,6 @@ app.post('/api/admin-ops/deploy', requireAdminAuth, async (_req, res) => { res.json({ ok: true, message: result.message }) }) -// ── Chatbot knowledge base ────────────────────────────────────────────────── -const MAX_CHATBOT_ENTRIES = 500 -let chatbotEntries = [] -let chatbotWritePromise = Promise.resolve() -let chatbotFileMtimeMs = 0 - -function queueChatbotWrite() { - chatbotWritePromise = chatbotWritePromise - .then(async () => { - await mkdir(DATA_DIR, { recursive: true }) - await writeFile( - CHATBOT_FILE, - JSON.stringify(chatbotEntries, null, 2), - 'utf8', - ) - }) - .catch(err => { - console.error('[chatbot] failed to write chatbot content:', err) - }) -} - -async function loadChatbotFromDisk() { - try { - const [fileStats, raw] = await Promise.all([ - stat(CHATBOT_FILE), - readFile(CHATBOT_FILE, 'utf8'), - ]) - const parsed = JSON.parse(raw) - chatbotEntries = Array.isArray(parsed) ? parsed.slice(0, MAX_CHATBOT_ENTRIES) : [] - chatbotFileMtimeMs = fileStats.mtimeMs - } catch { - chatbotEntries = [] - chatbotFileMtimeMs = 0 - } -} - -async function refreshChatbotFromDiskIfChanged() { - try { - const fileStats = await stat(CHATBOT_FILE) - if (fileStats.mtimeMs <= chatbotFileMtimeMs) return - await loadChatbotFromDisk() - } catch { - if (chatbotFileMtimeMs === 0) return - chatbotEntries = [] - chatbotFileMtimeMs = 0 - } -} - -// Public: return all chatbot entries for client-side matching -app.get('/api/chatbot-content', async (req, res) => { - await refreshChatbotFromDiskIfChanged() - res.json(chatbotEntries) -}) - -// Admin: get all entries -app.get('/api/admin/chatbot-content', async (req, res) => { - if (!isValidAdminSession(req)) { res.status(401).json({ message: 'Not authenticated.' }); return } - await refreshChatbotFromDiskIfChanged() - res.json(chatbotEntries) -}) - -// Admin: save full list (replace all) -app.post('/api/admin/chatbot-content', (req, res) => { - if (!isValidAdminSession(req)) { res.status(401).json({ message: 'Not authenticated.' }); return } - const body = req.body - if (!Array.isArray(body)) { res.status(400).json({ message: 'Expected array.' }); return } - const sanitized = body - .filter(e => e && typeof e.title === 'string' && typeof e.content === 'string') - .slice(0, MAX_CHATBOT_ENTRIES) - .map(e => ({ - id: typeof e.id === 'string' && e.id ? e.id : randomUUID(), - type: ['qa', 'topic', 'episode'].includes(e.type) ? e.type : 'qa', - title: String(e.title).trim().slice(0, 500), - content: String(e.content).trim().slice(0, 4000), - sourceLabel: typeof e.sourceLabel === 'string' ? e.sourceLabel.trim().slice(0, 160) : '', - priority: e.priority === true, - keywords: Array.isArray(e.keywords) - ? e.keywords.filter(k => typeof k === 'string').map(k => k.trim().toLowerCase()).slice(0, 20) - : [], - createdAt: typeof e.createdAt === 'string' ? e.createdAt : new Date().toISOString(), - updatedAt: typeof e.updatedAt === 'string' ? e.updatedAt : new Date().toISOString(), - })) - chatbotEntries = sanitized - queueChatbotWrite() - res.json({ ok: true, count: chatbotEntries.length }) -}) - function queueQuestionsWrite() { questionsWritePromise = questionsWritePromise .then(async () => { @@ -1331,14 +1256,27 @@ function loadQuestionsFromDisk() { questions = [] }) } -app.get('/api/admin-auth/status', (req, res) => { +// Rate limiter: max 10 attempts per 15 minutes per IP on the login endpoint +const loginRateLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 10, + standardHeaders: true, + legacyHeaders: false, + message: { message: 'Too many login attempts. Please wait 15 minutes and try again.' }, + skipSuccessfulRequests: true, +}) + +app.get('/api/admin-auth/status', async (req, res) => { res.json({ authenticated: isValidAdminSession(req), configured: isAdminPasswordConfigured(), + totpEnabled: await isTotpEnabled(), }) }) -app.post('/api/admin-auth/login', (req, res) => { +// Step 1: verify password. If TOTP is enabled, returns a short-lived pending token. +// If TOTP is not yet configured, logs straight in (backwards compatible). +app.post('/api/admin-auth/login', loginRateLimiter, async (req, res) => { const password = typeof req.body?.password === 'string' ? req.body.password : '' if (!isAdminPasswordConfigured()) { @@ -1351,11 +1289,110 @@ app.post('/api/admin-auth/login', (req, res) => { return } + const totpOn = await isTotpEnabled() + if (totpOn) { + const pendingToken = createPendingSession() + res.json({ totpRequired: true, pendingToken }) + return + } + const sessionToken = createAdminSession() setAdminSessionCookie(res, sessionToken) res.json({ ok: true }) }) +// Step 2a: verify TOTP code (or recovery code) after password was accepted +app.post('/api/admin-auth/totp-verify', loginRateLimiter, async (req, res) => { + const { pendingToken, code } = req.body ?? {} + + if (!consumePendingSession(pendingToken)) { + res.status(401).json({ message: 'Session expired or invalid. Please sign in again.' }) + return + } + + const state = await loadTotpState() + if (!state?.secret || !state?.verified) { + res.status(400).json({ message: 'TOTP is not configured.' }) + return + } + + const codeStr = typeof code === 'string' ? code.trim() : '' + + // Try TOTP first + if (verifyTotpCode(state.secret, codeStr)) { + const sessionToken = createAdminSession() + setAdminSessionCookie(res, sessionToken) + res.json({ ok: true }) + return + } + + // Try recovery code + if (consumeRecoveryCode(state, codeStr)) { + await saveTotpState(state) + const sessionToken = createAdminSession() + setAdminSessionCookie(res, sessionToken) + res.json({ ok: true, usedRecoveryCode: true, remainingRecoveryCodes: state.hashedRecoveryCodes.length }) + return + } + + res.status(401).json({ message: 'Invalid code. Try again or use a recovery code.' }) +}) + +// TOTP setup: generate a new secret and QR code (admin must be authenticated OR provide valid password) +app.post('/api/admin-auth/totp-setup-init', requireAdminAuth, async (req, res) => { + const secret = generateTotpSecret() + const uri = getTotpUri(secret) + const qrDataUrl = await qrcode.toDataURL(uri) + // Store unverified secret temporarily + const existing = await loadTotpState() + await saveTotpState({ ...existing, secret, verified: false }) + res.json({ qrDataUrl, secret }) +}) + +// TOTP setup: confirm the code to mark TOTP as verified and generate recovery codes +app.post('/api/admin-auth/totp-setup-confirm', requireAdminAuth, async (req, res) => { + const { code } = req.body ?? {} + const state = await loadTotpState() + + if (!state?.secret) { + res.status(400).json({ message: 'No TOTP setup in progress. Call /totp-setup-init first.' }) + return + } + + if (!verifyTotpCode(state.secret, typeof code === 'string' ? code.trim() : '')) { + res.status(401).json({ message: 'Code incorrect. Scan the QR code again and try once more.' }) + return + } + + const recoveryCodes = generateRecoveryCodes() + await saveTotpState({ + secret: state.secret, + verified: true, + hashedRecoveryCodes: recoveryCodes.map(hashRecoveryCode), + enabledAt: new Date().toISOString(), + }) + + res.json({ ok: true, recoveryCodes }) +}) + +// Disable TOTP (requires active admin session) +app.post('/api/admin-auth/totp-disable', requireAdminAuth, async (req, res) => { + await saveTotpState({ secret: null, verified: false, hashedRecoveryCodes: [], disabledAt: new Date().toISOString() }) + res.json({ ok: true }) +}) + +// Regenerate recovery codes (requires active admin session) +app.post('/api/admin-auth/totp-regen-recovery', requireAdminAuth, async (req, res) => { + const state = await loadTotpState() + if (!state?.secret || !state?.verified) { + res.status(400).json({ message: 'TOTP is not enabled.' }) + return + } + const recoveryCodes = generateRecoveryCodes() + await saveTotpState({ ...state, hashedRecoveryCodes: recoveryCodes.map(hashRecoveryCode) }) + res.json({ ok: true, recoveryCodes }) +}) + app.post('/api/admin-auth/logout', (req, res) => { const cookies = parseCookies(req.headers.cookie) const sessionToken = cookies[ADMIN_SESSION_COOKIE] @@ -1592,11 +1629,16 @@ app.post('/api/study-downloads/titus', studyDownloadRateLimit, async (req, res) return } - try { - await stat(TITUS_STUDY_FILE) - } catch { - res.status(503).json({ message: 'The Titus study file is not configured yet.' }) - return + const published = await loadSiteContentFile(DATA_FILE) + const configuredDownloadUrl = sanitizeUrl(published?.siteContent?.studyGuideDownloadUrl) + + if (!configuredDownloadUrl) { + try { + await stat(TITUS_STUDY_FILE) + } catch { + res.status(503).json({ message: 'The primary study guide download URL is not configured yet.' }) + return + } } const trimmedFirstName = firstName.trim() @@ -1617,6 +1659,11 @@ app.post('/api/study-downloads/titus', studyDownloadRateLimit, async (req, res) await syncContactToResend(trimmedName, trimmedEmail) } + if (configuredDownloadUrl) { + res.json({ ok: true, downloadUrl: configuredDownloadUrl }) + return + } + const token = createTitusDownloadToken(trimmedEmail) res.json({ ok: true, downloadUrl: `/api/study-downloads/titus/file?token=${encodeURIComponent(token)}` }) } catch (err) { @@ -1640,9 +1687,46 @@ app.post('/api/resource-download', studyDownloadRateLimit, async (req, res) => { } const published = await loadSiteContentFile(DATA_FILE) - const resource = Array.isArray(published?.siteContent?.customLinks) - ? published.siteContent.customLinks.find(link => link.id === resourceId && link.placement === 'resources') - : undefined + const siteContent = published?.siteContent + + function resolveResourceFromId(id) { + if (!siteContent || typeof siteContent !== 'object') return null + + const customResources = Array.isArray(siteContent.customLinks) + ? siteContent.customLinks.filter(link => link?.placement === 'resources') + : [] + + if (id.startsWith('custom:')) { + const customId = id.slice('custom:'.length) + const match = customResources.find(link => link.id === customId) + return match ? { label: match.label, url: match.url } : null + } + + if (id.startsWith('archived:')) { + const [, seriesId, ...linkIdParts] = id.split(':') + const linkId = linkIdParts.join(':') + const archivedSeries = Array.isArray(siteContent.archivedSeries) ? siteContent.archivedSeries : [] + const series = archivedSeries.find(item => item.id === seriesId) + const link = Array.isArray(series?.resourceLinks) + ? series.resourceLinks.find(item => item.id === linkId) + : null + return link ? { label: link.label || series?.title, url: link.url } : null + } + + const customMatch = customResources.find(link => link.id === id) + if (customMatch) return { label: customMatch.label, url: customMatch.url } + + const archivedSeries = Array.isArray(siteContent.archivedSeries) ? siteContent.archivedSeries : [] + for (const series of archivedSeries) { + if (!Array.isArray(series?.resourceLinks)) continue + const link = series.resourceLinks.find(item => item.id === id) + if (link) return { label: link.label || series?.title, url: link.url } + } + + return null + } + + const resource = resolveResourceFromId(resourceId) if (!resource || typeof resource.url !== 'string' || !resource.url.trim()) { res.status(400).json({ message: 'Resource not found.' }) @@ -2061,7 +2145,6 @@ Promise.all([ loadContactSubmissionsFromDisk(), loadQuestionsFromDisk(), loadDraftQuestionsFromDisk(), - loadChatbotFromDisk(), refreshContentCaches(), ]) .catch(err => { diff --git a/server/auth.js b/server/auth.js index b1f8225..bcd7f0e 100644 --- a/server/auth.js +++ b/server/auth.js @@ -1,6 +1,18 @@ -import { createHash, randomUUID } from 'node:crypto' +import { createHash, randomUUID, timingSafeEqual, createHmac, randomFillSync } from 'node:crypto' +import { readFile, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' import { parseCookies } from './helpers.js' +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const TOTP_SECRET_FILE = path.join(__dirname, '..', 'data', 'totp-secret.json') + +// Pending sessions: password verified, waiting for TOTP code +// Map +const TOTP_PENDING_TTL_MS = 5 * 60 * 1000 +const totpPendingSessions = new Map() + const ADMIN_SESSION_COOKIE = 'vbn_admin_session' const ADMIN_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000 const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD @@ -22,7 +34,6 @@ export function validateAdminPasswordSetup() { if (!isAdminPasswordConfigured() && process.env.NODE_ENV === 'production') { throw new Error('ADMIN_PASSWORD is required in production.') } - if (!isAdminPasswordConfigured()) { console.warn('ADMIN_PASSWORD is not configured; admin routes will remain disabled until the environment is configured.') } @@ -30,9 +41,180 @@ export function validateAdminPasswordSetup() { export function isAdminPasswordValid(password) { if (!isAdminPasswordConfigured()) return false - return sha256(password) === sha256(ADMIN_PASSWORD) + const a = Buffer.from(sha256(password), 'utf8') + const b = Buffer.from(sha256(ADMIN_PASSWORD), 'utf8') + if (a.length !== b.length) return false + return timingSafeEqual(a, b) } +// ── TOTP (RFC 6238) — implemented with Node built-in crypto ───────────────── + +const BASE32_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567' + +function base32Decode(str) { + const s = str.toUpperCase().replace(/=+$/, '') + let bits = 0 + let value = 0 + const output = [] + for (const char of s) { + const idx = BASE32_CHARS.indexOf(char) + if (idx === -1) continue + value = (value << 5) | idx + bits += 5 + if (bits >= 8) { + output.push((value >>> (bits - 8)) & 0xff) + bits -= 8 + } + } + return Buffer.from(output) +} + +function base32Encode(buf) { + let bits = 0 + let value = 0 + let output = '' + for (const byte of buf) { + value = (value << 8) | byte + bits += 8 + while (bits >= 5) { + output += BASE32_CHARS[(value >>> (bits - 5)) & 0x1f] + bits -= 5 + } + } + if (bits > 0) output += BASE32_CHARS[(value << (5 - bits)) & 0x1f] + return output +} + +function totpToken(secret, counter) { + const key = base32Decode(secret) + const msg = Buffer.alloc(8) + // Write 64-bit big-endian counter + const hi = Math.floor(counter / 0x100000000) + const lo = counter >>> 0 + msg.writeUInt32BE(hi, 0) + msg.writeUInt32BE(lo, 4) + const hmac = createHmac('sha1', key).update(msg).digest() + const offset = hmac[hmac.length - 1] & 0x0f + const code = ((hmac[offset] & 0x7f) << 24) + | (hmac[offset + 1] << 16) + | (hmac[offset + 2] << 8) + | hmac[offset + 3] + return String(code % 1000000).padStart(6, '0') +} + +export function generateTotpSecret() { + const buf = Buffer.allocUnsafe(20) + randomFillSync(buf) + return base32Encode(buf) +} + +export async function loadTotpState() { + try { + const raw = await readFile(TOTP_SECRET_FILE, 'utf8') + return JSON.parse(raw) + } catch { + return null + } +} + +export async function saveTotpState(state) { + await writeFile(TOTP_SECRET_FILE, JSON.stringify(state, null, 2), 'utf8') +} + +export async function isTotpEnabled() { + const state = await loadTotpState() + return Boolean(state?.secret && state?.verified) +} + +function randomBytesForRecovery(n) { + const buf = Buffer.allocUnsafe(n) + randomFillSync(buf) + return buf +} + +export function getTotpUri(secret, label = 'Siteforge Admin') { + const issuer = 'Siteforge' + return `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(label)}?secret=${secret}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30` +} + +export function verifyTotpCode(secret, code) { + try { + const token = String(code).replace(/\s/g, '') + const step = Math.floor(Date.now() / 1000 / 30) + // Accept current step and one step either side (±30 seconds clock skew) + for (const offset of [-1, 0, 1]) { + if (totpToken(secret, step + offset) === token) return true + } + return false + } catch { + return false + } +} + +// ── Recovery Codes ────────────────────────────────────────────────────────── + +const RECOVERY_CODE_COUNT = 8 + +function generateRecoveryCode() { + // Format: XXXX-XXXX-XXXX (uppercase alphanumeric, no ambiguous chars) + const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' + const randBytes = randomBytesForRecovery(12) + let code = '' + for (let i = 0; i < 12; i++) { + if (i > 0 && i % 4 === 0) code += '-' + code += chars[randBytes[i] % chars.length] + } + return code +} + +export function generateRecoveryCodes() { + const codes = [] + for (let i = 0; i < RECOVERY_CODE_COUNT; i++) { + codes.push(generateRecoveryCode()) + } + return codes +} + +export function hashRecoveryCode(code) { + return sha256(code.replace(/-/g, '').toUpperCase()) +} + +// Returns the matched code if valid, null otherwise. Mutates state.hashedRecoveryCodes. +export function consumeRecoveryCode(state, inputCode) { + if (!Array.isArray(state.hashedRecoveryCodes) || state.hashedRecoveryCodes.length === 0) return false + const normalized = inputCode.replace(/[-\s]/g, '').toUpperCase() + const inputHash = sha256(normalized) + const idx = state.hashedRecoveryCodes.findIndex(h => { + const a = Buffer.from(h, 'utf8') + const b = Buffer.from(inputHash, 'utf8') + return a.length === b.length && timingSafeEqual(a, b) + }) + if (idx === -1) return false + state.hashedRecoveryCodes.splice(idx, 1) + return true +} + +// ── Pending (password-ok, awaiting TOTP) sessions ─────────────────────────── + +export function createPendingSession() { + const token = randomUUID() + totpPendingSessions.set(token, { expiresAt: Date.now() + TOTP_PENDING_TTL_MS }) + return token +} + +export function consumePendingSession(token) { + if (!token) return false + const entry = totpPendingSessions.get(token) + if (!entry || entry.expiresAt <= Date.now()) { + totpPendingSessions.delete(token) + return false + } + totpPendingSessions.delete(token) + return true +} + +// ── Admin Sessions ─────────────────────────────────────────────────────────── + export function createAdminSession() { const token = randomUUID() adminSessions.set(token, Date.now() + ADMIN_SESSION_TTL_MS) diff --git a/server/helpers.js b/server/helpers.js index aad6fdf..6a3aef3 100644 --- a/server/helpers.js +++ b/server/helpers.js @@ -171,6 +171,7 @@ function sanitizeCustomLinks(value) { label: typeof item.label === 'string' ? item.label.trim().slice(0, 140) : '', url: sanitizeUrl(item.url), imageUrl: sanitizeUrl(item.imageUrl), + description: typeof item.description === 'string' ? item.description.trim().slice(0, 400) : '', placement, } }) @@ -196,6 +197,7 @@ function sanitizeArchivedSeriesResourceLinks(value) { .map(item => ({ id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(), label: typeof item.label === 'string' ? item.label.trim().slice(0, 120) : '', + description: typeof item.description === 'string' ? item.description.trim().slice(0, 400) : '', url: sanitizeUrl(item.url), })) .filter(item => item.label && item.url) @@ -334,6 +336,9 @@ export function inferImageExtensionFromDataUrl(dataUrl) { if (dataUrl.startsWith('data:image/jpeg;base64,')) return '.jpg' if (dataUrl.startsWith('data:image/webp;base64,')) return '.webp' if (dataUrl.startsWith('data:image/gif;base64,')) return '.gif' + if (dataUrl.startsWith('data:application/pdf;base64,')) return '.pdf' + if (dataUrl.startsWith('data:application/msword;base64,')) return '.doc' + if (dataUrl.startsWith('data:application/vnd.openxmlformats-officedocument.wordprocessingml.document;base64,')) return '.docx' return null } diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index 55f116c..a9b79bb 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -213,6 +213,14 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { const [backupFiles, setBackupFiles] = useState([]) const [selectedBackup, setSelectedBackup] = useState('') const [selectedBackupPreview, setSelectedBackupPreview] = useState(null) + + // TOTP management state + const [totpEnabled, setTotpEnabled] = useState(null) + const [totpSetupQr, setTotpSetupQr] = useState(null) + const [totpSetupSecret, setTotpSetupSecret] = useState(null) + const [totpConfirmCode, setTotpConfirmCode] = useState('') + const [totpMsg, setTotpMsg] = useState('') + const [totpRecoveryCodes, setTotpRecoveryCodes] = useState(null) const [publishState, setPublishState] = useState({ draftUpdatedAt: null, publishedAt: null }) const [assets, setAssets] = useState([]) const [assetTagEdits, setAssetTagEdits] = useState>({}) @@ -243,6 +251,13 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { return () => window.removeEventListener('beforeunload', handleBeforeUnload) }, [isDirty]) + useEffect(() => { + fetch('/api/admin-auth/status') + .then(r => r.ok ? r.json() : Promise.reject()) + .then(data => setTotpEnabled(!!(data as { totpEnabled?: boolean }).totpEnabled)) + .catch(() => {}) + }, []) + useEffect(() => { fetch('/api/admin-stats') .then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load stats')))) @@ -458,6 +473,53 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { })) } + async function handleTotpSetupInit() { + setTotpMsg('') + setTotpRecoveryCodes(null) + const res = await fetch('/api/admin-auth/totp-setup-init', { method: 'POST' }) + const data = await res.json().catch(() => ({})) as { qrDataUrl?: string; secret?: string; message?: string } + if (!res.ok) { setTotpMsg(data.message ?? 'Setup failed.'); return } + setTotpSetupQr(data.qrDataUrl ?? null) + setTotpSetupSecret(data.secret ?? null) + setTotpConfirmCode('') + } + + async function handleTotpSetupConfirm(e: React.FormEvent) { + e.preventDefault() + setTotpMsg('') + const res = await fetch('/api/admin-auth/totp-setup-confirm', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code: totpConfirmCode }), + }) + const data = await res.json().catch(() => ({})) as { ok?: boolean; recoveryCodes?: string[]; message?: string } + if (!res.ok) { setTotpMsg(data.message ?? 'Confirmation failed.'); return } + setTotpEnabled(true) + setTotpSetupQr(null) + setTotpSetupSecret(null) + setTotpConfirmCode('') + setTotpRecoveryCodes(data.recoveryCodes ?? null) + setTotpMsg('Two-factor authentication enabled.') + } + + async function handleTotpDisable() { + if (!confirm('Disable two-factor authentication? This will make your admin less secure.')) return + setTotpMsg('') + const res = await fetch('/api/admin-auth/totp-disable', { method: 'POST' }) + if (res.ok) { setTotpEnabled(false); setTotpRecoveryCodes(null); setTotpMsg('Two-factor authentication disabled.') } + else { const d = await res.json().catch(() => ({})) as { message?: string }; setTotpMsg(d.message ?? 'Failed to disable TOTP.') } + } + + async function handleTotpRegenRecovery() { + if (!confirm('Regenerate recovery codes? Your old codes will stop working immediately.')) return + setTotpMsg('') + const res = await fetch('/api/admin-auth/totp-regen-recovery', { method: 'POST' }) + const data = await res.json().catch(() => ({})) as { ok?: boolean; recoveryCodes?: string[]; message?: string } + if (!res.ok) { setTotpMsg(data.message ?? 'Failed.'); return } + setTotpRecoveryCodes(data.recoveryCodes ?? null) + setTotpMsg('New recovery codes generated. Save these now.') + } + function addRedirectRule() { setForm(f => ({ ...f, @@ -760,6 +822,35 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { ) } + function renderFileAssetSelector(value: string | null | undefined, onChange: (value: string) => void, fieldId: string) { + const assetOptions = [...assets.map(asset => ({ label: asset.filename, value: asset.url }))] + const currentValue = value?.trim() ?? '' + + if (currentValue && !assetOptions.some(item => item.value === currentValue)) { + assetOptions.unshift({ label: `Current file (${currentValue})`, value: currentValue }) + } + + if (assetOptions.length === 0) return null + + return ( +
+ + +
+ ) + } + + function isImageAsset(filename: string) { + return /\.(png|jpe?g|webp|gif)$/i.test(filename) + } + function confirmLeaveUnsavedChanges() { if (!isDirty) return true return confirm('You have unsaved changes. Leave this section without saving?') @@ -792,7 +883,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { ...f, customLinks: [ ...(f.customLinks ?? []), - { id: Date.now().toString(36), label: '', url: '', imageUrl: '', placement: 'resources' as const }, + { id: Date.now().toString(36), label: '', url: '', imageUrl: '', description: '', placement: 'resources' as const }, ], })) } @@ -808,6 +899,15 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { setForm(f => ({ ...f, customLinks: (f.customLinks ?? []).filter(l => l.id !== id) })) } + function moveLinkToResources(id: string) { + setForm(f => ({ + ...f, + customLinks: (f.customLinks ?? []).map(link => ( + link.id === id ? { ...link, placement: 'resources' as const } : link + )), + })) + } + function addBlock() { setForm(f => ({ ...f, @@ -870,7 +970,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { ...series, resourceLinks: [ ...(series.resourceLinks ?? []), - { id: `${seriesId}-${Date.now().toString(36)}`, label: '', url: '' }, + { id: `${seriesId}-${Date.now().toString(36)}`, label: '', description: '', url: '' }, ], } : series), @@ -923,6 +1023,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { { id: `${seriesId}-${Date.now().toString(36)}`, label: source.label, + description: source.description ?? '', url: source.url, }, ], @@ -949,6 +1050,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { .map(link => ({ id: `${seriesId}-${Date.now().toString(36)}-${link.id}`, label: link.label, + description: link.description ?? '', url: link.url, })) @@ -1114,6 +1216,12 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { alert('Failed to delete question') } } + + const resourceLinks = (form.customLinks ?? []).filter(link => link.placement === 'resources') + const archivedResourceCount = (form.archivedSeries ?? []).reduce((count, series) => { + return count + (series.resourceLinks ?? []).length + }, 0) + return (
@@ -1301,39 +1409,50 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {

No episode highlights yet. Add one below.

)} {(form.podcastFeaturedLinks ?? []).map(item => ( -
-
-
- - updatePodcastLink(item.id, 'episodeNumber', e.target.value)} /> +
+ +
+ {item.title || 'Untitled episode highlight'} +

{item.episodeNumber ? `Episode ${item.episodeNumber}` : 'No episode number yet'}

-
- - updatePodcastLink(item.id, 'title', e.target.value)} /> -
-
- -