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 @@
Clone to My Cookbook
+
+
@@ -267,6 +269,9 @@
Original Source
+
+ 0 Visitors
+
A quick and delicious salmon dinner.
@@ -431,7 +436,6 @@
Username *
-
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
+
+
+
+
+
+
+
+ Cooked this recipe with ForkFolio!
+ ForkFolio lets you save recipes from any website instantly, organize your kitchen cookbook, scale portions, and cook step-by-step with an interactive assistant.
+
+ Build My Own Cookbook
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Scale Ingredients:
+
+
+ 1x
+
+
+
+ Start Cooking Mode
+
+
+
+
+
+
+
Ingredients
+
Check off items as you gather them
+
+
+
+
+
+
Cooking Directions
+
Track your progress step-by-step
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
STEP 1 of 5
+
Step instructions...
+
+
+
+
+
+ Prev Step
+
+
+
+
+
+ Next Step
+
+
+
+
+
+
+
+
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);