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 8dac51b..fad070a 100644 --- a/data/admin-content-draft.json +++ b/data/admin-content-draft.json @@ -17,13 +17,17 @@ "aboutShowP1": "Verse by Verse with Nate walks through Scripture passage by passage — unpacking the original context, drawing out the meaning, and connecting each verse to how we live today.", "aboutShowP2": "Whether you're in the car, at the gym, or just looking for something to anchor your day, each episode is designed to feed your faith with solid, practical teaching.", "aboutNate": "Nate Emmert is a husband, dad, and lifelong student of the Bible from Lynchburg, Va. He's not a pastor or a professor — just someone who fell in love with digging into Scripture and wanted to bring others along for the journey. He created Verse by Verse to make deep Bible study accessible to anyone, whether you've read the Bible your whole life or you're just getting started. No seminary required. No prior knowledge assumed. Just the Word, unpacked verse by verse.", + "aboutPhotoUrl": "/images/nate-photo.jpeg", + "aboutVerseArtUrl": "/images/hebrews-4-12-verse-art.png", + "contactPhotoUrl": "/images/nate-contact-photo.png", "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": "/images/titus-cover.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.", @@ -97,5 +101,5 @@ ] } }, - "updatedAt": "2026-04-27T20:24:22.589Z" + "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 ecbb29e..cd924ad 100644 --- a/data/admin-content.json +++ b/data/admin-content.json @@ -17,13 +17,17 @@ "aboutShowP1": "Verse by Verse with Nate walks through Scripture passage by passage — unpacking the original context, drawing out the meaning, and connecting each verse to how we live today.", "aboutShowP2": "Whether you're in the car, at the gym, or just looking for something to anchor your day, each episode is designed to feed your faith with solid, practical teaching.", "aboutNate": "Nate Emmert is a husband, dad, and lifelong student of the Bible from Lynchburg, Va. He's not a pastor or a professor — just someone who fell in love with digging into Scripture and wanted to bring others along for the journey. He created Verse by Verse to make deep Bible study accessible to anyone, whether you've read the Bible your whole life or you're just getting started. No seminary required. No prior knowledge assumed. Just the Word, unpacked verse by verse.", + "aboutPhotoUrl": "/images/nate-photo.jpeg", + "aboutVerseArtUrl": "/images/hebrews-4-12-verse-art.png", + "contactPhotoUrl": "/images/nate-contact-photo.png", "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": "/images/titus-cover.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.", @@ -97,5 +101,5 @@ ] } }, - "updatedAt": "2026-04-27T20:24:19.789Z" + "updatedAt": "2026-05-04T17:42:41.949Z" } \ No newline at end of file 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 b4c1ce0..e669db7 100644 --- a/server.js +++ b/server.js @@ -1,26 +1,47 @@ 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' - -function escapeHtml(value) { - return String(value) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') -} - -function splitName(fullName) { - const parts = fullName.trim().split(/\s+/).filter(Boolean) - return { - firstName: parts[0] ?? '', - lastName: parts.slice(1).join(' '), - } -} +import qrcode from 'qrcode' +import { + sanitizeSiteContent, + escapeHtml, + escapeXml, + buildAbsoluteUrl, + injectSeoIntoHtml, + normalizeAssetBaseName, + inferImageExtensionFromDataUrl, + getClientIp, + hasVisitorConsent, + setConsentCookie, + splitName, + parseCookies, +} from './server/helpers.js' +import { + isAdminPasswordConfigured, + isValidAdminSession, + requireAdminAuth, + setAdminSessionCookie, + clearAdminSessionCookie, + createAdminSession, + deleteAdminSession, + validateAdminPasswordSetup, + isAdminPasswordValid, + isTotpEnabled, + loadTotpState, + saveTotpState, + generateTotpSecret, + getTotpUri, + verifyTotpCode, + generateRecoveryCodes, + hashRecoveryCode, + consumeRecoveryCode, + createPendingSession, + consumePendingSession, +} from './server/auth.js' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) @@ -31,13 +52,15 @@ const HIT_STATS_FILE = path.join(DATA_DIR, 'hit-stats.json') 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 CHATBOT_FILE = path.join(DATA_DIR, 'chatbot-content.json') +const DRAFT_QUESTIONS_FILE = path.join(DATA_DIR, 'questions-draft.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') const DIST_DIR = path.join(__dirname, 'dist') const INDEX_FILE = path.join(DIST_DIR, 'index.html') const DIST_IMAGES_DIR = path.join(DIST_DIR, 'images') const PUBLIC_IMAGES_DIR = path.join(__dirname, 'public', 'images') +validateAdminPasswordSetup() const TITUS_STUDY_FILE = process.env.TITUS_STUDY_FILE ? path.resolve(__dirname, process.env.TITUS_STUDY_FILE) : path.join(__dirname, 'A_Study_of_Titus.pdf') @@ -62,6 +85,50 @@ const DEFAULT_REDIRECT_RULES = [ statusCode: 301, }, ] + +function normalizeRedirectPath(value) { + if (typeof value !== 'string') return '' + const trimmed = value.trim() + if (!trimmed) return '' + const withSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}` + const normalized = withSlash.replace(/\/+/g, '/') + if (normalized === '/') return '' + if (normalized.startsWith('/api/') || normalized.startsWith('/admin')) return '' + return normalized +} + +function sanitizeUrl(value) { + if (typeof value !== 'string') return '' + const trimmed = value.trim() + if (!trimmed) return '' + if (trimmed.startsWith('/')) return trimmed + if (/^https?:\/\//i.test(trimmed)) return trimmed + return '' +} + +function sanitizeRedirectRules(value) { + const source = Array.isArray(value) ? value : [] + const seen = new Set() + const out = [] + + for (const item of source) { + const pathValue = normalizeRedirectPath(item?.path) + const target = sanitizeUrl(item?.target) + const statusCode = Number(item?.statusCode) === 302 ? 302 : 301 + if (!pathValue || !target) continue + if (seen.has(pathValue)) continue + seen.add(pathValue) + out.push({ + id: typeof item?.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(), + path: pathValue, + target, + statusCode, + }) + } + + return out.length > 0 ? out : DEFAULT_REDIRECT_RULES +} + const DEFAULT_SEO = { title: 'Verse by Verse with Nate', description: 'Verse by Verse with Nate explores Scripture one verse at a time with practical Bible teaching.', @@ -95,130 +162,8 @@ const DEFAULT_PUBLISH_STATE = { let cachedSiteContent = null let cachedDraftSiteContent = null let publishState = { ...DEFAULT_PUBLISH_STATE } - -function sanitizeUrl(value) { - if (typeof value !== 'string') return '' - const trimmed = value.trim() - if (!trimmed) return '' - if (trimmed.startsWith('/')) return trimmed - if (/^https?:\/\//i.test(trimmed)) return trimmed - return '' -} - -function normalizeRedirectPath(value) { - if (typeof value !== 'string') return '' - const trimmed = value.trim() - if (!trimmed) return '' - const withSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}` - const normalized = withSlash.replace(/\/+/g, '/') - if (normalized === '/') return '' - if (normalized.startsWith('/api/') || normalized.startsWith('/admin')) return '' - return normalized -} - -function normalizeSitemapPath(value) { - if (typeof value !== 'string') return '' - const trimmed = value.trim() - if (!trimmed) return '' - if (trimmed === '/') return '/' - return normalizeRedirectPath(trimmed) -} - -function sanitizeRedirectRules(value) { - const source = Array.isArray(value) ? value : [] - const seen = new Set() - const out = [] - - for (const item of source) { - const pathValue = normalizeRedirectPath(item?.path) - const target = sanitizeUrl(item?.target) - const statusCode = Number(item?.statusCode) === 302 ? 302 : 301 - if (!pathValue || !target) continue - if (seen.has(pathValue)) continue - seen.add(pathValue) - out.push({ - id: typeof item?.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(), - path: pathValue, - target, - statusCode, - }) - } - - return out.length > 0 ? out : DEFAULT_REDIRECT_RULES -} - -function sanitizeFeaturedLinks(value) { - const source = Array.isArray(value) ? value : [] - return source - .filter(item => item && typeof item === 'object') - .map(item => { - const discussionQuestions = Array.isArray(item.discussionQuestions) - ? item.discussionQuestions - .filter(question => typeof question === 'string') - .map(question => question.trim()) - .filter(Boolean) - .slice(0, 30) - : [] - - return { - id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(), - title: typeof item.title === 'string' ? item.title.trim().slice(0, 140) : '', - episodeNumber: typeof item.episodeNumber === 'string' ? item.episodeNumber.trim().slice(0, 20) : '', - summary: typeof item.summary === 'string' ? item.summary.trim().slice(0, 600) : '', - url: sanitizeUrl(item.url), - embedUrl: sanitizeUrl(item.embedUrl), - showNotes: typeof item.showNotes === 'string' ? item.showNotes.trim().slice(0, 10000) : '', - discussionQuestions, - } - }) - .filter(item => item.title || item.summary || item.url || item.embedUrl || item.showNotes || item.discussionQuestions.length > 0) -} - -function sanitizeSiteContent(siteContent) { - if (!siteContent || typeof siteContent !== 'object' || Array.isArray(siteContent)) return {} - - const seo = siteContent.seo && typeof siteContent.seo === 'object' ? siteContent.seo : {} - const legal = siteContent.legal && typeof siteContent.legal === 'object' ? siteContent.legal : {} - - return { - ...siteContent, - redirects: sanitizeRedirectRules(siteContent.redirects), - podcastFeaturedLinks: sanitizeFeaturedLinks(siteContent.podcastFeaturedLinks ?? DEFAULT_PODCAST_FEATURED_LINKS), - seo: { - title: typeof seo.title === 'string' && seo.title.trim() ? seo.title.trim().slice(0, 120) : DEFAULT_SEO.title, - description: typeof seo.description === 'string' && seo.description.trim() ? seo.description.trim().slice(0, 240) : DEFAULT_SEO.description, - ogTitle: typeof seo.ogTitle === 'string' && seo.ogTitle.trim() ? seo.ogTitle.trim().slice(0, 120) : DEFAULT_SEO.ogTitle, - ogDescription: typeof seo.ogDescription === 'string' && seo.ogDescription.trim() ? seo.ogDescription.trim().slice(0, 240) : DEFAULT_SEO.ogDescription, - ogImage: sanitizeUrl(seo.ogImage) || DEFAULT_SEO.ogImage, - canonicalUrl: sanitizeUrl(seo.canonicalUrl) || DEFAULT_SEO.canonicalUrl, - robotsPolicy: typeof seo.robotsPolicy === 'string' && seo.robotsPolicy.trim() ? seo.robotsPolicy.trim() : DEFAULT_SEO.robotsPolicy, - sitemapPaths: Array.isArray(seo.sitemapPaths) - ? seo.sitemapPaths - .map(pathItem => normalizeSitemapPath(pathItem)) - .filter(Boolean) - : [...DEFAULT_SEO.sitemapPaths], - }, - legal: { - privacyTitle: typeof legal.privacyTitle === 'string' && legal.privacyTitle.trim() ? legal.privacyTitle.trim().slice(0, 120) : DEFAULT_LEGAL.privacyTitle, - privacyBody: Array.isArray(legal.privacyBody) && legal.privacyBody.length > 0 - ? legal.privacyBody.filter(line => typeof line === 'string').map(line => line.trim()).filter(Boolean).slice(0, 20) - : [...DEFAULT_LEGAL.privacyBody], - termsTitle: typeof legal.termsTitle === 'string' && legal.termsTitle.trim() ? legal.termsTitle.trim().slice(0, 120) : DEFAULT_LEGAL.termsTitle, - termsBody: Array.isArray(legal.termsBody) && legal.termsBody.length > 0 - ? legal.termsBody.filter(line => typeof line === 'string').map(line => line.trim()).filter(Boolean).slice(0, 20) - : [...DEFAULT_LEGAL.termsBody], - }, - } -} - -function escapeXml(value) { - return String(value) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') -} +let draftQuestions = null +let draftQuestionsWritePromise = Promise.resolve() async function loadSiteContentFile(filePath) { const raw = await readFile(filePath, 'utf8') @@ -252,61 +197,50 @@ async function refreshContentCaches() { } } -function buildAbsoluteUrl(baseUrl, maybeRelativePath) { - const safeBase = typeof baseUrl === 'string' && baseUrl.trim() ? baseUrl.trim() : DEFAULT_SEO.canonicalUrl - const root = safeBase.endsWith('/') ? safeBase.slice(0, -1) : safeBase - if (typeof maybeRelativePath !== 'string' || !maybeRelativePath.trim()) return root - const value = maybeRelativePath.trim() - if (/^https?:\/\//i.test(value)) return value - if (value.startsWith('/')) return `${root}${value}` - return `${root}/${value}` +async function loadDraftQuestionsFromDisk() { + return readFile(DRAFT_QUESTIONS_FILE, 'utf8') + .then(raw => { + const parsed = JSON.parse(raw) + if (Array.isArray(parsed)) { + draftQuestions = parsed.slice(0, MAX_QUESTIONS) + } else if (Array.isArray(parsed?.questions)) { + draftQuestions = parsed.questions.slice(0, MAX_QUESTIONS) + } else { + draftQuestions = null + } + if (typeof parsed?.updatedAt === 'string') { + publishState.draftUpdatedAt = parsed.updatedAt + } + }) + .catch(() => { + draftQuestions = null + }) } -function injectSeoIntoHtml(html, siteContent) { - const seo = siteContent?.seo ?? DEFAULT_SEO - const title = seo.title || DEFAULT_SEO.title - const description = seo.description || DEFAULT_SEO.description - const ogTitle = seo.ogTitle || title - const ogDescription = seo.ogDescription || description - const canonical = buildAbsoluteUrl(seo.canonicalUrl || DEFAULT_SEO.canonicalUrl, '/') - const ogImage = buildAbsoluteUrl(canonical, seo.ogImage || DEFAULT_SEO.ogImage) - const robots = seo.robotsPolicy || DEFAULT_SEO.robotsPolicy - - return html - .replace(/[\s\S]*?<\/title>/i, `<title>${escapeHtml(title)}`) - .replace(//i, ``) - .replace(//i, ``) - .replace(//i, ``) - .replace(//i, ``) - .replace(//i, ``) - .replace(//i, ``) - .replace(//i, ``) - .replace(//i, ``) +function ensureDraftQuestions() { + if (draftQuestions !== null) return + draftQuestions = questions.slice(0, MAX_QUESTIONS) } -function normalizeAssetBaseName(name) { - if (typeof name !== 'string') return `upload-${Date.now()}` - const cleaned = name - .toLowerCase() - .replace(/[^a-z0-9._-]+/g, '-') - .replace(/-+/g, '-') - .replace(/^-|-$/g, '') - return cleaned || `upload-${Date.now()}` +async function readUploadsMetadata() { + try { + const raw = await readFile(UPLOADS_META_FILE, 'utf8') + return JSON.parse(raw) + } catch { + return {} + } } -function inferImageExtensionFromDataUrl(dataUrl) { - if (typeof dataUrl !== 'string') return null - if (dataUrl.startsWith('data:image/png;base64,')) return '.png' - 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' - return null +async function writeUploadsMetadata(metadata) { + await mkdir(DATA_DIR, { recursive: true }) + await writeFile(UPLOADS_META_FILE, JSON.stringify(metadata, null, 2), 'utf8') } 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 => { const info = await stat(path.join(UPLOADS_DIR, filename)) @@ -315,6 +249,7 @@ async function listUploadedAssets() { url: `/uploads/${filename}`, sizeBytes: info.size, updatedAt: info.mtime.toISOString(), + tags: Array.isArray(metadata[filename]) ? metadata[filename].filter(tag => typeof tag === 'string') : [], } })) @@ -362,13 +297,11 @@ let hitStatsWritePromise = Promise.resolve() const VISITOR_COOKIE = 'vbn_vid' const CONSENT_COOKIE = 'vbn_analytics_consent' -const ADMIN_SESSION_COOKIE = 'vbn_admin_session' const MAX_RECENT_VISITS = 1000 const VISITOR_RETENTION_DAYS_DEFAULT = 180 const BACKUP_RETENTION_DAYS = 30 const BACKUP_INTERVAL_MS = 24 * 60 * 60 * 1000 const ADMIN_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000 -const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD ?? 'change-me-admin-password' const EMPTY_VISITOR_STATS = { totalVisits: 0, @@ -397,47 +330,6 @@ let lastHitStatsWrite = { ok: true, at: null, error: null } let lastBackupStatus = { ok: true, at: null, error: null, file: null } let lastCachePurgeStatus = { ok: true, at: null, error: null } let lastDeployHookStatus = { ok: true, at: null, error: null } -const adminSessions = new Map() - -function sha256(value) { - return createHash('sha256').update(value).digest('hex') -} - -function isAdminPasswordConfigured() { - return ADMIN_PASSWORD !== 'change-me-admin-password' -} - -function isValidAdminSession(req) { - const cookies = parseCookies(req.headers.cookie) - const sessionToken = cookies[ADMIN_SESSION_COOKIE] - if (!sessionToken) return false - - const expiresAt = adminSessions.get(sessionToken) - if (!expiresAt) return false - if (expiresAt <= Date.now()) { - adminSessions.delete(sessionToken) - return false - } - - adminSessions.set(sessionToken, Date.now() + ADMIN_SESSION_TTL_MS) - return true -} - -function setAdminSessionCookie(res, token) { - res.append('Set-Cookie', `${ADMIN_SESSION_COOKIE}=${encodeURIComponent(token)}; Max-Age=${Math.floor(ADMIN_SESSION_TTL_MS / 1000)}; Path=/; HttpOnly; SameSite=Lax`) -} - -function clearAdminSessionCookie(res) { - res.append('Set-Cookie', `${ADMIN_SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax`) -} - -function requireAdminAuth(req, res, next) { - if (!isValidAdminSession(req)) { - res.status(401).json({ message: 'Unauthorized' }) - return - } - next() -} function normalizeIp(rawIp) { if (!rawIp) return 'unknown' @@ -459,45 +351,6 @@ function normalizeIp(rawIp) { return ip || 'unknown' } -function getClientIp(req) { - const forwarded = req.headers['x-forwarded-for'] - if (forwarded) { - return normalizeIp(forwarded) - } - return normalizeIp(req.ip) -} - -function parseCookies(cookieHeader) { - if (!cookieHeader) return {} - - return cookieHeader - .split(';') - .map(v => v.trim()) - .filter(Boolean) - .reduce((acc, part) => { - const idx = part.indexOf('=') - if (idx === -1) return acc - const key = part.slice(0, idx).trim() - const value = part.slice(idx + 1).trim() - try { - acc[key] = decodeURIComponent(value) - } catch { - acc[key] = value - } - return acc - }, {}) -} - -function hasVisitorConsent(req) { - const cookies = parseCookies(req.headers.cookie) - return cookies[CONSENT_COOKIE] === 'yes' -} - -function setConsentCookie(res, consent) { - const value = consent ? 'yes' : 'no' - res.append('Set-Cookie', `${CONSENT_COOKIE}=${value}; Max-Age=31536000; Path=/; SameSite=Lax`) -} - function isPrivateOrLocalIp(ip) { return ( ip === '127.0.0.1' @@ -1214,6 +1067,17 @@ app.post('/api/admin-content/publish', requireAdminAuth, async (_req, res) => { cachedSiteContent = source.siteContent publishState.publishedAt = publishedAt + + if (draftQuestions !== null) { + questions = draftQuestions.slice(0, MAX_QUESTIONS) + await mkdir(DATA_DIR, { recursive: true }) + await writeFile( + QUESTIONS_FILE, + JSON.stringify({ questions, updatedAt: publishedAt }, null, 2), + 'utf8', + ) + } + await createBackupSnapshot('post-publish') res.json({ ok: true, publishedAt }) @@ -1238,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 } @@ -1254,6 +1118,9 @@ app.post('/api/admin-assets', requireAdminAuth, async (req, res) => { await mkdir(UPLOADS_DIR, { recursive: true }) await writeFile(path.join(UPLOADS_DIR, finalName), buffer) + const metadata = await readUploadsMetadata() + metadata[finalName] = [] + await writeUploadsMetadata(metadata) res.json({ ok: true, asset: { filename: finalName, url: `/uploads/${finalName}` } }) } catch { @@ -1261,6 +1128,31 @@ app.post('/api/admin-assets', requireAdminAuth, async (req, res) => { } }) +app.patch('/api/admin-assets/:filename', requireAdminAuth, async (req, res) => { + try { + const { filename } = req.params + if (typeof filename !== 'string' || filename.includes('/') || filename.includes('..')) { + res.status(400).json({ message: 'Invalid filename.' }) + return + } + + const tags = Array.isArray(req.body?.tags) + ? req.body.tags.filter(tag => typeof tag === 'string').map(tag => tag.trim()).filter(Boolean) + : [] + + const filePath = path.join(UPLOADS_DIR, filename) + await stat(filePath) + + const metadata = await readUploadsMetadata() + metadata[filename] = tags + await writeUploadsMetadata(metadata) + + res.json({ ok: true, tags }) + } catch { + res.status(404).json({ message: 'Asset not found.' }) + } +}) + app.delete('/api/admin-assets/:filename', requireAdminAuth, async (req, res) => { try { const { filename } = req.params @@ -1270,6 +1162,9 @@ app.delete('/api/admin-assets/:filename', requireAdminAuth, async (req, res) => } await unlink(path.join(UPLOADS_DIR, filename)) + const metadata = await readUploadsMetadata() + delete metadata[filename] + await writeUploadsMetadata(metadata) res.json({ ok: true }) } catch { res.status(404).json({ message: 'Asset not found.' }) @@ -1310,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 () => { @@ -1415,6 +1223,23 @@ function queueQuestionsWrite() { }) } +function queueDraftQuestionsWrite() { + if (draftQuestions === null) return + draftQuestionsWritePromise = draftQuestionsWritePromise + .then(async () => { + await mkdir(DATA_DIR, { recursive: true }) + await writeFile( + DRAFT_QUESTIONS_FILE, + JSON.stringify({ questions: draftQuestions, updatedAt: new Date().toISOString() }, null, 2), + 'utf8', + ) + publishState.draftUpdatedAt = new Date().toISOString() + }) + .catch(err => { + console.error('[draft-questions] failed to write draft questions:', err) + }) +} + function loadQuestionsFromDisk() { return readFile(QUESTIONS_FILE, 'utf8') .then(raw => { @@ -1431,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()) { @@ -1446,23 +1284,119 @@ app.post('/api/admin-auth/login', (req, res) => { return } - if (sha256(password) !== sha256(ADMIN_PASSWORD)) { + if (!isAdminPasswordValid(password)) { res.status(401).json({ message: 'Invalid password.' }) return } - const sessionToken = randomUUID() - adminSessions.set(sessionToken, Date.now() + ADMIN_SESSION_TTL_MS) + 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] - if (sessionToken) { - adminSessions.delete(sessionToken) - } + deleteAdminSession(sessionToken) clearAdminSessionCookie(res) res.json({ ok: true }) }) @@ -1695,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() @@ -1720,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) { @@ -1728,6 +1672,107 @@ app.post('/api/study-downloads/titus', studyDownloadRateLimit, async (req, res) } }) +app.post('/api/resource-download', studyDownloadRateLimit, async (req, res) => { + try { + const { resourceId, firstName, lastName, email, subscribe, _honey } = req.body ?? {} + + if (_honey) { + res.json({ ok: true }) + return + } + + if (!resourceId || typeof resourceId !== 'string') { + res.status(400).json({ message: 'Resource ID is required.' }) + return + } + + const published = await loadSiteContentFile(DATA_FILE) + 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.' }) + return + } + + if (!firstName || typeof firstName !== 'string' || firstName.trim().length < 1 || firstName.trim().length > 100) { + res.status(400).json({ message: 'First name is required.' }) + return + } + + if (!lastName || typeof lastName !== 'string' || lastName.trim().length < 1 || lastName.trim().length > 100) { + res.status(400).json({ message: 'Last name is required.' }) + return + } + + if (!email || typeof email !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) { + res.status(400).json({ message: 'A valid email address is required.' }) + return + } + + const trimmedFirstName = firstName.trim() + const trimmedLastName = lastName.trim() + const trimmedName = `${trimmedFirstName} ${trimmedLastName}`.trim() + const trimmedEmail = email.trim() + const wantsSubscribe = subscribe !== false + + addContactSubmission({ + name: trimmedName, + email: trimmedEmail, + message: `Requested resource download: ${resource.label ?? resource.url}`, + messageType: 'general', + subscribe: wantsSubscribe, + }) + + if (wantsSubscribe) { + await syncContactToResend(trimmedName, trimmedEmail) + } + + res.json({ ok: true, downloadUrl: resource.url.trim() }) + } catch (err) { + console.error('[resource-download] request error:', err) + res.status(500).json({ message: 'Failed to process your request. Please try again.' }) + } +}) + app.get('/api/study-downloads/titus/file', async (req, res) => { const token = typeof req.query?.token === 'string' ? req.query.token : '' if (!token || !consumeTitusDownloadToken(token)) { @@ -1808,6 +1853,11 @@ app.post('/api/contact', contactRateLimit, async (req, res) => { } questions.unshift(question) questions = questions.slice(0, MAX_QUESTIONS) + if (draftQuestions !== null) { + draftQuestions.unshift(question) + draftQuestions = draftQuestions.slice(0, MAX_QUESTIONS) + queueDraftQuestionsWrite() + } queueQuestionsWrite() } const resend = new Resend(process.env.RESEND_API_KEY) @@ -1874,7 +1924,7 @@ app.post('/api/contact', contactRateLimit, async (req, res) => { // Get all questions (for admin) app.get('/api/admin-questions', requireAdminAuth, (_req, res) => { - res.json({ questions }) + res.json({ questions: draftQuestions ?? questions }) }) // Get only approved public questions (for homepage) @@ -1893,7 +1943,8 @@ app.post('/api/admin-questions/:id/answer', requireAdminAuth, (req, res) => { return } - const question = questions.find(q => q.id === id) + ensureDraftQuestions() + const question = draftQuestions.find(q => q.id === id) if (!question) { res.status(404).json({ message: 'Question not found.' }) return @@ -1901,7 +1952,7 @@ app.post('/api/admin-questions/:id/answer', requireAdminAuth, (req, res) => { question.answer = answer.trim() question.answeredAt = new Date().toISOString() - queueQuestionsWrite() + queueDraftQuestionsWrite() res.json({ ok: true, question }) }) @@ -1911,7 +1962,8 @@ app.post('/api/admin-questions/:id/approve', requireAdminAuth, (req, res) => { const { id } = req.params const { approved } = req.body ?? {} - const question = questions.find(q => q.id === id) + ensureDraftQuestions() + const question = draftQuestions.find(q => q.id === id) if (!question) { res.status(404).json({ message: 'Question not found.' }) return @@ -1919,7 +1971,7 @@ app.post('/api/admin-questions/:id/approve', requireAdminAuth, (req, res) => { question.isApproved = approved === true question.approvedAt = approved === true ? new Date().toISOString() : null - queueQuestionsWrite() + queueDraftQuestionsWrite() res.json({ ok: true, question }) }) @@ -1927,15 +1979,16 @@ app.post('/api/admin-questions/:id/approve', requireAdminAuth, (req, res) => { // Delete a question (admin) app.delete('/api/admin-questions/:id', requireAdminAuth, (req, res) => { const { id } = req.params - const index = questions.findIndex(q => q.id === id) + ensureDraftQuestions() + const index = draftQuestions.findIndex(q => q.id === id) if (index === -1) { res.status(404).json({ message: 'Question not found.' }) return } - questions.splice(index, 1) - queueQuestionsWrite() + draftQuestions.splice(index, 1) + queueDraftQuestionsWrite() res.json({ ok: true }) }) @@ -2091,7 +2144,7 @@ Promise.all([ loadVisitorStatsFromDisk(), loadContactSubmissionsFromDisk(), loadQuestionsFromDisk(), - loadChatbotFromDisk(), + loadDraftQuestionsFromDisk(), refreshContentCaches(), ]) .catch(err => { diff --git a/server/auth.js b/server/auth.js new file mode 100644 index 0000000..bcd7f0e --- /dev/null +++ b/server/auth.js @@ -0,0 +1,267 @@ +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 +const adminSessions = new Map() + +function cookieFlags() { + return process.env.NODE_ENV === 'production' ? '; Secure' : '' +} + +export function sha256(value) { + return createHash('sha256').update(String(value)).digest('hex') +} + +export function isAdminPasswordConfigured() { + return Boolean(ADMIN_PASSWORD) +} + +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.') + } +} + +export function isAdminPasswordValid(password) { + if (!isAdminPasswordConfigured()) return false + 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) + return token +} + +export function deleteAdminSession(token) { + if (token) { + adminSessions.delete(token) + } +} + +export function isValidAdminSession(req) { + if (!isAdminPasswordConfigured()) return false + + const cookies = parseCookies(req.headers.cookie) + const sessionToken = cookies[ADMIN_SESSION_COOKIE] + if (!sessionToken) return false + + const expiresAt = adminSessions.get(sessionToken) + if (!expiresAt || expiresAt <= Date.now()) { + adminSessions.delete(sessionToken) + return false + } + + adminSessions.set(sessionToken, Date.now() + ADMIN_SESSION_TTL_MS) + return true +} + +export function setAdminSessionCookie(res, token) { + res.append( + 'Set-Cookie', + `${ADMIN_SESSION_COOKIE}=${encodeURIComponent(token)}; Max-Age=${Math.floor(ADMIN_SESSION_TTL_MS / 1000)}; Path=/; HttpOnly; SameSite=Lax${cookieFlags()}`, + ) +} + +export function clearAdminSessionCookie(res) { + res.append( + 'Set-Cookie', + `${ADMIN_SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax${cookieFlags()}`, + ) +} + +export function requireAdminAuth(req, res, next) { + if (!isValidAdminSession(req)) { + res.status(401).json({ message: 'Unauthorized' }) + return + } + next() +} diff --git a/server/helpers.js b/server/helpers.js new file mode 100644 index 0000000..6a3aef3 --- /dev/null +++ b/server/helpers.js @@ -0,0 +1,417 @@ +import { randomUUID } from 'node:crypto' + +export const DEFAULT_REDIRECT_RULES = [ + { + id: 'spotify', + path: '/spotify', + target: 'https://open.spotify.com/show/0Gq1TzoJOdReSZ1gYQi8Xl', + statusCode: 301, + }, + { + id: 'apple', + path: '/apple', + target: 'https://podcasts.apple.com/search?term=Verse+by+Verse+with+Nate', + statusCode: 301, + }, + { + id: 'amazon', + path: '/amazon', + target: 'https://music.amazon.com/podcasts/202322bf-db86-4e7d-9a6b-4db7cbccbccf/verse-by-verse-with-nate', + statusCode: 301, + }, +] + +export const DEFAULT_SEO = { + title: 'Verse by Verse with Nate', + description: 'Verse by Verse with Nate explores Scripture one verse at a time with practical Bible teaching.', + ogTitle: 'Verse by Verse with Nate', + ogDescription: 'A Journey Through Scripture - verse by verse, nugget by nugget.', + ogImage: '/images/podcast-art.jpeg', + canonicalUrl: 'https://versebyversewithnate.us/', + robotsPolicy: 'index,follow', + sitemapPaths: ['/', '/start-here', '/questions', '/privacy', '/terms'], +} + +export const DEFAULT_LEGAL = { + privacyTitle: 'Privacy Policy', + privacyBody: [ + 'We respect your privacy and collect limited data to operate and improve this site.', + 'If you consent to analytics cookies, we may store masked IP-based location signals and returning visitor activity.', + 'Contact form details are used only to respond to your message and ministry communication requests.', + ], + termsTitle: 'Terms', + termsBody: [ + 'Content on this site is for informational and ministry purposes.', + 'External links are provided for convenience and are subject to third-party policies.', + 'By using this site, you agree to lawful use and respectful communication.', + ], +} + +export const DEFAULT_PODCAST_FEATURED_LINKS = [] +export const DEFAULT_PUBLISH_STATE = { + draftUpdatedAt: null, + publishedAt: null, +} + +export function escapeHtml(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +export function splitName(fullName) { + const parts = String(fullName).trim().split(/\s+/).filter(Boolean) + return { + firstName: parts[0] ?? '', + lastName: parts.slice(1).join(' '), + } +} + +export function sanitizeUrl(value) { + if (typeof value !== 'string') return '' + const trimmed = value.trim() + if (!trimmed) return '' + if (trimmed.startsWith('/')) return trimmed + if (/^https?:\/\//i.test(trimmed)) return trimmed + return '' +} + +export function normalizeRedirectPath(value) { + if (typeof value !== 'string') return '' + const trimmed = value.trim() + if (!trimmed) return '' + const withSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}` + const normalized = withSlash.replace(/\/+/g, '/') + if (normalized === '/') return '' + if (normalized.startsWith('/api/') || normalized.startsWith('/admin')) return '' + return normalized +} + +export function normalizeSitemapPath(value) { + if (typeof value !== 'string') return '' + const trimmed = value.trim() + if (!trimmed) return '' + if (trimmed === '/') return '/' + return normalizeRedirectPath(trimmed) +} + +export function sanitizeRedirectRules(value) { + const source = Array.isArray(value) ? value : [] + const seen = new Set() + const out = [] + + for (const item of source) { + const pathValue = normalizeRedirectPath(item?.path) + const target = sanitizeUrl(item?.target) + const statusCode = Number(item?.statusCode) === 302 ? 302 : 301 + if (!pathValue || !target) continue + if (seen.has(pathValue)) continue + seen.add(pathValue) + out.push({ + id: typeof item?.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(), + path: pathValue, + target, + statusCode, + }) + } + + return out.length > 0 ? out : DEFAULT_REDIRECT_RULES +} + +export function sanitizeFeaturedLinks(value) { + const source = Array.isArray(value) ? value : [] + return source + .filter(item => item && typeof item === 'object') + .map(item => { + const discussionQuestions = Array.isArray(item.discussionQuestions) + ? item.discussionQuestions + .filter(question => typeof question === 'string') + .map(question => question.trim()) + .filter(Boolean) + .slice(0, 30) + : [] + + return { + id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(), + title: typeof item.title === 'string' ? item.title.trim().slice(0, 140) : '', + episodeNumber: typeof item.episodeNumber === 'string' ? item.episodeNumber.trim().slice(0, 20) : '', + summary: typeof item.summary === 'string' ? item.summary.trim().slice(0, 600) : '', + url: sanitizeUrl(item.url), + embedUrl: sanitizeUrl(item.embedUrl), + showNotes: typeof item.showNotes === 'string' ? item.showNotes.trim().slice(0, 10000) : '', + discussionQuestions, + } + }) + .filter( + item => + item.title || + item.summary || + item.url || + item.embedUrl || + item.showNotes || + item.discussionQuestions.length > 0, + ) +} + +function sanitizeCustomLinks(value) { + const source = Array.isArray(value) ? value : [] + return source + .filter(item => item && typeof item === 'object') + .map(item => { + const placement = + item?.placement === 'platforms' || item?.placement === 'footer' || item?.placement === 'resources' + ? item.placement + : 'footer' + + return { + id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(), + 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, + } + }) + .filter(item => item.label && item.url) +} + +function sanitizeCustomBlocks(value) { + const source = Array.isArray(value) ? value : [] + return source + .filter(item => item && typeof item === 'object') + .map(item => ({ + id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(), + heading: typeof item.heading === 'string' ? item.heading.trim().slice(0, 140) : '', + body: typeof item.body === 'string' ? item.body.trim().slice(0, 4000) : '', + })) + .filter(item => item.heading || item.body) +} + +function sanitizeArchivedSeriesResourceLinks(value) { + const source = Array.isArray(value) ? value : [] + return source + .filter(item => item && typeof item === 'object') + .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) +} + +function sanitizeArchivedSeriesNotes(value) { + const source = Array.isArray(value) ? value : [] + return source + .filter(item => item && typeof item === 'object') + .map(item => ({ + id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(), + heading: typeof item.heading === 'string' ? item.heading.trim().slice(0, 140) : '', + body: typeof item.body === 'string' ? item.body.trim().slice(0, 4000) : '', + })) + .filter(item => item.heading || item.body) +} + +function sanitizeArchivedSeries(value) { + const source = Array.isArray(value) ? value : [] + return source + .filter(item => item && typeof item === 'object') + .map(item => ({ + id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(), + label: typeof item.label === 'string' ? item.label.trim().slice(0, 140) : '', + title: typeof item.title === 'string' ? item.title.trim().slice(0, 200) : '', + description: typeof item.description === 'string' ? item.description.trim().slice(0, 1000) : '', + imageUrl: sanitizeUrl(item.imageUrl), + listenUrl: sanitizeUrl(item.listenUrl), + studyGuideTitle: typeof item.studyGuideTitle === 'string' ? item.studyGuideTitle.trim().slice(0, 140) : '', + studyGuideDescription: typeof item.studyGuideDescription === 'string' ? item.studyGuideDescription.trim().slice(0, 4000) : '', + studyGuideUrl: sanitizeUrl(item.studyGuideUrl), + resourceLinks: sanitizeArchivedSeriesResourceLinks(item.resourceLinks), + notes: sanitizeArchivedSeriesNotes(item.notes), + })) + .filter( + item => + item.title || + item.description || + item.resourceLinks.length > 0 || + item.notes.length > 0, + ) +} + +export function sanitizeSiteContent(siteContent) { + if (!siteContent || typeof siteContent !== 'object' || Array.isArray(siteContent)) return {} + + const seo = siteContent.seo && typeof siteContent.seo === 'object' ? siteContent.seo : {} + const legal = siteContent.legal && typeof siteContent.legal === 'object' ? siteContent.legal : {} + + return { + ...siteContent, + customLinks: sanitizeCustomLinks(siteContent.customLinks), + customBlocks: sanitizeCustomBlocks(siteContent.customBlocks), + archivedSeries: sanitizeArchivedSeries(siteContent.archivedSeries), + redirects: sanitizeRedirectRules(siteContent.redirects), + podcastFeaturedLinks: sanitizeFeaturedLinks(siteContent.podcastFeaturedLinks ?? DEFAULT_PODCAST_FEATURED_LINKS), + seo: { + title: typeof seo.title === 'string' && seo.title.trim() ? seo.title.trim().slice(0, 120) : DEFAULT_SEO.title, + description: typeof seo.description === 'string' && seo.description.trim() ? seo.description.trim().slice(0, 240) : DEFAULT_SEO.description, + ogTitle: typeof seo.ogTitle === 'string' && seo.ogTitle.trim() ? seo.ogTitle.trim().slice(0, 120) : DEFAULT_SEO.ogTitle, + ogDescription: typeof seo.ogDescription === 'string' && seo.ogDescription.trim() ? seo.ogDescription.trim().slice(0, 240) : DEFAULT_SEO.ogDescription, + ogImage: sanitizeUrl(seo.ogImage) || DEFAULT_SEO.ogImage, + canonicalUrl: sanitizeUrl(seo.canonicalUrl) || DEFAULT_SEO.canonicalUrl, + robotsPolicy: typeof seo.robotsPolicy === 'string' && seo.robotsPolicy.trim() ? seo.robotsPolicy.trim() : DEFAULT_SEO.robotsPolicy, + sitemapPaths: Array.isArray(seo.sitemapPaths) + ? seo.sitemapPaths.map(pathItem => normalizeSitemapPath(pathItem)).filter(Boolean) + : [...DEFAULT_SEO.sitemapPaths], + }, + legal: { + privacyTitle: typeof legal.privacyTitle === 'string' && legal.privacyTitle.trim() ? legal.privacyTitle.trim().slice(0, 120) : DEFAULT_LEGAL.privacyTitle, + privacyBody: Array.isArray(legal.privacyBody) && legal.privacyBody.length > 0 + ? legal.privacyBody.filter(line => typeof line === 'string').map(line => line.trim()).filter(Boolean).slice(0, 20) + : [...DEFAULT_LEGAL.privacyBody], + termsTitle: typeof legal.termsTitle === 'string' && legal.termsTitle.trim() ? legal.termsTitle.trim().slice(0, 120) : DEFAULT_LEGAL.termsTitle, + termsBody: Array.isArray(legal.termsBody) && legal.termsBody.length > 0 + ? legal.termsBody.filter(line => typeof line === 'string').map(line => line.trim()).filter(Boolean).slice(0, 20) + : [...DEFAULT_LEGAL.termsBody], + }, + } +} + +export function escapeXml(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +export function buildAbsoluteUrl(baseUrl, maybeRelativePath) { + const safeBase = typeof baseUrl === 'string' && baseUrl.trim() ? baseUrl.trim() : DEFAULT_SEO.canonicalUrl + const root = safeBase.endsWith('/') ? safeBase.slice(0, -1) : safeBase + if (typeof maybeRelativePath !== 'string' || !maybeRelativePath.trim()) return root + const value = maybeRelativePath.trim() + if (/^https?:\/\//i.test(value)) return value + if (value.startsWith('/')) return `${root}${value}` + return `${root}/${value}` +} + +export function injectSeoIntoHtml(html, siteContent) { + const seo = siteContent?.seo ?? DEFAULT_SEO + const title = seo.title || DEFAULT_SEO.title + const description = seo.description || DEFAULT_SEO.description + const ogTitle = seo.ogTitle || title + const ogDescription = seo.ogDescription || description + const canonical = buildAbsoluteUrl(seo.canonicalUrl || DEFAULT_SEO.canonicalUrl, '/') + const ogImage = buildAbsoluteUrl(canonical, seo.ogImage || DEFAULT_SEO.ogImage) + const robots = seo.robotsPolicy || DEFAULT_SEO.robotsPolicy + + return html + .replace(/[\s\S]*?<\/title>/i, `<title>${escapeHtml(title)}`) + .replace(//i, ``) + .replace(//i, ``) + .replace(//i, ``) + .replace(//i, ``) + .replace(//i, ``) + .replace(//i, ``) + .replace(//i, ``) + .replace(//i, ``) +} + +export function normalizeAssetBaseName(name) { + if (typeof name !== 'string') return `upload-${Date.now()}` + const cleaned = name + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + return cleaned || `upload-${Date.now()}` +} + +export function inferImageExtensionFromDataUrl(dataUrl) { + if (typeof dataUrl !== 'string') return null + if (dataUrl.startsWith('data:image/png;base64,')) return '.png' + 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 +} + +export function normalizeIp(rawIp) { + if (!rawIp) return 'unknown' + + let ip = String(rawIp).trim() + + if (ip.includes(',')) { + ip = ip.split(',')[0].trim() + } + + if (ip.startsWith('::ffff:')) { + ip = ip.slice(7) + } + + if (ip === '::1') { + ip = '127.0.0.1' + } + + return ip || 'unknown' +} + +export function getClientIp(req) { + const forwarded = req.headers['x-forwarded-for'] + if (forwarded) { + return normalizeIp(forwarded) + } + return normalizeIp(req.ip) +} + +export function parseCookies(cookieHeader) { + if (!cookieHeader) return {} + + return cookieHeader + .split(';') + .map(v => v.trim()) + .filter(Boolean) + .reduce((acc, part) => { + const idx = part.indexOf('=') + if (idx === -1) return acc + const key = part.slice(0, idx).trim() + const value = part.slice(idx + 1).trim() + try { + acc[key] = decodeURIComponent(value) + } catch { + acc[key] = value + } + return acc + }, {}) +} + +export function hasVisitorConsent(req) { + const cookies = parseCookies(req.headers.cookie) + return cookies['vbn_analytics_consent'] === 'yes' +} + +export function setConsentCookie(res, consent) { + const value = consent ? 'yes' : 'no' + const secureFlag = process.env.NODE_ENV === 'production' ? '; Secure' : '' + res.append('Set-Cookie', `vbn_analytics_consent=${value}; Max-Age=31536000; Path=/; SameSite=Lax${secureFlag}`) +} + +export function isPrivateOrLocalIp(ip) { + return ( + ip === '127.0.0.1' || + ip === 'localhost' || + ip.startsWith('10.') || + ip.startsWith('192.168.') || + /^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(ip) || + ip.startsWith('fc') || + ip.startsWith('fd') || + ip.startsWith('fe80:') || + ip === 'unknown' + ) +} diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index 762b033..a9b79bb 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -1,8 +1,8 @@ import { useEffect, useState } from 'react' import type { ChangeEvent } from 'react' import { Link } from 'react-router-dom' -import type { SiteContent, CustomLink, CustomBlock, ArchivedSeries, ArchivedSeriesResourceLink, ArchivedSeriesNote, RedirectRule, PodcastFeaturedLink, SeoSettings, LegalSettings } from './App' -import { DEFAULTS } from './App' +import type { SiteContent, CustomLink, CustomBlock, ArchivedSeries, ArchivedSeriesResourceLink, ArchivedSeriesNote, RedirectRule, PodcastFeaturedLink, SeoSettings, LegalSettings } from './content' +import { DEFAULTS } from './content' interface Props { content: SiteContent @@ -66,6 +66,7 @@ interface AdminAsset { url: string sizeBytes: number updatedAt: string + tags?: string[] } interface PublishState { @@ -94,7 +95,7 @@ interface Question { } type StringField = Exclude -type MainContentSection = 'hero' | 'start-here' | 'about' | 'series' | 'share' +type MainContentSection = 'hero' | 'start-here' | 'about' | 'contact' | 'series' | 'share' const MAIN_CONTENT_SECTIONS: Array<{ id: MainContentSection; title: string; description: string }> = [ { @@ -110,7 +111,12 @@ const MAIN_CONTENT_SECTIONS: Array<{ id: MainContentSection; title: string; desc { id: 'about', title: 'About Section', - description: 'Manage the main show description and Nate bio content.', + description: 'Manage the main show description, Nate bio, and about images.', + }, + { + id: 'contact', + title: 'Contact Section', + description: 'Manage the contact page profile image and contact copy.', }, { id: 'series', @@ -178,6 +184,9 @@ const FIELDS: Array<{ key: StringField; label: string; multiline?: boolean; sect { key: 'aboutShowP1', label: 'About Show — Paragraph 1', multiline: true, section: 'about' }, { key: 'aboutShowP2', label: 'About Show — Paragraph 2', multiline: true, section: 'about' }, { key: 'aboutNate', label: 'About Nate', multiline: true, section: 'about' }, + { key: 'aboutPhotoUrl', label: 'About Section Photo URL', section: 'about' }, + { key: 'aboutVerseArtUrl', label: 'About Verse Art Image URL', section: 'about' }, + { key: 'contactPhotoUrl', label: 'Contact Profile Photo URL', section: 'contact' }, { key: 'seriesLabel', label: 'Series Label (e.g. "Now Playing")', section: 'series' }, { key: 'seriesTitle', label: 'Series Title', section: 'series' }, { key: 'seriesDescription', label: 'Series Description', multiline: true, section: 'series' }, @@ -195,8 +204,8 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { const [lastSavedSnapshot, setLastSavedSnapshot] = useState(() => JSON.stringify(content)) const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle') const [errorMsg, setErrorMsg] = useState('') - const [adminTab, setAdminTab] = useState<'content' | 'episodes' | 'settings' | 'publish' | 'analytics' | 'questions' | 'brand'>('content') - const [contentTab, setContentTab] = useState<'main' | 'custom'>('main') + const [adminTab, setAdminTab] = useState<'content' | 'episodes' | 'settings' | 'analytics' | 'questions' | 'brand' | 'assets'>('content') + const [contentTab, setContentTab] = useState<'main' | 'resources' | 'custom'>('main') const [stats, setStats] = useState(null) const [statsStatus, setStatsStatus] = useState<'loading' | 'ready' | 'error'>('loading') const [maintenanceMsg, setMaintenanceMsg] = useState('') @@ -204,8 +213,17 @@ 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>({}) const [opsStatus, setOpsStatus] = useState(null) const [assetUploadPending, setAssetUploadPending] = useState(false) @@ -233,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')))) @@ -274,13 +299,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { }) .catch(() => {}) - fetch('/api/admin-assets') - .then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load assets')))) - .then(data => { - const list = Array.isArray((data as { assets?: unknown }).assets) ? (data as { assets: AdminAsset[] }).assets : [] - setAssets(list) - }) - .catch(() => {}) + void reloadAssets() fetch('/api/admin-ops/status') .then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load operations status')))) @@ -346,7 +365,12 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { const r = await fetch('/api/admin-assets') if (!r.ok) throw new Error('Could not refresh assets') const data = await r.json() as { assets?: AdminAsset[] } - setAssets(Array.isArray(data.assets) ? data.assets : []) + const incomingAssets = Array.isArray(data.assets) ? data.assets : [] + setAssets(incomingAssets) + setAssetTagEdits(incomingAssets.reduce>((memo, asset) => { + memo[asset.filename] = (asset.tags ?? []).join(', ') + return memo + }, {})) } async function reloadOpsStatus() { @@ -371,6 +395,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { } const data = await res.json() as { updatedAt?: string } setPublishState(prev => ({ ...prev, draftUpdatedAt: data.updatedAt ?? new Date().toISOString() })) + setLastSavedSnapshot(JSON.stringify(form)) setStatus('saved') setTimeout(() => setStatus('idle'), 3500) } catch (err) { @@ -448,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, @@ -564,6 +636,30 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { } } + function handleAssetTagChange(filename: string, value: string) { + setAssetTagEdits(prev => ({ ...prev, [filename]: value })) + } + + async function handleSaveAssetTags(filename: string) { + const tagsText = assetTagEdits[filename] ?? '' + const tags = tagsText.split(',').map(tag => tag.trim()).filter(Boolean) + try { + const res = await fetch(`/api/admin-assets/${encodeURIComponent(filename)}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tags }), + }) + if (!res.ok) { + const data = await res.json().catch(() => ({})) + throw new Error((data as { message?: string }).message ?? 'Save failed') + } + await reloadAssets() + setOpsMsg('Asset tags saved.') + } catch (err) { + setOpsMsg(err instanceof Error ? err.message : 'Save failed.') + } + } + async function handlePurgeCache() { try { const res = await fetch('/api/admin-ops/purge-cache', { method: 'POST' }) @@ -701,18 +797,72 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { setForm(f => ({ ...f, [key]: value })) } + function renderImageAssetSelector(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 image (${currentValue})`, value: currentValue }) + } + + if (assetOptions.length === 0) return null + + return ( +
+ + +
+ ) + } + + 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?') } - function handleAdminTabChange(nextTab: 'content' | 'episodes' | 'settings' | 'publish' | 'analytics' | 'questions' | 'brand') { + function handleAdminTabChange(nextTab: 'content' | 'episodes' | 'settings' | 'analytics' | 'questions' | 'brand' | 'assets') { if (nextTab === adminTab) return if (!confirmLeaveUnsavedChanges()) return setAdminTab(nextTab) } - function handleContentTabChange(nextTab: 'main' | 'custom') { + function handleContentTabChange(nextTab: 'main' | 'resources' | 'custom') { if (nextTab === contentTab) return if (!confirmLeaveUnsavedChanges()) return setContentTab(nextTab) @@ -723,12 +873,22 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { ...f, customLinks: [ ...(f.customLinks ?? []), - { id: Date.now().toString(36), label: '', url: '', placement: 'platforms' as const }, + { id: Date.now().toString(36), label: '', url: '', imageUrl: '', placement: 'platforms' as const }, ], })) } - function updateLink(id: string, field: keyof CustomLink, value: string) { + function addResource() { + setForm(f => ({ + ...f, + customLinks: [ + ...(f.customLinks ?? []), + { id: Date.now().toString(36), label: '', url: '', imageUrl: '', description: '', placement: 'resources' as const }, + ], + })) + } + + function updateLink(id: string, field: keyof CustomLink, value: string | string[]) { setForm(f => ({ ...f, customLinks: (f.customLinks ?? []).map(l => l.id === id ? { ...l, [field]: value } : l), @@ -739,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, @@ -801,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), @@ -854,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, }, ], @@ -880,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, })) @@ -985,29 +1156,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { setContentTab('custom') } - async function handleSave() { - setStatus('saving') - setErrorMsg('') - try { - const res = await fetch('/api/admin-content', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ siteContent: form }), - }) - if (!res.ok) { - const data = await res.json().catch(() => ({})) - throw new Error((data as { message?: string }).message ?? 'Save failed') - } - onSave(form) - setLastSavedSnapshot(JSON.stringify(form)) - setStatus('saved') - setTimeout(() => setStatus('idle'), 3500) - } catch (err) { - setErrorMsg(err instanceof Error ? err.message : 'Unknown error') - setStatus('error') - } - } - function handleReset() { if (confirm('Reset all fields to defaults?')) { setForm(DEFAULTS) @@ -1068,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 (
@@ -1147,11 +1301,11 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
+
+
+ {isDirty && Unsaved changes} + Draft saved: {formatDate(publishState.draftUpdatedAt)} + Last published: {formatDate(publishState.publishedAt)} +
+
+ + + +
+
{adminTab === 'brand' && (
@@ -1225,64 +1398,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
)} - {adminTab === 'publish' && ( -
-
-

Publishing Workflow

-

Save drafts, preview before release, and publish when ready.

-
- -
-
-

Draft Updated

-

{formatDate(publishState.draftUpdatedAt)}

-
-
-

Last Published

-

{formatDate(publishState.publishedAt)}

-
-
- -
- - - -
- -
-

Deployment and Cache

-

Trigger deployment hooks and cache purge hooks.

-
-
-
-

Build Commit

-

{opsStatus?.buildCommit ?? 'Not available'}

-
-
-

Build Number

-

{opsStatus?.buildNumber ?? 'Not available'}

-
-
-

Deployed At

-

{formatDate(opsStatus?.deployedAt ?? null)}

-
-
-

Cache Purge

-

{opsStatus?.cachePurge.ok ? 'Healthy' : 'Needs setup'}

-

{formatDate(opsStatus?.cachePurge.at ?? null)}

-
-
-
- - - -
- - {opsMsg &&

{opsMsg}

} - {status === 'error' &&

✗ {errorMsg}

} - {status === 'saved' &&

✓ Draft action completed.

} -
- )} {adminTab === 'episodes' && (
@@ -1294,52 +1409,54 @@ 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)} /> -
-
- -