diff --git a/package-lock.json b/package-lock.json index c3ac744..e7a9434 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,11 @@ "version": "1.0.0", "dependencies": { "axios": "^1.7.2", + "better-sqlite3": "^12.10.0", "cheerio": "^1.0.0-rc.12", "cors": "^2.8.5", "express": "^4.19.2", + "express-rate-limit": "^8.5.2", "puppeteer": "^25.1.0", "puppeteer-extra": "^3.3.6", "puppeteer-extra-plugin-stealth": "^2.11.2" @@ -167,6 +169,60 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "12.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.10.0.tgz", + "integrity": "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/body-parser": { "version": "1.20.5", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", @@ -219,6 +275,30 @@ "concat-map": "0.0.1" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -299,6 +379,12 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, "node_modules/chromium-bidi": { "version": "16.0.1", "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-16.0.1.tgz", @@ -471,6 +557,30 @@ "ms": "2.0.0" } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -508,6 +618,15 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/devtools-protocol": { "version": "0.0.1624250", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1624250.tgz", @@ -617,6 +736,15 @@ "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -698,6 +826,15 @@ "node": ">= 0.6" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/express": { "version": "4.22.2", "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", @@ -744,6 +881,30 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, "node_modules/finalhandler": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", @@ -837,6 +998,12 @@ "node": ">= 0.6" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/fs-extra": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", @@ -912,6 +1079,12 @@ "node": ">= 0.4" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -1089,6 +1262,26 @@ "node": ">=0.10.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -1106,6 +1299,21 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "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", @@ -1288,6 +1496,18 @@ "node": ">= 0.6" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -1300,6 +1520,15 @@ "node": "*" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/mitt": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", @@ -1328,6 +1557,12 @@ "node": ">=0.10.0" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/modern-tar": { "version": "0.7.6", "resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.7.6.tgz", @@ -1343,6 +1578,12 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -1352,6 +1593,18 @@ "node": ">= 0.6" } }, + "node_modules/node-abi": { + "version": "3.92.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", + "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", @@ -1479,6 +1732,33 @@ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -1501,6 +1781,16 @@ "node": ">=10" } }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/puppeteer": { "version": "25.1.0", "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-25.1.0.tgz", @@ -1841,6 +2131,35 @@ "node": ">=0.10.0" } }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -1892,6 +2211,18 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/send": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", @@ -2051,6 +2382,51 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -2060,6 +2436,15 @@ "node": ">= 0.8" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -2086,6 +2471,43 @@ "node": ">=8" } }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -2095,6 +2517,18 @@ "node": ">=0.6" } }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -2141,6 +2575,12 @@ "node": ">= 0.8" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", diff --git a/package.json b/package.json index 1cde348..c9cfa45 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,11 @@ }, "dependencies": { "axios": "^1.7.2", + "better-sqlite3": "^12.10.0", "cheerio": "^1.0.0-rc.12", "cors": "^2.8.5", "express": "^4.19.2", + "express-rate-limit": "^8.5.2", "puppeteer": "^25.1.0", "puppeteer-extra": "^3.3.6", "puppeteer-extra-plugin-stealth": "^2.11.2" diff --git a/public/app.js b/public/app.js index ccdaba4..22c8c88 100644 --- a/public/app.js +++ b/public/app.js @@ -85,6 +85,9 @@ const elements = { btnDetailEdit: document.getElementById('btn-detail-edit'), btnDetailDelete: document.getElementById('btn-detail-delete'), btnDetailClone: document.getElementById('btn-detail-clone'), + btnDetailShare: document.getElementById('btn-detail-share'), + btnDetailRevokeShare: document.getElementById('btn-detail-revoke-share'), + tagShareStats: document.getElementById('tag-share-stats'), recipeDetailCreator: document.getElementById('recipe-detail-creator'), recipeDetailCreatorName: document.getElementById('recipe-detail-creator-name'), recipeDetailTitle: document.getElementById('recipe-detail-title'), @@ -175,6 +178,8 @@ function registerEvents() { elements.btnDetailEdit.addEventListener('click', editActiveRecipe); elements.btnDetailDelete.addEventListener('click', deleteActiveRecipe); elements.btnDetailClone.addEventListener('click', cloneActiveRecipe); + elements.btnDetailShare.addEventListener('click', shareActiveRecipe); + elements.btnDetailRevokeShare.addEventListener('click', revokeActiveRecipeShare); // Auth Form Submissions elements.loginForm.addEventListener('submit', handleLogin); @@ -539,6 +544,100 @@ async function cloneActiveRecipe() { } } +async function shareActiveRecipe() { + if (!activeRecipe) return; + + if (activeRecipe.isPublic === false) { + if (confirm(`"${activeRecipe.title}" is currently private.\n\nTo share it with people outside ForkFolio, you must make it public first. Would you like to make this recipe public now?`)) { + try { + const response = await fetch(`/api/recipes/${activeRecipe.id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + ...getAuthHeaders() + }, + body: JSON.stringify({ isPublic: true }) + }); + + if (response.status === 401) { + logout(); + return; + } + + if (!response.ok) throw new Error('Failed to update privacy.'); + + const updated = await response.json(); + activeRecipe = updated; + fetchRecipes(); + + // Re-render detail badge visual state + showRecipeDetails(updated); + } catch (err) { + console.error('Error toggling privacy for share link:', err); + alert('Failed to make the recipe public.'); + return; + } + } else { + return; + } + } + + // If public and has a share token + if (activeRecipe.shareToken) { + const shareLink = `${window.location.origin}/share.html?token=${activeRecipe.shareToken}`; + try { + await navigator.clipboard.writeText(shareLink); + alert(`Public share link successfully copied to your clipboard!\n\n${shareLink}`); + } catch (clipErr) { + console.warn('Clipboard write failed. Displaying fallback alert:', clipErr); + prompt('Public Share Link:', shareLink); + } + } else { + alert('Sharing token was not generated. Please try saving this recipe as public again.'); + } +} + +async function revokeActiveRecipeShare() { + if (!activeRecipe) return; + if (!confirm(`Are you sure you want to revoke the shared link for "${activeRecipe.title}"?\n\nThis will make the recipe private, disable the public link immediately, and clear all visitor history.`)) return; + + try { + const response = await fetch(`/api/recipes/${activeRecipe.id}/revoke-share`, { + method: 'POST', + headers: getAuthHeaders() + }); + + if (response.status === 401) { + logout(); + return; + } + + if (!response.ok) { + const err = await response.json().catch(() => ({})); + throw new Error(err.error || 'Failed to revoke shared link.'); + } + + alert(`Successfully revoked the public share link for "${activeRecipe.title}".`); + + // Refresh list and details view + const listUrl = activeTab === 'community-vault' ? '/api/recipes/public' : '/api/recipes'; + const listRes = await fetch(listUrl, { headers: getAuthHeaders() }); + if (listRes.ok) { + allRecipes = await listRes.json(); + renderLibrary(); + const updated = allRecipes.find(r => r.id === activeRecipe.id); + if (updated) { + showRecipeDetails(updated); + } else { + closeModal('detail-modal'); + } + } + } catch (error) { + console.error('Error revoking recipe share:', error); + alert(`Error: ${error.message}`); + } +} + // ------------------------------------------------------------- // DRAFT / MANUAL CREATOR MODAL POPULATOR // ------------------------------------------------------------- @@ -850,9 +949,17 @@ function showRecipeDetails(recipe) { if (canEdit) { elements.btnDetailEdit.style.display = 'flex'; elements.btnDetailDelete.style.display = 'flex'; + + // Revoke share button toggle + if (recipe.isPublic && recipe.shareToken) { + elements.btnDetailRevokeShare.style.display = 'flex'; + } else { + elements.btnDetailRevokeShare.style.display = 'none'; + } } else { elements.btnDetailEdit.style.display = 'none'; elements.btnDetailDelete.style.display = 'none'; + elements.btnDetailRevokeShare.style.display = 'none'; } if (isOwner) { @@ -892,6 +999,14 @@ function showRecipeDetails(recipe) { } else { elements.tagSource.style.display = 'none'; } + + // Public unique visitors count stats badge + if (recipe.isPublic && recipe.shareToken && recipe.visitorCount !== undefined) { + elements.tagShareStats.style.display = 'flex'; + elements.tagShareStats.querySelector('.val').textContent = `${recipe.visitorCount} Unique ${recipe.visitorCount === 1 ? 'Visitor' : 'Visitors'}`; + } else { + elements.tagShareStats.style.display = 'none'; + } // Populate Ingredients with scaler renderScaledIngredients(); diff --git a/public/index.html b/public/index.html index 5bf5724..2869e89 100644 --- a/public/index.html +++ b/public/index.html @@ -249,6 +249,8 @@ + + @@ -267,6 +269,9 @@ +

A quick and delicious salmon dinner.

@@ -431,7 +436,6 @@
-

Lowercase letters, numbers, underscores (3-20 characters).

@@ -439,7 +443,6 @@
-
diff --git a/public/share.html b/public/share.html new file mode 100644 index 0000000..9bc6550 --- /dev/null +++ b/public/share.html @@ -0,0 +1,229 @@ + + + + + + ForkFolio – Shared Recipe Viewer + + + + + + + + + + + + +
+ + + +
+ + +
+ +

Recipe Not Found

+

This link may have expired, or the chef has set this recipe back to private.

+ + Back to Homepage + +
+ + +
+ + + + + +
+ + + + + + + diff --git a/public/share.js b/public/share.js new file mode 100644 index 0000000..88ed459 --- /dev/null +++ b/public/share.js @@ -0,0 +1,387 @@ +// ForkFolio Shared Recipe Viewer Logic +let activeRecipe = null; +let currentMultiplier = 1; +let stopwatchInterval = null; +let stopwatchSeconds = 0; +let cookingActiveStep = 0; + +// DOM Cache +const elements = { + themeToggle: document.getElementById('theme-toggle'), + shareRecipeBlock: document.getElementById('share-recipe-block'), + recipeNotFoundBlock: document.getElementById('recipe-not-found-block'), + + // Recipe Details + recipeDetailBanner: document.getElementById('recipe-detail-banner'), + recipeDetailTitle: document.getElementById('recipe-detail-title'), + recipeDetailCreatorName: document.getElementById('recipe-detail-creator-name'), + tagServings: document.getElementById('tag-servings'), + tagPrep: document.getElementById('tag-prep'), + tagCook: document.getElementById('tag-cook'), + tagSource: document.getElementById('tag-source'), + recipeDetailDescription: document.getElementById('recipe-detail-description'), + recipeDetailIngredients: document.getElementById('recipe-detail-ingredients'), + recipeDetailInstructions: document.getElementById('recipe-detail-instructions'), + + // Scaler + scalerMinus: document.getElementById('scaler-minus'), + scalerPlus: document.getElementById('scaler-plus'), + currentServingsVal: document.getElementById('current-servings-val'), + btnTriggerCookmode: document.getElementById('btn-trigger-cookmode'), + + // Cooking Mode Modal + cookingModeModal: document.getElementById('cooking-mode-modal'), + cookRecipeTitle: document.getElementById('cook-recipe-title'), + cookTimerVal: document.getElementById('cook-timer-val'), + cookTimerPlay: document.getElementById('cook-timer-play'), + cookTimerReset: document.getElementById('cook-timer-reset'), + cookProgressBar: document.getElementById('cook-progress-bar'), + cookStepIndex: document.getElementById('cook-step-index'), + cookStepInstruction: document.getElementById('cook-step-instruction'), + cookBtnPrev: document.getElementById('cook-btn-prev'), + cookBtnNext: document.getElementById('cook-btn-next'), + cookStepDots: document.getElementById('cook-step-dots') +}; + +// Start Lifecycle +window.addEventListener('DOMContentLoaded', () => { + initTheme(); + registerEvents(); + fetchSharedRecipe(); +}); + +function initTheme() { + const savedTheme = localStorage.getItem('gourmet-theme') || 'dark'; + if (savedTheme === 'dark') { + document.body.classList.add('dark-theme'); + } else { + document.body.classList.remove('dark-theme'); + } +} + +function toggleTheme() { + document.body.classList.toggle('dark-theme'); + const isDark = document.body.classList.contains('dark-theme'); + localStorage.setItem('gourmet-theme', isDark ? 'dark' : 'light'); +} + +function registerEvents() { + elements.themeToggle.addEventListener('click', toggleTheme); + + // Portions Adjustments + elements.scalerMinus.addEventListener('click', () => adjustMultiplier(-0.5)); + elements.scalerPlus.addEventListener('click', () => adjustMultiplier(0.5)); + + // Cooking steppers triggers + elements.btnTriggerCookmode.addEventListener('click', enterCookingMode); + elements.cookBtnPrev.addEventListener('click', prevCookingStep); + elements.cookBtnNext.addEventListener('click', nextCookingStep); + elements.cookTimerPlay.addEventListener('click', toggleTimer); + elements.cookTimerReset.addEventListener('click', resetTimer); + + // Hotkeys for cooking stepper + document.addEventListener('keydown', (e) => { + if (elements.cookingModeModal.classList.contains('active')) { + if (e.key === 'ArrowRight' || e.key === ' ') { + e.preventDefault(); + nextCookingStep(); + } else if (e.key === 'ArrowLeft') { + e.preventDefault(); + prevCookingStep(); + } else if (e.key === 'Escape') { + exitCookingMode(); + } + } + }); +} + +// Fetch public shared recipe +async function fetchSharedRecipe() { + const token = new URLSearchParams(window.location.search).get('token'); + if (!token) { + showErrorBlock(); + return; + } + + try { + const response = await fetch(`/api/share/recipes/${token}`); + if (!response.ok) { + throw new Error('Not found'); + } + + activeRecipe = await response.json(); + renderRecipePage(activeRecipe); + } catch (err) { + console.error('Failed to load shared recipe:', err); + showErrorBlock(); + } +} + +function showErrorBlock() { + elements.recipeNotFoundBlock.style.display = 'flex'; + elements.shareRecipeBlock.style.display = 'none'; + lucide.createIcons(); +} + +function renderRecipePage(recipe) { + elements.recipeNotFoundBlock.style.display = 'none'; + elements.shareRecipeBlock.style.display = 'block'; + + // Banner Image Cover + if (recipe.image) { + elements.recipeDetailBanner.style.backgroundImage = `url('${recipe.image}')`; + } else { + elements.recipeDetailBanner.style.backgroundImage = 'none'; + elements.recipeDetailBanner.style.backgroundColor = 'var(--bg-app)'; + } + + elements.recipeDetailTitle.textContent = recipe.title; + elements.recipeDetailCreatorName.textContent = `@${recipe.createdBy || 'gourmet_system'}`; + elements.recipeDetailDescription.textContent = recipe.description || 'No description available for this recipe.'; + + // Servings and timings tags + if (recipe.servings) { + elements.tagServings.style.display = 'flex'; + elements.tagServings.querySelector('.val').textContent = `${recipe.servings} Servings`; + } else { + elements.tagServings.style.display = 'none'; + } + + if (recipe.prepTime) { + elements.tagPrep.style.display = 'flex'; + elements.tagPrep.querySelector('.val').textContent = recipe.prepTime; + } else { + elements.tagPrep.style.display = 'none'; + } + + if (recipe.cookTime) { + elements.tagCook.style.display = 'flex'; + elements.tagCook.querySelector('.val').textContent = recipe.cookTime; + } else { + elements.tagCook.style.display = 'none'; + } + + if (recipe.sourceUrl) { + elements.tagSource.style.display = 'flex'; + elements.tagSource.href = recipe.sourceUrl; + } else { + elements.tagSource.style.display = 'none'; + } + + // Render lists + renderScaledIngredients(); + renderInstructions(); + + lucide.createIcons(); +} + +// Ingredient Scaling Algorithms +function adjustMultiplier(change) { + const newMulti = currentMultiplier + change; + if (newMulti < 0.5 || newMulti > 10) return; + + currentMultiplier = newMulti; + elements.currentServingsVal.textContent = `${currentMultiplier}x`; + renderScaledIngredients(); +} + +function renderScaledIngredients() { + elements.recipeDetailIngredients.innerHTML = ''; + if (!activeRecipe || !activeRecipe.ingredients) return; + + activeRecipe.ingredients.forEach((ing) => { + const scaledText = scaleIngredientText(ing, currentMultiplier); + const li = document.createElement('li'); + li.className = 'ingredient-checkbox-item'; + li.innerHTML = ` +
+ ${scaledText} + `; + li.addEventListener('click', () => li.classList.toggle('checked')); + elements.recipeDetailIngredients.appendChild(li); + }); + + lucide.createIcons(); +} + +function scaleIngredientText(text, multiplier) { + if (multiplier === 1) return text; + + const mixedRegex = /^(\d+)\s+(\d+\/\d+)\s*(.*)/; + const mixedMatch = text.match(mixedRegex); + if (mixedMatch) { + const whole = parseInt(mixedMatch[1], 10); + const fracParts = mixedMatch[2].split('/'); + const fracVal = parseInt(fracParts[0], 10) / parseInt(fracParts[1], 10); + const total = (whole + fracVal) * multiplier; + return `${formatScaledNumber(total)} ${mixedMatch[3]}`; + } + + const fracRegex = /^(\d+\/\d+)\s*(.*)/; + const fracMatch = text.match(fracRegex); + if (fracMatch) { + const parts = fracMatch[1].split('/'); + const val = (parseInt(parts[0], 10) / parseInt(parts[1], 10)) * multiplier; + return `${formatScaledNumber(val)} ${fracMatch[2]}`; + } + + const numRegex = /^(\d+(?:\.\d+)?)\s*(.*)/; + const numMatch = text.match(numRegex); + if (numMatch) { + const val = parseFloat(numMatch[1]) * multiplier; + return `${formatScaledNumber(val)} ${numMatch[2]}`; + } + + return text; +} + +function formatScaledNumber(num) { + if (num % 1 === 0) return num.toString(); + const rounded = Math.round(num * 100) / 100; + const decimal = rounded % 1; + const integer = Math.floor(rounded); + + if (Math.abs(decimal - 0.25) < 0.05) return (integer > 0 ? integer + ' ' : '') + '1/4'; + if (Math.abs(decimal - 0.5) < 0.05) return (integer > 0 ? integer + ' ' : '') + '1/2'; + if (Math.abs(decimal - 0.75) < 0.05) return (integer > 0 ? integer + ' ' : '') + '3/4'; + if (Math.abs(decimal - 0.33) < 0.05) return (integer > 0 ? integer + ' ' : '') + '1/3'; + if (Math.abs(decimal - 0.66) < 0.05) return (integer > 0 ? integer + ' ' : '') + '2/3'; + + return rounded.toString(); +} + +// Render Directions Steps +function renderInstructions() { + elements.recipeDetailInstructions.innerHTML = ''; + if (!activeRecipe || !activeRecipe.instructions) return; + + activeRecipe.instructions.forEach((step, index) => { + const li = document.createElement('li'); + if (step.startsWith('###')) { + li.className = 'instruction-step-item section-header-step'; + li.innerHTML = `
${step.replace('###', '').trim()}
`; + } else { + li.className = 'instruction-step-item'; + li.innerHTML = ` +
${index + 1}
+
${step}
+ `; + li.addEventListener('click', () => li.classList.toggle('checked')); + } + elements.recipeDetailInstructions.appendChild(li); + }); +} + +// Standalone Cooking Assistant Mode +function enterCookingMode() { + if (!activeRecipe || !activeRecipe.instructions || activeRecipe.instructions.length === 0) return; + + elements.cookRecipeTitle.textContent = activeRecipe.title; + cookingActiveStep = 0; + + // Filter out sub-section headers (###) + const cleanInstructions = activeRecipe.instructions.filter(step => !step.startsWith('###')); + if (cleanInstructions.length === 0) return; + + elements.cookingModeModal.classList.add('active'); + document.body.style.overflow = 'hidden'; + + renderCookingStep(cleanInstructions); + resetTimer(); + startTimer(); +} + +function exitCookingMode() { + elements.cookingModeModal.classList.remove('active'); + document.body.style.overflow = 'auto'; + stopTimer(); +} + +function renderCookingStep(instructions) { + const stepText = instructions[cookingActiveStep]; + elements.cookStepIndex.textContent = `Step ${cookingActiveStep + 1} of ${instructions.length}`; + elements.cookStepInstruction.textContent = stepText; + + // Update progress bar + const progressPercent = ((cookingActiveStep + 1) / instructions.length) * 100; + elements.cookProgressBar.style.width = `${progressPercent}%`; + + // Navigation dots + elements.cookStepDots.innerHTML = ''; + instructions.forEach((_, idx) => { + const dot = document.createElement('div'); + dot.className = `dot ${idx === cookingActiveStep ? 'active' : ''}`; + dot.addEventListener('click', () => { + cookingActiveStep = idx; + renderCookingStep(instructions); + }); + elements.cookStepDots.appendChild(dot); + }); + + // Enable/disable navigation buttons + elements.cookBtnPrev.disabled = cookingActiveStep === 0; + const nextBtnIconWrapper = elements.cookBtnNext.querySelector('.btn-icon-wrapper'); + if (cookingActiveStep === instructions.length - 1) { + elements.cookBtnNext.querySelector('span').textContent = 'Finish Cooking'; + if (nextBtnIconWrapper) nextBtnIconWrapper.innerHTML = ''; + } else { + elements.cookBtnNext.querySelector('span').textContent = 'Next Step'; + if (nextBtnIconWrapper) nextBtnIconWrapper.innerHTML = ''; + } + lucide.createIcons(); +} + +function prevCookingStep() { + const cleanInstructions = activeRecipe.instructions.filter(step => !step.startsWith('###')); + if (cookingActiveStep > 0) { + cookingActiveStep--; + renderCookingStep(cleanInstructions); + } +} + +function nextCookingStep() { + const cleanInstructions = activeRecipe.instructions.filter(step => !step.startsWith('###')); + if (cookingActiveStep < cleanInstructions.length - 1) { + cookingActiveStep++; + renderCookingStep(cleanInstructions); + } else { + exitCookingMode(); + } +} + +// Timer Stopwatch Controls +function startTimer() { + if (stopwatchInterval) return; + elements.cookTimerPlay.innerHTML = ''; + lucide.createIcons(); + + stopwatchInterval = setInterval(() => { + stopwatchSeconds++; + const mins = Math.floor(stopwatchSeconds / 60).toString().padStart(2, '0'); + const secs = (stopwatchSeconds % 60).toString().padStart(2, '0'); + elements.cookTimerVal.textContent = `${mins}:${secs}`; + }, 1000); +} + +function stopTimer() { + if (stopwatchInterval) { + clearInterval(stopwatchInterval); + stopwatchInterval = null; + } + elements.cookTimerPlay.innerHTML = ''; + lucide.createIcons(); +} + +function toggleTimer() { + if (stopwatchInterval) { + stopTimer(); + } else { + startTimer(); + } +} + +function resetTimer() { + stopTimer(); + stopwatchSeconds = 0; + elements.cookTimerVal.textContent = '00:00'; +} diff --git a/server.js b/server.js index 83e99ff..6084bf2 100644 --- a/server.js +++ b/server.js @@ -7,6 +7,8 @@ const path = require('path'); const crypto = require('crypto'); const puppeteer = require('puppeteer-extra'); const StealthPlugin = require('puppeteer-extra-plugin-stealth'); +const Database = require('better-sqlite3'); +const rateLimit = require('express-rate-limit'); // Enable stealth mode to bypass bot detection (Cloudflare, Akamai, etc.) puppeteer.use(StealthPlugin()); @@ -16,47 +18,220 @@ const PORT = process.env.PORT || 3000; // Setup directories for persistence const DATA_DIR = path.join(__dirname, 'data'); -const DATA_FILE = path.join(DATA_DIR, 'recipes.json'); -const USERS_FILE = path.join(DATA_DIR, 'users.json'); +const DB_FILE = path.join(DATA_DIR, 'forkfolio.db'); if (!fs.existsSync(DATA_DIR)) { fs.mkdirSync(DATA_DIR, { recursive: true }); } -if (!fs.existsSync(DATA_FILE)) { - fs.writeFileSync(DATA_FILE, JSON.stringify([], null, 2)); -} -if (!fs.existsSync(USERS_FILE)) { - fs.writeFileSync(USERS_FILE, JSON.stringify([], null, 2)); + +// Open SQLite Database connection and set WAL journal mode for concurrency speed +const db = new Database(DB_FILE); +db.pragma('journal_mode = WAL'); + +// Initialize database schema tables +db.prepare(` + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + salt TEXT NOT NULL, + passwordHash TEXT NOT NULL, + isAdmin INTEGER NOT NULL DEFAULT 0, + isRecipeManager INTEGER NOT NULL DEFAULT 0, + createdAt TEXT NOT NULL + ) +`).run(); + +db.prepare(` + CREATE TABLE IF NOT EXISTS recipes ( + id TEXT PRIMARY KEY, + userId TEXT NOT NULL, + createdBy TEXT NOT NULL, + isPublic INTEGER NOT NULL DEFAULT 1, + title TEXT NOT NULL, + description TEXT, + servings TEXT, + prepTime TEXT, + cookTime TEXT, + totalTime TEXT, + image TEXT, + images TEXT, -- JSON stringified array + ingredients TEXT, -- JSON stringified array + instructions TEXT, -- JSON stringified array + sourceUrl TEXT, + shareToken TEXT UNIQUE, -- Unguessable sharing token + createdAt TEXT NOT NULL, + updatedAt TEXT, + FOREIGN KEY (userId) REFERENCES users (id) ON DELETE CASCADE + ) +`).run(); + +db.prepare(` + CREATE TABLE IF NOT EXISTS settings ( + geminiApiKey TEXT DEFAULT '' + ) +`).run(); + +db.prepare(` + CREATE TABLE IF NOT EXISTS recipe_shares_visits ( + shareToken TEXT NOT NULL, + visitorIp TEXT NOT NULL, + visitedAt TEXT NOT NULL, + PRIMARY KEY (shareToken, visitorIp) + ) +`).run(); + +// Initialize single settings record if empty +const settingsCount = db.prepare('SELECT COUNT(*) as count FROM settings').get().count; +if (settingsCount === 0) { + db.prepare('INSERT INTO settings (geminiApiKey) VALUES (?)').run(''); } -// Ensure default admin user exists +// Ensure default system_admin and admin user profiles exist try { - const usersData = fs.readFileSync(USERS_FILE, 'utf8'); - const existingUsers = JSON.parse(usersData); - const hasAdmin = existingUsers.some(u => u.isAdmin === true); - if (!hasAdmin) { + // Ensure default system_admin user exists to fulfill recipe foreign key constraints + const hasSystemAdmin = db.prepare("SELECT COUNT(*) as count FROM users WHERE id = 'system_admin'").get().count; + if (hasSystemAdmin === 0) { + db.prepare(` + INSERT INTO users (id, username, salt, passwordHash, isAdmin, isRecipeManager, createdAt) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run('system_admin', 'system_admin', 'system_salt', 'system_hash', 0, 0, new Date().toISOString()); + } + + const adminCount = db.prepare("SELECT COUNT(*) as count FROM users WHERE isAdmin = 1").get().count; + if (adminCount === 0) { const salt = crypto.randomBytes(16).toString('hex'); const passwordHash = crypto.pbkdf2Sync('admin123', salt, 1000, 64, 'sha512').toString('hex'); - existingUsers.push({ - id: 'u_admin', - username: 'admin', + db.prepare(` + INSERT INTO users (id, username, salt, passwordHash, isAdmin, isRecipeManager, createdAt) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run( + 'u_admin', + 'admin', salt, passwordHash, - isAdmin: true, - isRecipeManager: true, - createdAt: new Date().toISOString() - }); - fs.writeFileSync(USERS_FILE, JSON.stringify(existingUsers, null, 2), 'utf8'); + 1, + 1, + new Date().toISOString() + ); console.log('--- ForkFolio Admin Initialized ---'); console.log('Default Admin Username: admin'); console.log('Default Admin Password: admin123'); console.log('--------------------------------------'); } } catch (e) { - console.error('Failed to initialize default admin user:', e); + console.error('Failed to initialize default admin and system users:', e); } -// In-memory sessions storage (token -> { id, username }) +// Automatic Migration from legacy JSON databases if they exist +const LEGACY_USERS_FILE = path.join(DATA_DIR, 'users.json'); +const LEGACY_RECIPES_FILE = path.join(DATA_DIR, 'recipes.json'); +const LEGACY_SETTINGS_FILE = path.join(DATA_DIR, 'settings.json'); + +try { + // Temporarily disable foreign keys during bulk migrations to allow flexible insert order + db.pragma('foreign_keys = OFF'); + + // 1. Migrate Users + if (fs.existsSync(LEGACY_USERS_FILE)) { + const backupUsersFile = path.join(DATA_DIR, 'users.json.backup'); + const data = fs.readFileSync(LEGACY_USERS_FILE, 'utf8'); + const users = JSON.parse(data); + if (Array.isArray(users) && users.length > 0) { + console.log(`[Migration] Found ${users.length} legacy users. Migrating to SQLite...`); + const insertUser = db.prepare(` + INSERT OR IGNORE INTO users (id, username, salt, passwordHash, isAdmin, isRecipeManager, createdAt) + VALUES (?, ?, ?, ?, ?, ?, ?) + `); + + const transaction = db.transaction((userList) => { + for (const u of userList) { + insertUser.run( + u.id, + u.username.trim().toLowerCase(), + u.salt, + u.passwordHash, + u.isAdmin ? 1 : 0, + u.isRecipeManager ? 1 : 0, + u.createdAt || new Date().toISOString() + ); + } + }); + transaction(users); + console.log(`[Migration] User migration completed.`); + } + fs.renameSync(LEGACY_USERS_FILE, backupUsersFile); + } + + // 2. Migrate Recipes + if (fs.existsSync(LEGACY_RECIPES_FILE)) { + const backupRecipesFile = path.join(DATA_DIR, 'recipes.json.backup'); + const data = fs.readFileSync(LEGACY_RECIPES_FILE, 'utf8'); + const recipes = JSON.parse(data); + if (Array.isArray(recipes) && recipes.length > 0) { + console.log(`[Migration] Found ${recipes.length} legacy recipes. Migrating to SQLite...`); + const insertRecipe = db.prepare(` + INSERT OR IGNORE INTO recipes ( + id, userId, createdBy, isPublic, title, description, servings, + prepTime, cookTime, totalTime, image, images, ingredients, instructions, sourceUrl, shareToken, createdAt, updatedAt + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + + const transaction = db.transaction((recipeList) => { + for (const r of recipeList) { + const shareToken = r.isPublic !== false && !r.shareToken + ? crypto.randomBytes(16).toString('hex') + : (r.shareToken || null); + + insertRecipe.run( + r.id, + r.userId || 'system_admin', + r.createdBy || 'gourmet_system', + r.isPublic !== false ? 1 : 0, + r.title || 'Untitled Recipe', + r.description || '', + r.servings || '', + r.prepTime || '', + r.cookTime || '', + r.totalTime || '', + r.image || '', + JSON.stringify(r.images || []), + JSON.stringify(r.ingredients || []), + JSON.stringify(r.instructions || []), + r.sourceUrl || '', + shareToken, + r.createdAt || new Date().toISOString(), + r.updatedAt || null + ); + } + }); + transaction(recipes); + console.log(`[Migration] Recipe migration completed.`); + } + fs.renameSync(LEGACY_RECIPES_FILE, backupRecipesFile); + } + + // 3. Migrate Settings + if (fs.existsSync(LEGACY_SETTINGS_FILE)) { + const backupSettingsFile = path.join(DATA_DIR, 'settings.json.backup'); + const data = fs.readFileSync(LEGACY_SETTINGS_FILE, 'utf8'); + const settings = JSON.parse(data); + if (settings && settings.geminiApiKey) { + console.log('[Migration] Migrating system settings key to SQLite...'); + db.prepare('UPDATE settings SET geminiApiKey = ?').run(settings.geminiApiKey.trim()); + console.log('[Migration] Settings migration completed.'); + } + fs.renameSync(LEGACY_SETTINGS_FILE, backupSettingsFile); + } + + // Restore foreign keys enforcement after migrations + db.pragma('foreign_keys = ON'); +} catch (migrationError) { + // Guarantee foreign keys re-enabled even on crash + db.pragma('foreign_keys = ON'); + console.error('[Migration] Failed to perform automatic SQLite migration:', migrationError); +} + +// In-memory sessions storage (token -> { id, username, isAdmin, isRecipeManager }) const activeSessions = new Map(); app.use(cors()); @@ -64,6 +239,13 @@ app.use(express.json({ limit: '10mb' })); app.use(express.urlencoded({ limit: '10mb', extended: true })); app.use(express.static(path.join(__dirname, 'public'))); +// Configure rate-limiting middleware for public shared routes +const publicShareLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 100, // Limit each IP to 100 requests per 15-minute window + message: { error: 'Too many queries from this IP. Please try again in 15 minutes.' } +}); + // Helper: Parse ISO 8601 Durations (e.g. PT1H30M to "1 hr 30 mins") function parseISODuration(duration) { if (!duration || typeof duration !== 'string') return ''; @@ -94,7 +276,7 @@ function extractInstructions(instructions) { result.push(inst.text || inst.name || ''); } else if (inst['@type'] === 'HowToSection' && Array.isArray(inst.itemListElement)) { if (inst.name) { - result.push(`### ${inst.name}`); // Prefix section names to identify them in the frontend + result.push(`### ${inst.name}`); // Prefix section names } for (const step of inst.itemListElement) { if (typeof step === 'string') { @@ -128,40 +310,6 @@ function normalizeImages(imageObj) { return []; } -// Helper: Retrieve saved users -function readUsers() { - try { - const data = fs.readFileSync(USERS_FILE, 'utf8'); - let users = JSON.parse(data); - let migrated = false; - users = users.map(u => { - if (u.isAdmin && !u.isRecipeManager) { - u.isRecipeManager = true; - migrated = true; - } - return u; - }); - if (migrated) { - writeUsers(users); - } - return users; - } catch (e) { - console.error('Error reading users database:', e); - return []; - } -} - -// Helper: Save users to storage -function writeUsers(users) { - try { - fs.writeFileSync(USERS_FILE, JSON.stringify(users, null, 2), 'utf8'); - return true; - } catch (e) { - console.error('Error writing users database:', e); - return false; - } -} - // Password Hashing and Salt helpers function generateSalt() { return crypto.randomBytes(16).toString('hex'); @@ -186,40 +334,28 @@ function requireAuth(req, res, next) { next(); } -// Helper: Retrieve saved recipes with automatic backward-compatibility migration -function readRecipes() { - try { - const data = fs.readFileSync(DATA_FILE, 'utf8'); - let recipes = JSON.parse(data); - let migrated = false; - recipes = recipes.map(r => { - if (!r.userId) { - r.userId = 'system_admin'; - r.createdBy = 'gourmet_system'; - r.isPublic = true; - migrated = true; - } - return r; - }); - if (migrated) { - writeRecipes(recipes); +// Helper: Deserialize JSON columns from SQLite recipe row +function formatRecipe(row) { + if (!row) return null; + + let visitorCount = 0; + if (row.shareToken) { + try { + const res = db.prepare('SELECT COUNT(*) as count FROM recipe_shares_visits WHERE shareToken = ?').get(row.shareToken); + visitorCount = res ? res.count : 0; + } catch (e) { + console.error('Failed to query unique visitor count:', e); } - return recipes; - } catch (e) { - console.error('Error reading recipes database:', e); - return []; } -} -// Helper: Save recipes to storage -function writeRecipes(recipes) { - try { - fs.writeFileSync(DATA_FILE, JSON.stringify(recipes, null, 2), 'utf8'); - return true; - } catch (e) { - console.error('Error writing recipes database:', e); - return false; - } + return { + ...row, + isPublic: row.isPublic === 1, + images: JSON.parse(row.images || '[]'), + ingredients: JSON.parse(row.ingredients || '[]'), + instructions: JSON.parse(row.instructions || '[]'), + visitorCount + }; } // Auth API: User Registration (Requires Admin session) @@ -242,36 +378,33 @@ app.post('/api/auth/register', requireAuth, (req, res) => { return res.status(400).json({ error: 'Password must be at least 6 characters long.' }); } - const users = readUsers(); - const existingUser = users.find(u => u.username.toLowerCase() === cleanUsername); - if (existingUser) { - return res.status(400).json({ error: 'Username is already taken.' }); - } - - const salt = generateSalt(); - const passwordHash = hashPassword(password, salt); - - const newUser = { - id: 'u_' + Date.now().toString(36) + Math.random().toString(36).substr(2, 5), - username: cleanUsername, - salt, - passwordHash, - isAdmin: false, - isRecipeManager: false, - createdAt: new Date().toISOString() - }; - - users.push(newUser); - writeUsers(users); - - res.status(201).json({ - user: { - id: newUser.id, - username: newUser.username, - isAdmin: false, - isRecipeManager: false + try { + const existing = db.prepare('SELECT id FROM users WHERE username = ?').get(cleanUsername); + if (existing) { + return res.status(400).json({ error: 'Username is already taken.' }); } - }); + + const salt = generateSalt(); + const passwordHash = hashPassword(password, salt); + const id = 'u_' + Date.now().toString(36) + Math.random().toString(36).substr(2, 5); + + db.prepare(` + INSERT INTO users (id, username, salt, passwordHash, isAdmin, isRecipeManager, createdAt) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run(id, cleanUsername, salt, passwordHash, 0, 0, new Date().toISOString()); + + res.status(201).json({ + user: { + id, + username: cleanUsername, + isAdmin: false, + isRecipeManager: false + } + }); + } catch (error) { + console.error('Error during registration:', error); + res.status(500).json({ error: 'Failed to create account.' }); + } }); // Auth API: User Login @@ -282,35 +415,38 @@ app.post('/api/auth/login', (req, res) => { } const cleanUsername = username.trim().toLowerCase(); - const users = readUsers(); - const user = users.find(u => u.username.toLowerCase() === cleanUsername); - - if (!user) { - return res.status(401).json({ error: 'Invalid username or password.' }); - } - - const hash = hashPassword(password, user.salt); - if (hash !== user.passwordHash) { - return res.status(401).json({ error: 'Invalid username or password.' }); - } - - const token = crypto.randomBytes(32).toString('hex'); - activeSessions.set(token, { - id: user.id, - username: user.username, - isAdmin: user.isAdmin || false, - isRecipeManager: user.isRecipeManager || false - }); - - res.json({ - token, - user: { - id: user.id, - username: user.username, - isAdmin: user.isAdmin || false, - isRecipeManager: user.isRecipeManager || false + try { + const user = db.prepare('SELECT * FROM users WHERE username = ?').get(cleanUsername); + if (!user) { + return res.status(401).json({ error: 'Invalid username or password.' }); } - }); + + const hash = hashPassword(password, user.salt); + if (hash !== user.passwordHash) { + return res.status(401).json({ error: 'Invalid username or password.' }); + } + + const token = crypto.randomBytes(32).toString('hex'); + activeSessions.set(token, { + id: user.id, + username: user.username, + isAdmin: user.isAdmin === 1, + isRecipeManager: user.isRecipeManager === 1 + }); + + res.json({ + token, + user: { + id: user.id, + username: user.username, + isAdmin: user.isAdmin === 1, + isRecipeManager: user.isRecipeManager === 1 + } + }); + } catch (error) { + console.error('Error during login:', error); + res.status(500).json({ error: 'Login failed.' }); + } }); // Admin API: List all users (Requires Admin session) @@ -319,15 +455,20 @@ app.get('/api/admin/users', requireAuth, (req, res) => { return res.status(403).json({ error: 'Access denied. Administrator privileges required.' }); } - const users = readUsers().map(u => ({ - id: u.id, - username: u.username, - isAdmin: u.isAdmin || false, - isRecipeManager: u.isRecipeManager || false, - createdAt: u.createdAt - })); - - res.json(users); + try { + const rows = db.prepare('SELECT id, username, isAdmin, isRecipeManager, createdAt FROM users').all(); + const users = rows.map(u => ({ + id: u.id, + username: u.username, + isAdmin: u.isAdmin === 1, + isRecipeManager: u.isRecipeManager === 1, + createdAt: u.createdAt + })); + res.json(users); + } catch (error) { + console.error('Error listing users:', error); + res.status(500).json({ error: 'Failed to retrieve registered users.' }); + } }); // Admin API: Delete user (Requires Admin session) @@ -340,49 +481,48 @@ app.delete('/api/admin/users/:id', requireAuth, (req, res) => { return res.status(400).json({ error: 'You cannot delete your own administrative account.' }); } - const users = readUsers(); - const userIndex = users.findIndex(u => u.id === req.params.id); - - if (userIndex === -1) { - return res.status(404).json({ error: 'User not found.' }); - } - - const deletedUser = users[userIndex]; - const preserveRecipes = req.body.preserveRecipes === true; - - // Handle recipe dependencies - const recipes = readRecipes(); - let updatedRecipes; - - if (preserveRecipes) { - // Keep in Shared Cookbook (reassign to system_admin, set as public) - updatedRecipes = recipes.map(r => { - if (r.userId === deletedUser.id) { - r.userId = 'system_admin'; - r.createdBy = `${deletedUser.username} (former)`; - r.isPublic = true; - } - return r; - }); - } else { - // Purge entirely - updatedRecipes = recipes.filter(r => r.userId !== deletedUser.id); - } - - writeRecipes(updatedRecipes); - - // Remove user profile - users.splice(userIndex, 1); - writeUsers(users); - - // Invalidate any active session tokens of deleted user - for (const [token, session] of activeSessions.entries()) { - if (session.id === deletedUser.id) { - activeSessions.delete(token); + try { + const user = db.prepare('SELECT id, username FROM users WHERE id = ?').get(req.params.id); + if (!user) { + return res.status(404).json({ error: 'User not found.' }); } + + const preserveRecipes = req.body.preserveRecipes === true; + + // SQLite transaction for relational database integrity + const performDelete = db.transaction(() => { + if (preserveRecipes) { + // Reassign user's recipes to system_admin, set as public + db.prepare(` + UPDATE recipes + SET userId = 'system_admin', + createdBy = ?, + isPublic = 1 + WHERE userId = ? + `).run(`${user.username} (former)`, user.id); + } else { + // Delete user's recipes + db.prepare('DELETE FROM recipes WHERE userId = ?').run(user.id); + } + + // Delete user profile + db.prepare('DELETE FROM users WHERE id = ?').run(user.id); + }); + + performDelete(); + + // Invalidate active sessions + for (const [token, session] of activeSessions.entries()) { + if (session.id === user.id) { + activeSessions.delete(token); + } + } + + res.json({ success: true }); + } catch (error) { + console.error('Error deleting user:', error); + res.status(500).json({ error: 'Failed to delete user.' }); } - - res.json({ success: true }); }); // Admin API: Change user role (Requires Admin session) @@ -400,25 +540,27 @@ app.put('/api/admin/users/:id/role', requireAuth, (req, res) => { return res.status(400).json({ error: 'isAdmin property must be a boolean.' }); } - const users = readUsers(); - const userIndex = users.findIndex(u => u.id === req.params.id); - - if (userIndex === -1) { - return res.status(404).json({ error: 'User not found.' }); - } - - users[userIndex].isAdmin = isAdmin; - writeUsers(users); - - // Update active sessions to reflect the privilege change immediately - for (const [token, session] of activeSessions.entries()) { - if (session.id === users[userIndex].id) { - session.isAdmin = isAdmin; - activeSessions.set(token, session); + try { + const user = db.prepare('SELECT id, username FROM users WHERE id = ?').get(req.params.id); + if (!user) { + return res.status(404).json({ error: 'User not found.' }); } + + db.prepare('UPDATE users SET isAdmin = ? WHERE id = ?').run(isAdmin ? 1 : 0, user.id); + + // Update sessions + for (const [token, session] of activeSessions.entries()) { + if (session.id === user.id) { + session.isAdmin = isAdmin; + activeSessions.set(token, session); + } + } + + res.json({ success: true, user: { id: user.id, username: user.username, isAdmin } }); + } catch (error) { + console.error('Error changing user role:', error); + res.status(500).json({ error: 'Failed to update user role.' }); } - - res.json({ success: true, user: { id: users[userIndex].id, username: users[userIndex].username, isAdmin } }); }); // Admin API: Toggle recipe manager role (Requires Admin session) @@ -432,147 +574,228 @@ app.put('/api/admin/users/:id/recipe-manager', requireAuth, (req, res) => { return res.status(400).json({ error: 'isRecipeManager property must be a boolean.' }); } - const users = readUsers(); - const userIndex = users.findIndex(u => u.id === req.params.id); - - if (userIndex === -1) { - return res.status(404).json({ error: 'User not found.' }); - } - - users[userIndex].isRecipeManager = isRecipeManager; - writeUsers(users); - - // Update active sessions to reflect the privilege change immediately - for (const [token, session] of activeSessions.entries()) { - if (session.id === users[userIndex].id) { - session.isRecipeManager = isRecipeManager; - activeSessions.set(token, session); + try { + const user = db.prepare('SELECT id, username FROM users WHERE id = ?').get(req.params.id); + if (!user) { + return res.status(404).json({ error: 'User not found.' }); } + + db.prepare('UPDATE users SET isRecipeManager = ? WHERE id = ?').run(isRecipeManager ? 1 : 0, user.id); + + // Update sessions + for (const [token, session] of activeSessions.entries()) { + if (session.id === user.id) { + session.isRecipeManager = isRecipeManager; + activeSessions.set(token, session); + } + } + + res.json({ success: true, user: { id: user.id, username: user.username, isRecipeManager } }); + } catch (error) { + console.error('Error toggling manager role:', error); + res.status(500).json({ error: 'Failed to update manager permission.' }); } - - res.json({ success: true, user: { id: users[userIndex].id, username: users[userIndex].username, isRecipeManager } }); }); // API: List authenticated user's private recipes app.get('/api/recipes', requireAuth, (req, res) => { - const recipes = readRecipes(); - const userRecipes = recipes.filter(r => r.userId === req.user.id); - res.json(userRecipes); + try { + const rows = db.prepare('SELECT * FROM recipes WHERE userId = ? ORDER BY createdAt DESC').all(req.user.id); + const userRecipes = rows.map(formatRecipe); + res.json(userRecipes); + } catch (error) { + console.error('Error fetching recipes:', error); + res.status(500).json({ error: 'Failed to retrieve recipes.' }); + } }); // API: List all public recipes for Shared Cookbook app.get('/api/recipes/public', requireAuth, (req, res) => { - const recipes = readRecipes(); - const publicRecipes = recipes.filter(r => r.isPublic !== false); - res.json(publicRecipes); + try { + const rows = db.prepare('SELECT * FROM recipes WHERE isPublic = 1 ORDER BY createdAt DESC').all(); + const publicRecipes = rows.map(formatRecipe); + res.json(publicRecipes); + } catch (error) { + console.error('Error fetching public recipes:', error); + res.status(500).json({ error: 'Failed to retrieve community recipes.' }); + } }); // API: Clone a public recipe to user's private collection app.post('/api/recipes/clone/:id', requireAuth, (req, res) => { - const recipes = readRecipes(); - const sourceRecipe = recipes.find(r => r.id === req.params.id); - - if (!sourceRecipe) { - return res.status(404).json({ error: 'Recipe not found' }); + try { + const sourceRecipe = db.prepare('SELECT * FROM recipes WHERE id = ?').get(req.params.id); + + if (!sourceRecipe) { + return res.status(404).json({ error: 'Recipe not found' }); + } + + if (sourceRecipe.isPublic === 0) { + return res.status(403).json({ error: 'This recipe is private and cannot be cloned.' }); + } + + const clonedId = Date.now().toString(36) + Math.random().toString(36).substr(2, 5); + + db.prepare(` + INSERT INTO recipes ( + id, userId, createdBy, isPublic, title, description, servings, + prepTime, cookTime, totalTime, image, images, ingredients, instructions, sourceUrl, shareToken, createdAt, updatedAt + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + clonedId, + req.user.id, + req.user.username, + 0, // Default cloned copies to private so users can customize them privately + sourceRecipe.title || 'Untitled Recipe', + sourceRecipe.description || '', + sourceRecipe.servings || '', + sourceRecipe.prepTime || '', + sourceRecipe.cookTime || '', + sourceRecipe.totalTime || '', + sourceRecipe.image || '', + sourceRecipe.images || '[]', + sourceRecipe.ingredients || '[]', + sourceRecipe.instructions || '[]', + sourceRecipe.sourceUrl || '', + null, // Cloned copies start as private, no share token + new Date().toISOString(), + null + ); + + const clonedObj = formatRecipe(db.prepare('SELECT * FROM recipes WHERE id = ?').get(clonedId)); + res.status(201).json(clonedObj); + } catch (error) { + console.error('Error cloning recipe:', error); + res.status(500).json({ error: 'Failed to clone recipe.' }); } - - if (sourceRecipe.isPublic === false) { - return res.status(403).json({ error: 'This recipe is private and cannot be cloned.' }); - } - - const clonedRecipe = { - ...sourceRecipe, - id: Date.now().toString(36) + Math.random().toString(36).substr(2, 5), - userId: req.user.id, - createdBy: req.user.username, - isPublic: false, // Default cloned copies to private so users can customize them privately - createdAt: new Date().toISOString(), - updatedAt: undefined - }; - - recipes.push(clonedRecipe); - writeRecipes(recipes); - res.status(201).json(clonedRecipe); }); // API: Save new recipe app.post('/api/recipes', requireAuth, (req, res) => { - const recipes = readRecipes(); - const newRecipe = { - id: Date.now().toString(36) + Math.random().toString(36).substr(2, 5), - userId: req.user.id, - createdBy: req.user.username, - isPublic: req.body.isPublic !== false, - title: req.body.title || 'Untitled Recipe', - description: req.body.description || '', - ingredients: Array.isArray(req.body.ingredients) ? req.body.ingredients : [], - instructions: Array.isArray(req.body.instructions) ? req.body.instructions : [], - servings: req.body.servings || '', - prepTime: req.body.prepTime || '', - cookTime: req.body.cookTime || '', - totalTime: req.body.totalTime || '', - image: req.body.image || '', - images: Array.isArray(req.body.images) ? req.body.images : [], - sourceUrl: req.body.sourceUrl || '', - createdAt: new Date().toISOString() - }; + const isPublic = req.body.isPublic !== false ? 1 : 0; + const shareToken = isPublic === 1 ? crypto.randomBytes(16).toString('hex') : null; + const newId = Date.now().toString(36) + Math.random().toString(36).substr(2, 5); - recipes.push(newRecipe); - writeRecipes(recipes); - res.status(201).json(newRecipe); + try { + db.prepare(` + INSERT INTO recipes ( + id, userId, createdBy, isPublic, title, description, servings, + prepTime, cookTime, totalTime, image, images, ingredients, instructions, sourceUrl, shareToken, createdAt, updatedAt + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + newId, + req.user.id, + req.user.username, + isPublic, + req.body.title || 'Untitled Recipe', + req.body.description || '', + req.body.servings || '', + req.body.prepTime || '', + req.body.cookTime || '', + req.body.totalTime || '', + req.body.image || '', + JSON.stringify(Array.isArray(req.body.images) ? req.body.images : []), + JSON.stringify(Array.isArray(req.body.ingredients) ? req.body.ingredients : []), + JSON.stringify(Array.isArray(req.body.instructions) ? req.body.instructions : []), + req.body.sourceUrl || '', + shareToken, + new Date().toISOString(), + null + ); + + const savedObj = formatRecipe(db.prepare('SELECT * FROM recipes WHERE id = ?').get(newId)); + res.status(201).json(savedObj); + } catch (error) { + console.error('Error saving new recipe:', error); + res.status(500).json({ error: 'Failed to save recipe.' }); + } }); // API: Update recipe app.put('/api/recipes/:id', requireAuth, (req, res) => { - const recipes = readRecipes(); - const index = recipes.findIndex(r => r.id === req.params.id); - - if (index === -1) { - return res.status(404).json({ error: 'Recipe not found' }); + try { + const existing = db.prepare('SELECT * FROM recipes WHERE id = ?').get(req.params.id); + + if (!existing) { + return res.status(404).json({ error: 'Recipe not found' }); + } + + if (existing.userId !== req.user.id && !req.user.isRecipeManager && !req.user.isAdmin) { + return res.status(403).json({ error: 'You do not have permission to modify this recipe.' }); + } + + const isPublic = req.body.isPublic !== undefined ? (req.body.isPublic ? 1 : 0) : existing.isPublic; + + // Generate shareToken if it becomes public and doesn't have one + let shareToken = existing.shareToken; + if (isPublic === 1 && !shareToken) { + shareToken = crypto.randomBytes(16).toString('hex'); + } else if (isPublic === 0) { + shareToken = null; // Revoke shareToken if it is made private + } + + db.prepare(` + UPDATE recipes + SET title = ?, + description = ?, + ingredients = ?, + instructions = ?, + servings = ?, + prepTime = ?, + cookTime = ?, + totalTime = ?, + image = ?, + images = ?, + sourceUrl = ?, + isPublic = ?, + shareToken = ?, + updatedAt = ? + WHERE id = ? + `).run( + req.body.title || existing.title, + req.body.description !== undefined ? req.body.description : existing.description, + Array.isArray(req.body.ingredients) ? JSON.stringify(req.body.ingredients) : existing.ingredients, + Array.isArray(req.body.instructions) ? JSON.stringify(req.body.instructions) : existing.instructions, + req.body.servings !== undefined ? req.body.servings : existing.servings, + req.body.prepTime !== undefined ? req.body.prepTime : existing.prepTime, + req.body.cookTime !== undefined ? req.body.cookTime : existing.cookTime, + req.body.totalTime !== undefined ? req.body.totalTime : existing.totalTime, + req.body.image !== undefined ? req.body.image : existing.image, + Array.isArray(req.body.images) ? JSON.stringify(req.body.images) : existing.images, + req.body.sourceUrl !== undefined ? req.body.sourceUrl : existing.sourceUrl, + isPublic, + shareToken, + new Date().toISOString(), + req.params.id + ); + + const updatedObj = formatRecipe(db.prepare('SELECT * FROM recipes WHERE id = ?').get(req.params.id)); + res.json(updatedObj); + } catch (error) { + console.error('Error updating recipe:', error); + res.status(500).json({ error: 'Failed to update recipe.' }); } - - if (recipes[index].userId !== req.user.id && !req.user.isRecipeManager && !req.user.isAdmin) { - return res.status(403).json({ error: 'You do not have permission to modify this recipe.' }); - } - - const updatedRecipe = { - ...recipes[index], - title: req.body.title || recipes[index].title, - description: req.body.description !== undefined ? req.body.description : recipes[index].description, - ingredients: Array.isArray(req.body.ingredients) ? req.body.ingredients : recipes[index].ingredients, - instructions: Array.isArray(req.body.instructions) ? req.body.instructions : recipes[index].instructions, - servings: req.body.servings !== undefined ? req.body.servings : recipes[index].servings, - prepTime: req.body.prepTime !== undefined ? req.body.prepTime : recipes[index].prepTime, - cookTime: req.body.cookTime !== undefined ? req.body.cookTime : recipes[index].cookTime, - totalTime: req.body.totalTime !== undefined ? req.body.totalTime : recipes[index].totalTime, - image: req.body.image !== undefined ? req.body.image : recipes[index].image, - images: Array.isArray(req.body.images) ? req.body.images : recipes[index].images, - sourceUrl: req.body.sourceUrl !== undefined ? req.body.sourceUrl : recipes[index].sourceUrl, - isPublic: req.body.isPublic !== undefined ? req.body.isPublic : recipes[index].isPublic, - updatedAt: new Date().toISOString() - }; - - recipes[index] = updatedRecipe; - writeRecipes(recipes); - res.json(updatedRecipe); }); // API: Delete recipe app.delete('/api/recipes/:id', requireAuth, (req, res) => { - let recipes = readRecipes(); - const index = recipes.findIndex(r => r.id === req.params.id); - - if (index === -1) { - return res.status(404).json({ error: 'Recipe not found' }); + try { + const existing = db.prepare('SELECT userId FROM recipes WHERE id = ?').get(req.params.id); + + if (!existing) { + return res.status(404).json({ error: 'Recipe not found' }); + } + + if (existing.userId !== req.user.id && !req.user.isRecipeManager && !req.user.isAdmin) { + return res.status(403).json({ error: 'You do not have permission to delete this recipe.' }); + } + + db.prepare('DELETE FROM recipes WHERE id = ?').run(req.params.id); + res.json({ success: true }); + } catch (error) { + console.error('Error deleting recipe:', error); + res.status(500).json({ error: 'Failed to delete recipe.' }); } - - if (recipes[index].userId !== req.user.id && !req.user.isRecipeManager && !req.user.isAdmin) { - return res.status(403).json({ error: 'You do not have permission to delete this recipe.' }); - } - - const filtered = recipes.filter(r => r.id !== req.params.id); - writeRecipes(filtered); - res.json({ success: true }); }); // Helper: Parse recipe data from raw HTML string @@ -897,14 +1120,10 @@ function parseRecipeFromHtml(html, url) { function isBotChallengePage(html) { if (!html || typeof html !== 'string') return true; const lowerHtml = html.toLowerCase(); - // Cloudflare challenge signatures if (lowerHtml.includes('cf-browser-verification') || lowerHtml.includes('cf_chl_opt')) return true; if (lowerHtml.includes('checking your browser') && lowerHtml.includes('cloudflare')) return true; - // Akamai bot manager if (lowerHtml.includes('akamai') && lowerHtml.includes('bot manager')) return true; - // Generic CAPTCHA / challenge if (lowerHtml.includes('recaptcha') && !lowerHtml.includes('recipeingredient')) return true; - // Very short response with no recipe-related content if (html.length < 500 && !lowerHtml.includes('recipe')) return true; return false; } @@ -927,27 +1146,19 @@ async function fetchWithPuppeteer(url) { }); const page = await browser.newPage(); - - // Set a realistic viewport and user agent await page.setViewport({ width: 1920, height: 1080 }); await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'); - - // Set extra headers to appear more like a real browser await page.setExtraHTTPHeaders({ 'Accept-Language': 'en-US,en;q=0.9', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8' }); - // Navigate and wait for the page to fully load (networkidle0 waits for all requests to finish) await page.goto(url, { waitUntil: 'networkidle0', timeout: 30000 }); - // Give JS-rendered content a moment to hydrate await new Promise(resolve => setTimeout(resolve, 2000)); - - // Scroll down to trigger any lazy-loaded content await page.evaluate(() => { window.scrollTo(0, document.body.scrollHeight / 2); }); @@ -966,28 +1177,105 @@ async function fetchWithPuppeteer(url) { // API: Load System Settings app.get('/api/settings', requireAuth, (req, res) => { - const settingsFile = path.join(__dirname, 'data', 'settings.json'); try { - if (!fs.existsSync(settingsFile)) { - fs.writeFileSync(settingsFile, JSON.stringify({ geminiApiKey: '' }, null, 2)); - } - const data = fs.readFileSync(settingsFile, 'utf8'); - res.json(JSON.parse(data)); + const settings = db.prepare('SELECT geminiApiKey FROM settings LIMIT 1').get(); + res.json({ geminiApiKey: settings ? settings.geminiApiKey : '' }); } catch (e) { - res.status(500).json({ error: 'Failed to read settings file' }); + console.error('Failed to load settings:', e); + res.status(500).json({ error: 'Failed to read settings' }); } }); // API: Save System Settings app.post('/api/settings', requireAuth, (req, res) => { - const settingsFile = path.join(__dirname, 'data', 'settings.json'); const { geminiApiKey } = req.body; try { - const settings = { geminiApiKey: geminiApiKey || '' }; - fs.writeFileSync(settingsFile, JSON.stringify(settings, null, 2), 'utf8'); - res.json(settings); + db.prepare('UPDATE settings SET geminiApiKey = ?').run(geminiApiKey || ''); + res.json({ geminiApiKey: geminiApiKey || '' }); } catch (e) { - res.status(500).json({ error: 'Failed to save settings file' }); + console.error('Failed to save settings:', e); + res.status(500).json({ error: 'Failed to save settings' }); + } +}); + +// API: Sanity-checked, unauthenticated public recipe sharing with rate limiting +app.get('/api/share/recipes/:shareToken', publicShareLimiter, (req, res) => { + const shareToken = req.params.shareToken; + if (!shareToken || shareToken.trim() === '') { + return res.status(400).json({ error: 'Share token is required.' }); + } + + try { + const recipe = db.prepare('SELECT * FROM recipes WHERE shareToken = ? AND isPublic = 1').get(shareToken); + if (!recipe) { + return res.status(404).json({ error: 'Recipe not found or is set to private.' }); + } + + // Log guest visitor IP dynamically inside the background + try { + const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress; + db.prepare(` + INSERT OR IGNORE INTO recipe_shares_visits (shareToken, visitorIp, visitedAt) + VALUES (?, ?, ?) + `).run(shareToken, ip, new Date().toISOString()); + } catch (visitErr) { + console.error('Failed to log shared visitor IP:', visitErr); + } + + // Strip sensitive user identifiers and internal database keys + const sanitizedRecipe = { + title: recipe.title, + description: recipe.description, + servings: recipe.servings, + prepTime: recipe.prepTime, + cookTime: recipe.cookTime, + totalTime: recipe.totalTime, + image: recipe.image, + images: JSON.parse(recipe.images || '[]'), + ingredients: JSON.parse(recipe.ingredients || '[]'), + instructions: JSON.parse(recipe.instructions || '[]'), + sourceUrl: recipe.sourceUrl, + createdBy: recipe.createdBy, + createdAt: recipe.createdAt + }; + + res.json(sanitizedRecipe); + } catch (error) { + console.error('Error serving shared recipe:', error); + res.status(500).json({ error: 'Internal server error while retrieving shared recipe.' }); + } +}); + +// API: Revoke public recipe sharing link (requires authenticated ownership or permission) +app.post('/api/recipes/:id/revoke-share', requireAuth, (req, res) => { + try { + const recipe = db.prepare('SELECT * FROM recipes WHERE id = ?').get(req.params.id); + + if (!recipe) { + return res.status(404).json({ error: 'Recipe not found.' }); + } + + // Verify permissions: owner, recipe manager, or admin + if (recipe.userId !== req.user.id && !req.user.isRecipeManager && !req.user.isAdmin) { + return res.status(403).json({ error: 'You do not have permission to revoke this recipe share link.' }); + } + + const oldShareToken = recipe.shareToken; + + // Relational transaction to clear sharing state and purge visits data + const performRevocation = db.transaction(() => { + db.prepare('UPDATE recipes SET isPublic = 0, shareToken = NULL WHERE id = ?').run(req.params.id); + + if (oldShareToken) { + db.prepare('DELETE FROM recipe_shares_visits WHERE shareToken = ?').run(oldShareToken); + } + }); + + performRevocation(); + res.json({ success: true }); + } catch (error) { + console.error('Error revoking recipe share link:', error); + res.status(500).json({ error: 'Failed to revoke shared link.' }); } }); @@ -1024,18 +1312,16 @@ app.post('/api/scrape', requireAuth, async (req, res) => { }); html = response.data; - // Check if we got a bot challenge instead of real content if (isBotChallengePage(html)) { console.log('[Scraper] Axios response appears to be a bot challenge page, falling back to Puppeteer...'); - html = null; // Force Puppeteer fallback + html = null; } } catch (axiosError) { const status = axiosError.response ? axiosError.response.status : 0; if (status === 403 || status === 503 || status === 429) { console.log(`[Scraper] Axios blocked with HTTP ${status}, falling back to Puppeteer...`); - // Will fall through to Puppeteer below } else { - throw axiosError; // Re-throw non-blocking errors (DNS failures, timeouts, etc.) + throw axiosError; } } @@ -1054,10 +1340,7 @@ app.post('/api/scrape', requireAuth, async (req, res) => { } } - // Parse the retrieved HTML into recipe data const recipeData = parseRecipeFromHtml(html, url); - - // Include metadata about which method succeeded recipeData._scrapingMethod = usedPuppeteer ? 'puppeteer-stealth' : 'axios'; res.json(recipeData);