From 541853bb1d4b7d4097fcd3ce7e86001f9dc064fc Mon Sep 17 00:00:00 2001 From: dmitriylaukhin Date: Mon, 6 Jul 2026 11:19:51 +0500 Subject: [PATCH 01/21] Add clean-pdf dependencies and browserless dev service --- docker-compose.yml | 10 + next.config.ts | 2 +- package-lock.json | 787 ++++++++++++++++++++++++++++++++++++++++++++- package.json | 4 + vitest.config.ts | 2 +- 5 files changed, 801 insertions(+), 4 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 6780321..319a0af 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,5 +11,15 @@ services: volumes: - postgres_data:/var/lib/postgresql + browserless: + image: ghcr.io/browserless/chromium + restart: unless-stopped + ports: + - "127.0.0.1:3333:3000" + environment: + CONCURRENT: "2" + QUEUED: "10" + TIMEOUT: "120000" + volumes: postgres_data: diff --git a/next.config.ts b/next.config.ts index d766244..4cfb7b1 100644 --- a/next.config.ts +++ b/next.config.ts @@ -3,7 +3,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { output: "standalone", transpilePackages: ["unified", "remark-parse"], - serverExternalPackages: ["@prisma/client", "@prisma/adapter-pg", "pg"], + serverExternalPackages: ["@prisma/client", "@prisma/adapter-pg", "pg", "jsdom", "playwright-core", "defuddle"], }; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index 886230b..97a4498 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,14 +28,17 @@ "better-auth": "^1.6.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "defuddle": "^0.19.1", "disposable-email-domains": "^1.0.62", "force-graph": "^1.51.4", "gray-matter": "^4.0.3", "iconv-lite": "^0.7.2", + "jsdom": "^29.1.1", "lucide-react": "^1.7.0", "next": "16.2.2", "next-themes": "^0.4.6", "pg": "^8.20.0", + "playwright-core": "^1.61.1", "react": "19.2.4", "react-dom": "19.2.4", "remark-parse": "^11.0.0", @@ -48,6 +51,7 @@ "devDependencies": { "@tailwindcss/postcss": "^4", "@types/bcryptjs": "^2.4.6", + "@types/jsdom": "^28.0.3", "@types/node": "^20", "@types/pg": "^8.20.0", "@types/react": "^19", @@ -75,6 +79,53 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "license": "MIT" + }, "node_modules/@aws-crypto/crc32": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", @@ -1391,6 +1442,18 @@ "resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.1.21.tgz", "integrity": "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==" }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -1415,6 +1478,140 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz", + "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz", + "integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@dnd-kit/accessibility": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", @@ -1673,6 +1870,23 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@floating-ui/core": { "version": "1.7.5", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", @@ -2357,6 +2571,13 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@mixmark-io/domino": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz", + "integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==", + "license": "BSD-2-Clause", + "optional": true + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -4874,6 +5095,26 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@types/jsdom": { + "version": "28.0.3", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-28.0.3.tgz", + "integrity": "sha512-/HQ2uFoetFTXuye8vzIcHw2z6Fwi7Hi/qcgC+RoS9NCyewiqxhVGqlG+ViGB6lkax481R6dmhf1I7lIGlzJStQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^8.0.0", + "undici-types": "^7.21.0" + } + }, + "node_modules/@types/jsdom/node_modules/undici-types": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.28.0.tgz", + "integrity": "sha512-LJAfY+2w6HGeT8d8J1wNQsUGUEGio6NWWpwdwurQe4f6oojzCFuGLizl1KSve4irsTxyLly1QhEeE6iapdaIvQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -4963,6 +5204,13 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -5676,6 +5924,16 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@xmldom/xmldom": { + "version": "0.9.10", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz", + "integrity": "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14.6" + } + }, "node_modules/accessor-fn": { "version": "1.5.3", "resolved": "https://registry.npmjs.org/accessor-fn/-/accessor-fn-1.5.3.tgz", @@ -6190,6 +6448,22 @@ "url": "https://github.com/Pomax/bezierjs/blob/master/FUNDING.md" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC", + "optional": true + }, "node_modules/bowser": { "version": "2.14.1", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", @@ -6511,6 +6785,15 @@ "dev": true, "license": "MIT" }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -6570,6 +6853,56 @@ "node": ">= 8" } }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "optional": true, + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "optional": true, + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssom": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", + "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", + "license": "MIT", + "optional": true + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -6799,6 +7132,19 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -6870,6 +7216,12 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -6942,6 +7294,24 @@ "integrity": "sha512-f8mefEW4WIVg4LckePx3mALjQSPQgFlg9U8yaPdlsbdYcHQyj9n2zL2LJEA52smeYxOvmd/nB7TpMtHGMTHcug==", "license": "MIT" }, + "node_modules/defuddle": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/defuddle/-/defuddle-0.19.1.tgz", + "integrity": "sha512-7e2IVQYuNncMe9Ws8KkU/KHD8H1LFfFPmdTgRVuQNgJPOeQQSqZzAhacCyNAGwg44cM2vwuCW1cy9fmbdOZ+pA==", + "license": "MIT", + "dependencies": { + "commander": "^12.1.0" + }, + "bin": { + "defuddle": "dist/cli.js" + }, + "optionalDependencies": { + "linkedom": "^0.18.12", + "mathml-to-latex": "^1.8.0", + "temml": "^0.13.3", + "turndown": "^7.2.0" + } + }, "node_modules/denque": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", @@ -7020,6 +7390,65 @@ "node": ">=0.10.0" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "optional": true, + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause", + "optional": true + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "optional": true, + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "optional": true, + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dotenv": { "version": "17.4.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz", @@ -8496,6 +8925,58 @@ "node": ">=16.9.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/html-escaper": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", + "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==", + "license": "MIT", + "optional": true + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "optional": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/http-status-codes": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz", @@ -8880,6 +9361,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "license": "MIT" + }, "node_modules/is-property": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", @@ -9103,6 +9590,55 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -9502,6 +10038,31 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/linkedom": { + "version": "0.18.12", + "resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.12.tgz", + "integrity": "sha512-jalJsOwIKuQJSeTvsgzPe9iJzyfVaEJiEXl+25EkKevsULHvMJzpNqwvj1jOESWdmgKDiXObyjOYwlUqG7wo1Q==", + "license": "ISC", + "optional": true, + "dependencies": { + "css-select": "^5.1.0", + "cssom": "^0.5.0", + "html-escaper": "^3.0.3", + "htmlparser2": "^10.0.0", + "uhyphen": "^0.2.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "canvas": ">= 2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/linkify-it": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", @@ -9645,6 +10206,16 @@ "node": ">= 0.4" } }, + "node_modules/mathml-to-latex": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/mathml-to-latex/-/mathml-to-latex-1.8.0.tgz", + "integrity": "sha512-gQ0uK3zqB8HwlfaXJkEL5rgaZNbKUiBMmBP/B/W+b+t6KcseLSuYb1b0BjLgS9ZiQa24ePkqTX8/6FaQuDL7wQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@xmldom/xmldom": "^0.9.10" + } + }, "node_modules/mdast-util-from-markdown": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", @@ -9682,6 +10253,12 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "license": "CC0-1.0" + }, "node_modules/mdurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", @@ -10397,6 +10974,19 @@ "dev": true, "license": "MIT" }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "optional": true, + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/nypm": { "version": "0.6.5", "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz", @@ -10653,6 +11243,30 @@ "node": ">=6" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -10838,6 +11452,18 @@ "pathe": "^2.0.3" } }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -11220,7 +11846,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -11400,7 +12025,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -11629,6 +12253,18 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -12201,6 +12837,12 @@ "uuid": "^10.0.0" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "license": "MIT" + }, "node_modules/tabbable": { "version": "6.4.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", @@ -12238,6 +12880,16 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/temml": { + "version": "0.13.3", + "resolved": "https://registry.npmjs.org/temml/-/temml-0.13.3.tgz", + "integrity": "sha512-GLNEdf5qBWux3adbOxFus4jlds8nCdEIkkKq99m/4GGTfqnsjlVlK/i371Ux7yYSg/WNmOyAkNT/GJlZoJ0v+w==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18.13.0" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -12319,6 +12971,24 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.4.6", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.6.tgz", + "integrity": "sha512-rbP0Gyx8b3Ae9yO//CU2wbSnQNoQ66m1nJdSbSHmnwKwzkkz/u8mERYU8T2rmlmy+bJvRNn84yNCW8gYqox44Q==", + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.6" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.6", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.6.tgz", + "integrity": "sha512-TkQNGJIhlEphpHCjKodMTSe23egUZr/g+flI2qkLgiJ/maAzSgXypSLRTNH3nCmqgayEmtcJBiLcfODSAr1xoA==", + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -12332,6 +13002,30 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/trough": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", @@ -12431,6 +13125,20 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/turndown": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.4.tgz", + "integrity": "sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@mixmark-io/domino": "^2.2.0" + }, + "engines": { + "node": ">=18", + "npm": ">=9" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -12566,6 +13274,13 @@ "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", "license": "MIT" }, + "node_modules/uhyphen": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz", + "integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==", + "license": "ISC", + "optional": true + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", @@ -12585,6 +13300,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -12978,6 +13702,50 @@ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "license": "MIT" }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -13110,6 +13878,21 @@ "node": ">=0.10.0" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "license": "MIT" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index cf6dc86..7d0a81e 100644 --- a/package.json +++ b/package.json @@ -34,14 +34,17 @@ "better-auth": "^1.6.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "defuddle": "^0.19.1", "disposable-email-domains": "^1.0.62", "force-graph": "^1.51.4", "gray-matter": "^4.0.3", "iconv-lite": "^0.7.2", + "jsdom": "^29.1.1", "lucide-react": "^1.7.0", "next": "16.2.2", "next-themes": "^0.4.6", "pg": "^8.20.0", + "playwright-core": "^1.61.1", "react": "19.2.4", "react-dom": "19.2.4", "remark-parse": "^11.0.0", @@ -54,6 +57,7 @@ "devDependencies": { "@tailwindcss/postcss": "^4", "@types/bcryptjs": "^2.4.6", + "@types/jsdom": "^28.0.3", "@types/node": "^20", "@types/pg": "^8.20.0", "@types/react": "^19", diff --git a/vitest.config.ts b/vitest.config.ts index 2efc5f1..189c88f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,6 +7,6 @@ export default defineConfig({ }, test: { environment: "node", - include: ["src/lib/tools/**/__tests__/**/*.test.ts"], + include: ["src/lib/**/__tests__/**/*.test.ts"], }, }); From f5768331cf069adf8ba4df0dca89d93ae956465d Mon Sep 17 00:00:00 2001 From: dmitriylaukhin Date: Mon, 6 Jul 2026 11:21:01 +0500 Subject: [PATCH 02/21] Ignore .superpowers SDD scratch dir --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 95b0c3a..dbd0181 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,6 @@ next-env.d.ts # agent-browser auth state lms-auth.json + +# SDD scratch +.superpowers/ From 52073fd914dbcd3429bdb440ee5b1dd92906620b Mon Sep 17 00:00:00 2001 From: dmitriylaukhin Date: Mon, 6 Jul 2026 11:25:31 +0500 Subject: [PATCH 03/21] Document clean-pdf env vars in .env.example --- .env.example | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.env.example b/.env.example index 71c35ab..1021443 100644 --- a/.env.example +++ b/.env.example @@ -45,3 +45,7 @@ WOW_MOMENT_LESSON_ID="obs-start-l2-006" # Obsidian Toolbox (/tools): "true" — показывать точки входа и роуты (staging); # не задано/иное — скрыто, /tools редиректит на /dashboard (prod, пока дорабатываем) TOOLBOX_VISIBLE="" + +# Чистый PDF (browserless на staging/prod; локально — SSH-туннель на staging) +BROWSER_WS_URL="ws://localhost:3333" +PDF_MONTHLY_LIMIT="100" From 2c619be043e8b103f88c50340ff7d3f441e45fd6 Mon Sep 17 00:00:00 2001 From: dmitriylaukhin Date: Mon, 6 Jul 2026 11:28:11 +0500 Subject: [PATCH 04/21] Add SSRF URL validator for clean-pdf --- src/lib/clean-pdf/__tests__/ssrf.test.ts | 58 +++++++++++++++++ src/lib/clean-pdf/ssrf.ts | 82 ++++++++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 src/lib/clean-pdf/__tests__/ssrf.test.ts create mode 100644 src/lib/clean-pdf/ssrf.ts diff --git a/src/lib/clean-pdf/__tests__/ssrf.test.ts b/src/lib/clean-pdf/__tests__/ssrf.test.ts new file mode 100644 index 0000000..f6fdecc --- /dev/null +++ b/src/lib/clean-pdf/__tests__/ssrf.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { BlockedUrlError, assertPublicUrl, isPrivateAddress } from "@/lib/clean-pdf/ssrf"; + +describe("isPrivateAddress", () => { + const privateIps = [ + "127.0.0.1", "0.0.0.0", "10.1.2.3", "100.64.0.1", "169.254.169.254", + "172.16.0.1", "172.31.255.255", "192.168.1.1", "192.0.0.8", "198.18.0.1", + "224.0.0.1", "255.255.255.255", + "::1", "::", "fc00::1", "fd12:3456::1", "fe80::1", "::ffff:10.0.0.1", "::ffff:127.0.0.1", + ]; + const publicIps = ["93.184.216.34", "8.8.8.8", "172.32.0.1", "2606:2800:220:1::1", "::ffff:8.8.8.8"]; + + it.each(privateIps)("блокирует %s", (ip) => expect(isPrivateAddress(ip)).toBe(true)); + it.each(publicIps)("пропускает %s", (ip) => expect(isPrivateAddress(ip)).toBe(false)); +}); + +describe("assertPublicUrl", () => { + const publicResolve = async () => [{ address: "93.184.216.34", family: 4 }]; + const privateResolve = async () => [{ address: "172.18.0.2", family: 4 }]; + const mixedResolve = async () => [ + { address: "93.184.216.34", family: 4 }, + { address: "10.0.0.5", family: 4 }, + ]; + + it("пропускает публичный https-URL", async () => { + const url = await assertPublicUrl("https://example.com/article", publicResolve); + expect(url.hostname).toBe("example.com"); + }); + + it.each([ + "file:///etc/passwd", + "ftp://example.com/x", + "chrome://settings", + "not a url", + ])("блокирует %s", async (raw) => { + await expect(assertPublicUrl(raw, publicResolve)).rejects.toThrow(BlockedUrlError); + }); + + it("блокирует localhost и .local без резолва", async () => { + await expect(assertPublicUrl("http://localhost:3000/x", publicResolve)).rejects.toThrow(BlockedUrlError); + await expect(assertPublicUrl("http://printer.local/x", publicResolve)).rejects.toThrow(BlockedUrlError); + }); + + it("блокирует литеральный приватный IP", async () => { + await expect(assertPublicUrl("http://192.168.1.1/admin", publicResolve)).rejects.toThrow(BlockedUrlError); + await expect(assertPublicUrl("http://[::1]:8080/", publicResolve)).rejects.toThrow(BlockedUrlError); + }); + + it("блокирует хост, резолвящийся в приватный IP (включая частично)", async () => { + await expect(assertPublicUrl("https://evil.example/x", privateResolve)).rejects.toThrow(BlockedUrlError); + await expect(assertPublicUrl("https://evil.example/x", mixedResolve)).rejects.toThrow(BlockedUrlError); + }); + + it("блокирует хост, который не резолвится", async () => { + const failResolve = async () => { throw new Error("ENOTFOUND"); }; + await expect(assertPublicUrl("https://nope.example/x", failResolve)).rejects.toThrow(BlockedUrlError); + }); +}); diff --git a/src/lib/clean-pdf/ssrf.ts b/src/lib/clean-pdf/ssrf.ts new file mode 100644 index 0000000..ecd01c2 --- /dev/null +++ b/src/lib/clean-pdf/ssrf.ts @@ -0,0 +1,82 @@ +import { lookup } from "node:dns/promises"; +import { isIP } from "node:net"; + +export class BlockedUrlError extends Error {} + +export type LookupFn = ( + hostname: string, + opts: { all: true }, +) => Promise<{ address: string; family: number }[]>; + +function v4ToInt(ip: string): number { + return ip.split(".").reduce((acc, o) => acc * 256 + Number(o), 0); +} + +// [начало включительно, конец включительно] в виде 32-битных чисел +const V4_PRIVATE: Array<[number, number]> = [ + ["0.0.0.0", "0.255.255.255"], // "this network" + ["10.0.0.0", "10.255.255.255"], // RFC1918 + ["100.64.0.0", "100.127.255.255"], // CGNAT + ["127.0.0.0", "127.255.255.255"], // loopback + ["169.254.0.0", "169.254.255.255"], // link-local / cloud metadata + ["172.16.0.0", "172.31.255.255"], // RFC1918 (docker-сети попадают сюда) + ["192.0.0.0", "192.0.0.255"], // IETF protocol assignments + ["192.168.0.0", "192.168.255.255"], // RFC1918 + ["198.18.0.0", "198.19.255.255"], // benchmarking + ["224.0.0.0", "255.255.255.255"], // multicast + reserved + broadcast +].map(([a, b]) => [v4ToInt(a), v4ToInt(b)] as [number, number]); + +function isPrivateV4(ip: string): boolean { + const n = v4ToInt(ip); + return V4_PRIVATE.some(([lo, hi]) => n >= lo && n <= hi); +} + +export function isPrivateAddress(ip: string): boolean { + if (isIP(ip) === 4) return isPrivateV4(ip); + if (isIP(ip) !== 6) return true; // не IP — не пропускаем + + const lower = ip.toLowerCase(); + // v4-mapped: ::ffff:10.0.0.1 + const mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/); + if (mapped) return isPrivateV4(mapped[1]); + + if (lower === "::" || lower === "::1") return true; + // fc00::/7 (ULA), fe80::/10 (link-local) + const firstGroup = parseInt(lower.split(":")[0] || "0", 16); + if (firstGroup >= 0xfc00 && firstGroup <= 0xfdff) return true; + if (firstGroup >= 0xfe80 && firstGroup <= 0xfebf) return true; + return false; +} + +export async function assertPublicUrl(raw: string, resolve: LookupFn = lookup): Promise { + let url: URL; + try { + url = new URL(raw); + } catch { + throw new BlockedUrlError("Некорректный URL"); + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new BlockedUrlError("Поддерживаются только http и https"); + } + + const hostname = url.hostname.replace(/^\[|\]$/g, ""); // [::1] → ::1 + if (hostname === "localhost" || hostname.endsWith(".local") || hostname.endsWith(".internal")) { + throw new BlockedUrlError("Адрес недоступен"); + } + + if (isIP(hostname)) { + if (isPrivateAddress(hostname)) throw new BlockedUrlError("Адрес недоступен"); + return url; + } + + let addresses: { address: string; family: number }[]; + try { + addresses = await resolve(hostname, { all: true }); + } catch { + throw new BlockedUrlError("Не удалось определить адрес сайта"); + } + if (addresses.length === 0 || addresses.some((a) => isPrivateAddress(a.address))) { + throw new BlockedUrlError("Адрес недоступен"); + } + return url; +} From 9a74339087af59f3ac5b73176f23136160b797c7 Mon Sep 17 00:00:00 2001 From: dmitriylaukhin Date: Mon, 6 Jul 2026 11:37:05 +0500 Subject: [PATCH 05/21] Block v4-mapped IPv6 literals in SSRF validator --- src/lib/clean-pdf/__tests__/ssrf.test.ts | 14 +++++- src/lib/clean-pdf/ssrf.ts | 57 +++++++++++++++++++----- 2 files changed, 60 insertions(+), 11 deletions(-) diff --git a/src/lib/clean-pdf/__tests__/ssrf.test.ts b/src/lib/clean-pdf/__tests__/ssrf.test.ts index f6fdecc..4ab54e0 100644 --- a/src/lib/clean-pdf/__tests__/ssrf.test.ts +++ b/src/lib/clean-pdf/__tests__/ssrf.test.ts @@ -7,8 +7,9 @@ describe("isPrivateAddress", () => { "172.16.0.1", "172.31.255.255", "192.168.1.1", "192.0.0.8", "198.18.0.1", "224.0.0.1", "255.255.255.255", "::1", "::", "fc00::1", "fd12:3456::1", "fe80::1", "::ffff:10.0.0.1", "::ffff:127.0.0.1", + "::ffff:7f00:1", "::ffff:a00:1", "::ffff:c0a8:101", "::ffff:a9fe:a9fe", ]; - const publicIps = ["93.184.216.34", "8.8.8.8", "172.32.0.1", "2606:2800:220:1::1", "::ffff:8.8.8.8"]; + const publicIps = ["93.184.216.34", "8.8.8.8", "172.32.0.1", "2606:2800:220:1::1", "::ffff:8.8.8.8", "::ffff:808:808"]; it.each(privateIps)("блокирует %s", (ip) => expect(isPrivateAddress(ip)).toBe(true)); it.each(publicIps)("пропускает %s", (ip) => expect(isPrivateAddress(ip)).toBe(false)); @@ -55,4 +56,15 @@ describe("assertPublicUrl", () => { const failResolve = async () => { throw new Error("ENOTFOUND"); }; await expect(assertPublicUrl("https://nope.example/x", failResolve)).rejects.toThrow(BlockedUrlError); }); + + it("блокирует v4-mapped IPv6 в bracket-нотации (SSRF-обход)", async () => { + for (const raw of [ + "http://[::ffff:127.0.0.1]/", + "http://[::ffff:10.0.0.1]/", + "http://[::ffff:169.254.169.254]/", + "http://[::ffff:192.168.1.1]/", + ]) { + await expect(assertPublicUrl(raw, publicResolve)).rejects.toThrow(BlockedUrlError); + } + }); }); diff --git a/src/lib/clean-pdf/ssrf.ts b/src/lib/clean-pdf/ssrf.ts index ecd01c2..68b5b06 100644 --- a/src/lib/clean-pdf/ssrf.ts +++ b/src/lib/clean-pdf/ssrf.ts @@ -31,20 +31,57 @@ function isPrivateV4(ip: string): boolean { return V4_PRIVATE.some(([lo, hi]) => n >= lo && n <= hi); } +/** Разворачивает IPv6 (в т.ч. сжатый `::` и v4-mapped/embedded dotted) в 8 групп по 16 бит. */ +function expandV6(ip: string): number[] | null { + let s = ip.toLowerCase(); + + // Встроенный dotted-хвост (::ffff:127.0.0.1, ::127.0.0.1) → в hex-группы + const dotted = s.match(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/); + if (dotted) { + if (isIP(dotted[1]) !== 4) return null; + const n = v4ToInt(dotted[1]); + s = s.slice(0, dotted.index) + ((n >>> 16) & 0xffff).toString(16) + ":" + (n & 0xffff).toString(16); + } + + const halves = s.split("::"); + if (halves.length > 2) return null; + const head = halves[0] ? halves[0].split(":") : []; + const tail = halves.length === 2 ? (halves[1] ? halves[1].split(":") : []) : []; + + let groups: string[]; + if (halves.length === 2) { + const missing = 8 - head.length - tail.length; + if (missing < 0) return null; + groups = [...head, ...Array(missing).fill("0"), ...tail]; + } else { + groups = head; + } + if (groups.length !== 8) return null; + + const nums = groups.map((g) => (g === "" ? 0 : parseInt(g, 16))); + if (nums.some((x) => Number.isNaN(x) || x < 0 || x > 0xffff)) return null; + return nums; +} + export function isPrivateAddress(ip: string): boolean { if (isIP(ip) === 4) return isPrivateV4(ip); if (isIP(ip) !== 6) return true; // не IP — не пропускаем - const lower = ip.toLowerCase(); - // v4-mapped: ::ffff:10.0.0.1 - const mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/); - if (mapped) return isPrivateV4(mapped[1]); + const groups = expandV6(ip); + if (!groups) return true; // не смогли разобрать v6 — не пропускаем - if (lower === "::" || lower === "::1") return true; - // fc00::/7 (ULA), fe80::/10 (link-local) - const firstGroup = parseInt(lower.split(":")[0] || "0", 16); - if (firstGroup >= 0xfc00 && firstGroup <= 0xfdff) return true; - if (firstGroup >= 0xfe80 && firstGroup <= 0xfebf) return true; + // ::/96 (v4-compatible, вкл. :: и ::1) и ::ffff:0:0/96 (v4-mapped): + // младшие 32 бита содержат IPv4 — проверяем его напрямую. + const firstFiveZero = groups.slice(0, 5).every((g) => g === 0); + if (firstFiveZero && (groups[5] === 0 || groups[5] === 0xffff)) { + const v4num = groups[6] * 0x10000 + groups[7]; + const dottedV4 = `${(v4num >>> 24) & 255}.${(v4num >>> 16) & 255}.${(v4num >>> 8) & 255}.${v4num & 255}`; + return isPrivateV4(dottedV4); + } + + const first = groups[0]; + if (first >= 0xfc00 && first <= 0xfdff) return true; // fc00::/7 (ULA) + if (first >= 0xfe80 && first <= 0xfebf) return true; // fe80::/10 (link-local) return false; } @@ -59,7 +96,7 @@ export async function assertPublicUrl(raw: string, resolve: LookupFn = lookup): throw new BlockedUrlError("Поддерживаются только http и https"); } - const hostname = url.hostname.replace(/^\[|\]$/g, ""); // [::1] → ::1 + const hostname = url.hostname.replace(/^\[|\]$/g, "").replace(/\.$/, ""); // [::1] → ::1 if (hostname === "localhost" || hostname.endsWith(".local") || hostname.endsWith(".internal")) { throw new BlockedUrlError("Адрес недоступен"); } From 6316c0999320ed860f0817d255f7d9d125894cfa Mon Sep 17 00:00:00 2001 From: dmitriylaukhin Date: Mon, 6 Jul 2026 11:40:24 +0500 Subject: [PATCH 06/21] Add PDF HTML template with typography, themes and TOC --- src/lib/clean-pdf/__tests__/template.test.ts | 59 ++++++++++ src/lib/clean-pdf/template.ts | 107 +++++++++++++++++++ 2 files changed, 166 insertions(+) create mode 100644 src/lib/clean-pdf/__tests__/template.test.ts create mode 100644 src/lib/clean-pdf/template.ts diff --git a/src/lib/clean-pdf/__tests__/template.test.ts b/src/lib/clean-pdf/__tests__/template.test.ts new file mode 100644 index 0000000..2a74221 --- /dev/null +++ b/src/lib/clean-pdf/__tests__/template.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { buildCleanHtml, safePdfFilename } from "@/lib/clean-pdf/template"; + +const base = { + title: "Заголовок статьи", + url: "https://example.com/article", + theme: "light" as const, +}; + +describe("buildCleanHtml", () => { + it("экранирует HTML в метаполях", () => { + const html = buildCleanHtml({ ...base, title: ``, contentHtml: "

ок

" }); + expect(html).not.toContain(""); + expect(html).toContain("<script>"); + }); + + it("вставляет контент и шапку", () => { + const html = buildCleanHtml({ ...base, author: "Автор", site: "Example", contentHtml: "

Текст статьи

" }); + expect(html).toContain("

Текст статьи

"); + expect(html).toContain("Заголовок статьи"); + expect(html).toContain("Автор"); + expect(html).toContain("https://example.com/article"); + }); + + it("строит оглавление при 3+ заголовках и проставляет якоря", () => { + const content = "

Один

a

Два

b

Три

c

"; + const html = buildCleanHtml({ ...base, contentHtml: content }); + expect(html).toContain("Содержание"); + expect(html).toMatch(/

Один<\/h2>/); + expect((html.match(/class="toc-item/g) ?? []).length).toBe(3); + }); + + it("не строит оглавление при <3 заголовках", () => { + const html = buildCleanHtml({ ...base, contentHtml: "

Один

a

" }); + expect(html).not.toContain("Содержание"); + }); + + it("уникализирует одинаковые якоря", () => { + const content = "

Раздел

Раздел

Раздел

"; + const html = buildCleanHtml({ ...base, contentHtml: content }); + const ids = [...html.matchAll(/

m[1]); + expect(new Set(ids).size).toBe(3); + }); + + it("переключает тёмную тему", () => { + const light = buildCleanHtml({ ...base, contentHtml: "

x

" }); + const dark = buildCleanHtml({ ...base, theme: "dark", contentHtml: "

x

" }); + expect(light).toContain('data-theme="light"'); + expect(dark).toContain('data-theme="dark"'); + }); +}); + +describe("safePdfFilename", () => { + it("убирает опасные символы и ограничивает длину", () => { + expect(safePdfFilename('Статья: как/не\\надо "делать"?')).toBe("Статья_ как_не_надо _делать_"); + expect(safePdfFilename("")).toBe("document"); + expect(safePdfFilename("a".repeat(300)).length).toBeLessThanOrEqual(140); + }); +}); diff --git a/src/lib/clean-pdf/template.ts b/src/lib/clean-pdf/template.ts new file mode 100644 index 0000000..da29cb4 --- /dev/null +++ b/src/lib/clean-pdf/template.ts @@ -0,0 +1,107 @@ +import { JSDOM } from "jsdom"; + +export interface CleanArticle { + title: string; + author?: string; + site?: string; + url: string; + contentHtml: string; + theme: "light" | "dark"; +} + +function escapeHtml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +export function safePdfFilename(title: string): string { + const cleaned = title.replace(/[\\/:"*?<>|\n\r]+/g, "_").trim().slice(0, 140); + return cleaned || "document"; +} + +interface TocEntry { id: string; text: string; level: 2 | 3 } + +/** Проставляет id заголовкам h2/h3 и возвращает контент + записи оглавления. */ +function prepareContent(contentHtml: string): { html: string; toc: TocEntry[] } { + const dom = new JSDOM(`${contentHtml}`); + const doc = dom.window.document; + const used = new Set(); + const toc: TocEntry[] = []; + + doc.querySelectorAll("h2, h3").forEach((h) => { + const text = (h.textContent ?? "").trim(); + if (!text) return; + let id = text.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "") || "section"; + let i = 2; + while (used.has(id)) id = `${id}-${i++}`; + used.add(id); + h.id = id; + toc.push({ id, text, level: h.tagName === "H2" ? 2 : 3 }); + }); + + return { html: doc.body.innerHTML, toc }; +} + +const CSS = ` + :root[data-theme="light"] { --bg: #faf7f0; --fg: #1f1d1a; --muted: #6b6459; --line: #d8d2c4; --accent: #7a2e2e; } + :root[data-theme="dark"] { --bg: #201e1b; --fg: #e8e4dc; --muted: #a39b8d; --line: #3d3a34; --accent: #d4a0a0; } + * { box-sizing: border-box; } + body { background: var(--bg); color: var(--fg); font-family: Georgia, "Times New Roman", serif; + font-size: 12.5pt; line-height: 1.65; margin: 0; } + main { max-width: 100%; } + h1 { font-size: 22pt; line-height: 1.25; margin: 0 0 6pt; } + h2 { font-size: 16pt; margin: 20pt 0 8pt; } + h3 { font-size: 13.5pt; margin: 16pt 0 6pt; } + p { margin: 0 0 9pt; } + a { color: var(--accent); text-decoration: none; } + img, video, iframe { max-width: 100%; height: auto; } + pre { background: rgba(127,127,127,.08); border: 1px solid var(--line); padding: 8pt; + overflow-x: hidden; white-space: pre-wrap; word-wrap: break-word; + font-family: "SF Mono", Menlo, Consolas, monospace; font-size: 9.5pt; } + code { font-family: "SF Mono", Menlo, Consolas, monospace; font-size: 0.9em; } + blockquote { border-left: 3px solid var(--line); margin: 0 0 9pt; padding: 2pt 0 2pt 12pt; color: var(--muted); } + table { border-collapse: collapse; width: 100%; font-size: 10.5pt; } + th, td { border: 1px solid var(--line); padding: 4pt 6pt; text-align: left; } + figure { margin: 0 0 9pt; } figcaption { color: var(--muted); font-size: 9.5pt; } + .meta { color: var(--muted); font-size: 10pt; margin-bottom: 4pt; } + .meta a { color: var(--muted); } + .head-rule { border: 0; border-top: 1px solid var(--line); margin: 12pt 0 16pt; } + .toc { border: 1px solid var(--line); padding: 10pt 14pt; margin: 0 0 16pt; } + .toc-title { font-weight: bold; margin-bottom: 6pt; } + .toc-item { display: block; margin: 2pt 0; } + .toc-item.lvl3 { padding-left: 14pt; font-size: 0.92em; } + h2, h3 { break-after: avoid; } pre, blockquote, figure, table { break-inside: avoid; } +`; + +export function buildCleanHtml(article: CleanArticle): string { + const { html, toc } = prepareContent(article.contentHtml); + const metaParts = [article.site, article.author].filter(Boolean).map((s) => escapeHtml(s!)); + + const tocHtml = toc.length >= 3 + ? `` + : ""; + + return ` + + + +${escapeHtml(article.title)} + + + +
+

${escapeHtml(article.title)}

+ ${metaParts.length ? `
${metaParts.join(" · ")}
` : ""} + +
+ ${tocHtml} + ${html} +
+ +`; +} From 1d5b4f9251d89d9dff8c471b4c734daf219352e0 Mon Sep 17 00:00:00 2001 From: dmitriylaukhin Date: Mon, 6 Jul 2026 11:46:31 +0500 Subject: [PATCH 07/21] Add API key generator and Zotero script builder --- src/lib/clean-pdf/__tests__/api-key.test.ts | 15 ++++ .../clean-pdf/__tests__/zotero-script.test.ts | 12 +++ src/lib/clean-pdf/api-key.ts | 7 ++ src/lib/clean-pdf/zotero-script.ts | 80 +++++++++++++++++++ 4 files changed, 114 insertions(+) create mode 100644 src/lib/clean-pdf/__tests__/api-key.test.ts create mode 100644 src/lib/clean-pdf/__tests__/zotero-script.test.ts create mode 100644 src/lib/clean-pdf/api-key.ts create mode 100644 src/lib/clean-pdf/zotero-script.ts diff --git a/src/lib/clean-pdf/__tests__/api-key.test.ts b/src/lib/clean-pdf/__tests__/api-key.test.ts new file mode 100644 index 0000000..1c74a96 --- /dev/null +++ b/src/lib/clean-pdf/__tests__/api-key.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { PDF_KEY_PREFIX, generatePdfKey } from "@/lib/clean-pdf/api-key"; + +describe("generatePdfKey", () => { + it("формат sbpdf_, достаточная длина", () => { + const key = generatePdfKey(); + expect(key.startsWith(PDF_KEY_PREFIX)).toBe(true); + expect(key.length).toBeGreaterThanOrEqual(PDF_KEY_PREFIX.length + 32); + expect(key.slice(PDF_KEY_PREFIX.length)).toMatch(/^[A-Za-z0-9_-]+$/); + }); + + it("две генерации различаются", () => { + expect(generatePdfKey()).not.toBe(generatePdfKey()); + }); +}); diff --git a/src/lib/clean-pdf/__tests__/zotero-script.test.ts b/src/lib/clean-pdf/__tests__/zotero-script.test.ts new file mode 100644 index 0000000..22ca45f --- /dev/null +++ b/src/lib/clean-pdf/__tests__/zotero-script.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { buildZoteroScript } from "@/lib/clean-pdf/zotero-script"; + +describe("buildZoteroScript", () => { + it("подставляет ключ и адрес школы", () => { + const s = buildZoteroScript({ apiKey: "sbpdf_test123", baseUrl: "https://school.second-brain.ru" }); + expect(s).toContain("'sbpdf_test123'"); + expect(s).toContain("'https://school.second-brain.ru'"); + expect(s).toContain("/api/pdf?url="); + expect(s).not.toContain("YOUR_KEY"); + }); +}); diff --git a/src/lib/clean-pdf/api-key.ts b/src/lib/clean-pdf/api-key.ts new file mode 100644 index 0000000..4278ce1 --- /dev/null +++ b/src/lib/clean-pdf/api-key.ts @@ -0,0 +1,7 @@ +import { randomBytes } from "node:crypto"; + +export const PDF_KEY_PREFIX = "sbpdf_"; + +export function generatePdfKey(): string { + return PDF_KEY_PREFIX + randomBytes(24).toString("base64url"); +} diff --git a/src/lib/clean-pdf/zotero-script.ts b/src/lib/clean-pdf/zotero-script.ts new file mode 100644 index 0000000..bac113b --- /dev/null +++ b/src/lib/clean-pdf/zotero-script.ts @@ -0,0 +1,80 @@ +// Адаптация скрипта Clean PDF (pdf.brainysnipe.ru/zotero-script.js) под школу. +export function buildZoteroScript({ apiKey, baseUrl }: { apiKey: string; baseUrl: string }): string { + return `// ── Настройки ────────────────────────────────────────────────────────────── +const API_KEY = '${apiKey}'; // персональный ключ из school.second-brain.ru/tools/clean-pdf +const SERVICE_URL = '${baseUrl}'; +const FORMAT = 'A4'; // 'A4' или 'Letter' +const THEME = 'light'; // 'light' или 'dark' +// ─────────────────────────────────────────────────────────────────────────── + +if (typeof item === "undefined" || !item) return; +(async function () { + let tempFilePath = null; + try { + function joinPath(dir, filename) { + if (typeof OS !== "undefined" && OS.Path && OS.Path.join) return OS.Path.join(dir, filename); + const separator = dir.includes("\\\\") ? "\\\\" : "/"; + return dir.replace(/[\\\\/]+$/, "") + separator + filename; + } + + let targetItem = item; + if (!targetItem.isRegularItem()) { + if (targetItem.isAttachment() || targetItem.isNote()) + targetItem = Zotero.Items.get(targetItem.parentID); + } + if (!targetItem?.isRegularItem()) { + Zotero.alert(null, "Чистый PDF", "Выберите обычную запись Zotero."); + return; + } + const url = targetItem.getField("url"); + if (!url) { Zotero.alert(null, "Чистый PDF", "У записи нет URL."); return; } + + const pw = new Zotero.ProgressWindow(); + pw.changeHeadline("Чистый PDF"); + const progress = new pw.ItemProgress(targetItem.getImageSrc(), targetItem.getField("title")); + pw.show(); + pw.addDescription("Генерируем PDF…"); + + const response = await Zotero.getMainWindow().fetch( + SERVICE_URL + "/api/pdf?url=" + encodeURIComponent(url) + "&format=" + FORMAT + "&theme=" + THEME, + { headers: { 'Authorization': 'Bearer ' + API_KEY } } + ); + if (!response.ok) { + const body = await response.json().catch(() => ({})); + progress.setError(); + pw.addDescription("Ошибка: " + (body.error || ("HTTP " + response.status))); + pw.startCloseTimer(8000); + return; + } + + const uint8Array = new Uint8Array(await (await response.blob()).arrayBuffer()); + if (uint8Array.length < 1000) { + progress.setError(); pw.addDescription("Пришёл пустой PDF."); pw.startCloseTimer(8000); return; + } + + const safeTitle = (targetItem.getField("title") || "Document").replace(/[\\\\/:"*?<>|]+/g, "_").slice(0, 140); + tempFilePath = joinPath(Zotero.getTempDirectory().path, safeTitle + ".pdf"); + await Zotero.getMainWindow().IOUtils.write(tempFilePath, uint8Array); + await Zotero.Attachments.importFromFile({ file: tempFilePath, parentItemID: targetItem.id, contentType: "application/pdf" }); + + for (const attId of targetItem.getAttachments()) { + const att = Zotero.Items.get(attId); + const ct = att?.attachmentContentType || ""; + if (ct === "text/html" || ct === "application/zip") await att.eraseTx(); + } + + progress.setProgress(100); + const usesCount = response.headers.get('X-Uses-Count'); + const maxUses = response.headers.get('X-Max-Uses'); + const usageInfo = (usesCount && maxUses) ? (" (" + usesCount + "/" + maxUses + " за месяц)") : ""; + pw.addDescription("PDF прикреплён." + usageInfo); + pw.startCloseTimer(4000); + } catch (e) { + Zotero.alert(null, "Ошибка", e.toString()); + } finally { + if (tempFilePath) { + await Zotero.getMainWindow().IOUtils.remove(tempFilePath, { ignoreAbsent: true }).catch(() => {}); + } + } +})();`; +} From b49680c1185f89572c1b5ba94107b81846576309 Mon Sep 17 00:00:00 2001 From: dmitriylaukhin Date: Mon, 6 Jul 2026 11:53:31 +0500 Subject: [PATCH 08/21] Add PdfApiKey model and key storage helpers Adds a Prisma model for per-student Clean PDF API keys plus a hand-written migration (no local Postgres to run `migrate dev` against). getOrCreatePdfKey/regenerateKey wrap the model with lazy-creation and rotation logic on top of generatePdfKey(). --- .../migration.sql | 14 +++++++++++ prisma/schema.prisma | 11 +++++++++ src/lib/clean-pdf/keys.ts | 23 +++++++++++++++++++ 3 files changed, 48 insertions(+) create mode 100644 prisma/migrations/20260706120000_add_pdf_api_key/migration.sql create mode 100644 src/lib/clean-pdf/keys.ts diff --git a/prisma/migrations/20260706120000_add_pdf_api_key/migration.sql b/prisma/migrations/20260706120000_add_pdf_api_key/migration.sql new file mode 100644 index 0000000..f13dc1b --- /dev/null +++ b/prisma/migrations/20260706120000_add_pdf_api_key/migration.sql @@ -0,0 +1,14 @@ +-- Чистый PDF — персональный API-ключ студента (Zotero/curl) +CREATE TABLE "PdfApiKey" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "key" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "PdfApiKey_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "PdfApiKey_userId_key" ON "PdfApiKey"("userId"); +CREATE UNIQUE INDEX "PdfApiKey_key_key" ON "PdfApiKey"("key"); + +ALTER TABLE "PdfApiKey" ADD CONSTRAINT "PdfApiKey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index cf509ea..d0b3d72 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -47,6 +47,7 @@ model User { questionMessages StudentQuestionMessage[] wrappedRuns WrappedRun[] toolUsages ToolUsage[] + pdfApiKey PdfApiKey? } // Obsidian Wrapped — агрегаты прогона (БЕЗ имён/текстов заметок; обработка волта 100% client-side) @@ -82,6 +83,16 @@ model ToolUsage { @@index([createdAt]) } +// Чистый PDF — персональный API-ключ студента (Zotero/curl) +model PdfApiKey { + id String @id @default(cuid()) + userId String @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + key String @unique // sbpdf_, показывается студенту на /tools/clean-pdf + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + model Session { id String @id @default(cuid()) userId String diff --git a/src/lib/clean-pdf/keys.ts b/src/lib/clean-pdf/keys.ts new file mode 100644 index 0000000..cf014d7 --- /dev/null +++ b/src/lib/clean-pdf/keys.ts @@ -0,0 +1,23 @@ +import { prisma } from "@/lib/prisma"; +import { generatePdfKey } from "@/lib/clean-pdf/api-key"; + +export async function getOrCreatePdfKey(userId: string): Promise { + const existing = await prisma.pdfApiKey.findUnique({ where: { userId } }); + if (existing) return existing.key; + const created = await prisma.pdfApiKey.upsert({ + where: { userId }, + create: { userId, key: generatePdfKey() }, + update: {}, + }); + return created.key; +} + +export async function regenerateKey(userId: string): Promise { + const key = generatePdfKey(); + await prisma.pdfApiKey.upsert({ + where: { userId }, + create: { userId, key }, + update: { key }, + }); + return key; +} From ef46cdbe2968a80db81f76b1602beb967792f262 Mon Sep 17 00:00:00 2001 From: dmitriylaukhin Date: Mon, 6 Jul 2026 12:00:07 +0500 Subject: [PATCH 09/21] Add paid-access and usage limit helpers for clean-pdf --- src/lib/clean-pdf/__tests__/access.test.ts | 43 ++++++++++++++++ src/lib/clean-pdf/access.ts | 60 ++++++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 src/lib/clean-pdf/__tests__/access.test.ts create mode 100644 src/lib/clean-pdf/access.ts diff --git a/src/lib/clean-pdf/__tests__/access.test.ts b/src/lib/clean-pdf/__tests__/access.test.ts new file mode 100644 index 0000000..1910569 --- /dev/null +++ b/src/lib/clean-pdf/__tests__/access.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { decidePaidAccess, monthStartUtc } from "@/lib/clean-pdf/access"; + +const now = new Date("2026-07-06T10:00:00Z"); +const freeSlug = "obsidian-start"; + +describe("decidePaidAccess", () => { + it("платный enrollment без срока — да", () => { + expect(decidePaidAccess({ role: "student", banned: false, freeSlug, now, + enrollments: [{ courseSlug: "zotero", expiresAt: null }] })).toBe(true); + }); + + it("только бесплатный курс — нет", () => { + expect(decidePaidAccess({ role: "student", banned: false, freeSlug, now, + enrollments: [{ courseSlug: "obsidian-start", expiresAt: null }] })).toBe(false); + }); + + it("без enrollments — нет", () => { + expect(decidePaidAccess({ role: "student", banned: false, freeSlug, now, enrollments: [] })).toBe(false); + }); + + it("истёкший платный — нет, живой срок — да", () => { + expect(decidePaidAccess({ role: "student", banned: false, freeSlug, now, + enrollments: [{ courseSlug: "zotero", expiresAt: new Date("2026-01-01") }] })).toBe(false); + expect(decidePaidAccess({ role: "student", banned: false, freeSlug, now, + enrollments: [{ courseSlug: "zotero", expiresAt: new Date("2027-01-01") }] })).toBe(true); + }); + + it("admin и curator — да даже без курсов", () => { + expect(decidePaidAccess({ role: "admin", banned: false, freeSlug, now, enrollments: [] })).toBe(true); + expect(decidePaidAccess({ role: "curator", banned: false, freeSlug, now, enrollments: [] })).toBe(true); + }); + + it("бан всё перекрывает", () => { + expect(decidePaidAccess({ role: "admin", banned: true, freeSlug, now, enrollments: [] })).toBe(false); + }); +}); + +describe("monthStartUtc", () => { + it("возвращает первое число месяца 00:00 UTC", () => { + expect(monthStartUtc(new Date("2026-07-06T23:59:59Z")).toISOString()).toBe("2026-07-01T00:00:00.000Z"); + }); +}); diff --git a/src/lib/clean-pdf/access.ts b/src/lib/clean-pdf/access.ts new file mode 100644 index 0000000..a56b3eb --- /dev/null +++ b/src/lib/clean-pdf/access.ts @@ -0,0 +1,60 @@ +import { prisma } from "@/lib/prisma"; + +// НЕ "clean-pdf": так CopyButton логирует копирования — они не должны тратить лимит +export const PDF_TOOL_ID = "clean-pdf-generate"; +export const BURST_LIMIT = 5; // успешных генераций в минуту +const BURST_WINDOW_MS = 60_000; + +export function decidePaidAccess(input: { + role: string; + banned: boolean; + enrollments: { courseSlug: string; expiresAt: Date | null }[]; + freeSlug: string; + now?: Date; +}): boolean { + const now = input.now ?? new Date(); + if (input.banned) return false; + if (input.role === "admin" || input.role === "curator") return true; + return input.enrollments.some( + (e) => e.courseSlug !== input.freeSlug && (e.expiresAt === null || e.expiresAt > now), + ); +} + +export async function hasPaidAccess(userId: string): Promise { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { + role: true, + banned: true, + enrollments: { select: { expiresAt: true, course: { select: { slug: true } } } }, + }, + }); + if (!user) return false; + return decidePaidAccess({ + role: user.role, + banned: user.banned ?? false, + freeSlug: process.env.FREE_COURSE_SLUG ?? "obsidian-start", + enrollments: user.enrollments.map((e) => ({ courseSlug: e.course.slug, expiresAt: e.expiresAt })), + }); +} + +export function monthStartUtc(now: Date): Date { + return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); +} + +export function getMonthlyLimit(): number { + const n = Number(process.env.PDF_MONTHLY_LIMIT); + return Number.isFinite(n) && n > 0 ? n : 100; +} + +export async function getMonthlyUsage(userId: string): Promise { + return prisma.toolUsage.count({ + where: { userId, tool: PDF_TOOL_ID, createdAt: { gte: monthStartUtc(new Date()) } }, + }); +} + +export async function getBurstUsage(userId: string): Promise { + return prisma.toolUsage.count({ + where: { userId, tool: PDF_TOOL_ID, createdAt: { gte: new Date(Date.now() - BURST_WINDOW_MS) } }, + }); +} From 89383fab1d8208973f020d04c9d3d46ca91a1349 Mon Sep 17 00:00:00 2001 From: dmitriylaukhin Date: Mon, 6 Jul 2026 12:09:28 +0500 Subject: [PATCH 10/21] Add PDF generation pipeline via browserless and Defuddle Connects to browserless over CDP (playwright-core), extracts article content with Defuddle/JSDOM, renders it through buildCleanHtml, and prints a PDF on a second page. Adds an in-browser request filter as a second line of SSRF defense against redirects to private/localhost hosts, on top of assertPublicUrl's DNS check. Integration test is gated by RUN_PDF_INTEGRATION=1 (describe.runIf) so it is skipped in a normal npm run test and only runs against a real browserless instance. --- .../clean-pdf/__tests__/generate.int.test.ts | 17 ++++ src/lib/clean-pdf/generate.ts | 99 +++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 src/lib/clean-pdf/__tests__/generate.int.test.ts create mode 100644 src/lib/clean-pdf/generate.ts diff --git a/src/lib/clean-pdf/__tests__/generate.int.test.ts b/src/lib/clean-pdf/__tests__/generate.int.test.ts new file mode 100644 index 0000000..ce79914 --- /dev/null +++ b/src/lib/clean-pdf/__tests__/generate.int.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { generateCleanPdf } from "@/lib/clean-pdf/generate"; + +const enabled = process.env.RUN_PDF_INTEGRATION === "1"; + +describe.runIf(enabled)("generateCleanPdf (integration, нужен browserless)", () => { + it("генерирует PDF реальной статьи", async () => { + const { pdf, title } = await generateCleanPdf({ + url: "https://habr.com/ru/articles/942236/", + format: "A4", + theme: "light", + }); + expect(pdf.length).toBeGreaterThan(20_000); + expect(pdf.subarray(0, 5).toString()).toBe("%PDF-"); + expect(title.length).toBeGreaterThan(3); + }, 120_000); +}); diff --git a/src/lib/clean-pdf/generate.ts b/src/lib/clean-pdf/generate.ts new file mode 100644 index 0000000..7a6c8f8 --- /dev/null +++ b/src/lib/clean-pdf/generate.ts @@ -0,0 +1,99 @@ +import { isIP } from "node:net"; +import { chromium } from "playwright-core"; +import { JSDOM } from "jsdom"; +import { Defuddle } from "defuddle/node"; +import { assertPublicUrl, isPrivateAddress } from "@/lib/clean-pdf/ssrf"; +import { buildCleanHtml } from "@/lib/clean-pdf/template"; + +export class EmptyContentError extends Error {} +export class RenderError extends Error {} + +const GOTO_TIMEOUT_MS = 30_000; +const SETTLE_TIMEOUT_MS = 10_000; +const UA = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36"; + +function browserWsUrl(): string { + const base = process.env.BROWSER_WS_URL; + if (!base) throw new RenderError("BROWSER_WS_URL не задан"); + const token = process.env.BROWSERLESS_TOKEN; + return token ? `${base}${base.includes("?") ? "&" : "?"}token=${token}` : base; +} + +export async function generateCleanPdf(opts: { + url: string; + format: "A4" | "Letter"; + theme: "light" | "dark"; +}): Promise<{ pdf: Buffer; title: string }> { + const target = await assertPublicUrl(opts.url); + + const browser = await chromium.connectOverCDP(browserWsUrl()).catch((e) => { + throw new RenderError(`Браузер недоступен: ${e.message}`); + }); + + try { + const context = await browser.newContext({ userAgent: UA, viewport: { width: 1280, height: 800 } }); + // Вторая линия SSRF-обороны: страница не может дёргать приватные адреса + // (литеральные IP и localhost; hostnames уже проверены до goto). + await context.route("**/*", (route) => { + try { + const u = new URL(route.request().url()); + const host = u.hostname.replace(/^\[|\]$/g, ""); + if ( + (u.protocol !== "http:" && u.protocol !== "https:") || + host === "localhost" || host.endsWith(".local") || host.endsWith(".internal") || + (isIP(host) !== 0 && isPrivateAddress(host)) + ) { + return route.abort(); + } + } catch { + return route.abort(); + } + return route.continue(); + }); + + const page = await context.newPage(); + await page.goto(target.href, { waitUntil: "domcontentloaded", timeout: GOTO_TIMEOUT_MS }).catch((e) => { + throw new RenderError(`Страница не открылась: ${e.message}`); + }); + await page.waitForLoadState("networkidle", { timeout: SETTLE_TIMEOUT_MS }).catch(() => {}); + + const rawHtml = await page.content(); + const pageTitle = await page.title().catch(() => ""); + + const dom = new JSDOM(rawHtml, { url: target.href }); + const article = await Defuddle(dom.window.document, target.href); + // DefuddleResponse (node_modules/defuddle/dist/types.d.ts) типизирует + // content/title/author/site/domain/wordCount как обязательные (не optional) поля — + // сам объект тоже не nullable (Defuddle() не возвращает null/undefined). + // Отсутствие контента выражается пустой строкой/нулевым wordCount, не отсутствием поля. + if (!article.content || article.wordCount < 10) { + throw new EmptyContentError("Не удалось выделить содержимое страницы"); + } + + const title = article.title || pageTitle || target.hostname; + const cleanHtml = buildCleanHtml({ + title, + author: article.author || undefined, + site: article.site || article.domain || undefined, + url: target.href, + contentHtml: article.content, + theme: opts.theme, + }); + + const pdfPage = await context.newPage(); + await pdfPage.setContent(cleanHtml, { waitUntil: "networkidle", timeout: SETTLE_TIMEOUT_MS }).catch(() => {}); + // page.pdf() в playwright-core не принимает опцию timeout (проверено по + // node_modules/playwright-core/types/types.d.ts) — брифовский вариант с + // timeout здесь не компилировался, поле убрано. + const pdf = await pdfPage.pdf({ + format: opts.format, + printBackground: true, + margin: { top: "15mm", bottom: "18mm", left: "15mm", right: "15mm" }, + }); + + return { pdf, title }; + } finally { + await browser.close().catch(() => {}); + } +} From 0c56b0b8096eedf86f505ac13b7246bf29497e92 Mon Sep 17 00:00:00 2001 From: dmitriylaukhin Date: Mon, 6 Jul 2026 12:21:24 +0500 Subject: [PATCH 11/21] Bound PDF print timeout and block WebSocket SSRF in clean-pdf --- src/lib/clean-pdf/generate.ts | 56 ++++++++++++++++++++++++++++++----- 1 file changed, 48 insertions(+), 8 deletions(-) diff --git a/src/lib/clean-pdf/generate.ts b/src/lib/clean-pdf/generate.ts index 7a6c8f8..6be925c 100644 --- a/src/lib/clean-pdf/generate.ts +++ b/src/lib/clean-pdf/generate.ts @@ -8,8 +8,23 @@ import { buildCleanHtml } from "@/lib/clean-pdf/template"; export class EmptyContentError extends Error {} export class RenderError extends Error {} +/** + * Оборачивает промис в app-level таймаут: если `p` не завершится за `ms`, + * гонка завершится отказом с RenderError, и внешний `finally` (browser.close()) + * освободит слот browserless вместо того, чтобы держать его до зависшего вызова. + */ +function withTimeout(p: Promise, ms: number, message: string): Promise { + let timer: ReturnType; + const guard = new Promise((_, reject) => { + timer = setTimeout(() => reject(new RenderError(message)), ms); + timer.unref?.(); + }); + return Promise.race([p, guard]).finally(() => clearTimeout(timer)) as Promise; +} + const GOTO_TIMEOUT_MS = 30_000; const SETTLE_TIMEOUT_MS = 10_000; +const PDF_TIMEOUT_MS = 30_000; const UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36"; @@ -38,7 +53,7 @@ export async function generateCleanPdf(opts: { await context.route("**/*", (route) => { try { const u = new URL(route.request().url()); - const host = u.hostname.replace(/^\[|\]$/g, ""); + const host = u.hostname.replace(/^\[|\]$/g, "").replace(/\.$/, ""); if ( (u.protocol !== "http:" && u.protocol !== "https:") || host === "localhost" || host.endsWith(".local") || host.endsWith(".internal") || @@ -52,6 +67,26 @@ export async function generateCleanPdf(opts: { return route.continue(); }); + // context.route НЕ перехватывает WebSocket — отдельная защита от SSRF через ws:// + await context.routeWebSocket("**/*", (ws) => { + try { + const u = new URL(ws.url()); + const host = u.hostname.replace(/^\[|\]$/g, "").replace(/\.$/, ""); + if ( + (u.protocol !== "ws:" && u.protocol !== "wss:") || + host === "localhost" || host.endsWith(".local") || host.endsWith(".internal") || + (isIP(host) !== 0 && isPrivateAddress(host)) + ) { + ws.close(); + return; + } + } catch { + ws.close(); + return; + } + ws.connectToServer(); + }); + const page = await context.newPage(); await page.goto(target.href, { waitUntil: "domcontentloaded", timeout: GOTO_TIMEOUT_MS }).catch((e) => { throw new RenderError(`Страница не открылась: ${e.message}`); @@ -84,13 +119,18 @@ export async function generateCleanPdf(opts: { const pdfPage = await context.newPage(); await pdfPage.setContent(cleanHtml, { waitUntil: "networkidle", timeout: SETTLE_TIMEOUT_MS }).catch(() => {}); // page.pdf() в playwright-core не принимает опцию timeout (проверено по - // node_modules/playwright-core/types/types.d.ts) — брифовский вариант с - // timeout здесь не компилировался, поле убрано. - const pdf = await pdfPage.pdf({ - format: opts.format, - printBackground: true, - margin: { top: "15mm", bottom: "18mm", left: "15mm", right: "15mm" }, - }); + // node_modules/playwright-core/types/types.d.ts) и не имеет implicit-бага — + // оборачиваем в app-level withTimeout, чтобы зависание всё же дошло до + // finally (browser.close()) и не держало CONCURRENT-слот browserless. + const pdf = await withTimeout( + pdfPage.pdf({ + format: opts.format, + printBackground: true, + margin: { top: "15mm", bottom: "18mm", left: "15mm", right: "15mm" }, + }), + PDF_TIMEOUT_MS, + "Печать PDF не завершилась вовремя", + ); return { pdf, title }; } finally { From c50a295290e7da4cd3b13baa5cc29fb33482edac Mon Sep 17 00:00:00 2001 From: dmitriylaukhin Date: Mon, 6 Jul 2026 12:24:23 +0500 Subject: [PATCH 12/21] Add /api/pdf route with key/session auth and limits Adds GET /api/pdf: resolves the caller via a Bearer sbpdf_ API key (PdfApiKey table) or a Better Auth session cookie, then gates on paid course access, burst/monthly usage limits, generates the PDF via generateCleanPdf, records a ToolUsage row, and streams the file with X-Uses-Count/X-Max-Uses headers. Errors map to 401/403/422/429/504 JSON responses. Whitelists /api/pdf in middleware PUBLIC_ROUTES so the route can perform its own auth instead of being redirected to /login. --- src/app/api/pdf/route.ts | 83 ++++++++++++++++++++++++++++++++++++++++ src/middleware.ts | 2 +- 2 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 src/app/api/pdf/route.ts diff --git a/src/app/api/pdf/route.ts b/src/app/api/pdf/route.ts new file mode 100644 index 0000000..a18b806 --- /dev/null +++ b/src/app/api/pdf/route.ts @@ -0,0 +1,83 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { + BURST_LIMIT, PDF_TOOL_ID, getBurstUsage, getMonthlyLimit, getMonthlyUsage, hasPaidAccess, +} from "@/lib/clean-pdf/access"; +import { BlockedUrlError } from "@/lib/clean-pdf/ssrf"; +import { EmptyContentError, RenderError, generateCleanPdf } from "@/lib/clean-pdf/generate"; +import { safePdfFilename } from "@/lib/clean-pdf/template"; +import { PDF_KEY_PREFIX } from "@/lib/clean-pdf/api-key"; + +export const dynamic = "force-dynamic"; + +const querySchema = z.object({ + url: z.string().min(1).max(2000), + format: z.enum(["A4", "Letter"]).default("A4"), + theme: z.enum(["light", "dark"]).default("light"), +}); + +function jsonError(status: number, error: string, extra?: Record) { + return NextResponse.json({ error }, { status, headers: extra }); +} + +async function resolveUserId(req: NextRequest): Promise { + const authHeader = req.headers.get("authorization"); + if (authHeader?.startsWith("Bearer ")) { + const key = authHeader.slice("Bearer ".length).trim(); + if (!key.startsWith(PDF_KEY_PREFIX)) return null; + const record = await prisma.pdfApiKey.findUnique({ where: { key }, select: { userId: true } }); + return record?.userId ?? null; + } + const session = await auth.api.getSession({ headers: req.headers }); + return session?.user.id ?? null; +} + +export async function GET(req: NextRequest) { + const userId = await resolveUserId(req); + if (!userId) return jsonError(401, "Нужен API-ключ или вход в аккаунт школы"); + + if (!(await hasPaidAccess(userId))) { + return jsonError(403, "Инструмент доступен студентам платных курсов школы"); + } + + const parsed = querySchema.safeParse(Object.fromEntries(req.nextUrl.searchParams)); + if (!parsed.success) return jsonError(422, "Проверьте параметры: url, format (A4|Letter), theme (light|dark)"); + + if ((await getBurstUsage(userId)) >= BURST_LIMIT) { + return jsonError(429, "Слишком часто: подождите минуту"); + } + const limit = getMonthlyLimit(); + const used = await getMonthlyUsage(userId); + if (used >= limit) { + return jsonError(429, `Лимит ${limit} PDF в месяц исчерпан`, { + "X-Uses-Count": String(used), "X-Max-Uses": String(limit), + }); + } + + try { + const { pdf, title } = await generateCleanPdf(parsed.data); + await prisma.toolUsage.create({ data: { userId, tool: PDF_TOOL_ID } }); + + const filename = safePdfFilename(title); + return new NextResponse(new Uint8Array(pdf), { + status: 200, + headers: { + "Content-Type": "application/pdf", + "Content-Disposition": `attachment; filename="document.pdf"; filename*=UTF-8''${encodeURIComponent(filename)}.pdf`, + "X-Uses-Count": String(used + 1), + "X-Max-Uses": String(limit), + }, + }); + } catch (e) { + if (e instanceof BlockedUrlError || e instanceof EmptyContentError) { + return jsonError(422, e.message); + } + if (e instanceof RenderError) { + return jsonError(504, "Не получилось отрендерить страницу, попробуйте позже"); + } + console.error("[clean-pdf]", e); + return jsonError(504, "Не получилось сгенерировать PDF, попробуйте позже"); + } +} diff --git a/src/middleware.ts b/src/middleware.ts index 293b790..be920db 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getSessionCookie } from "better-auth/cookies"; -const PUBLIC_ROUTES = ["/login", "/register", "/verify-email", "/forgot-password", "/reset-password", "/api/auth", "/api/register", "/api/internal", "/maintenance", "/share/wrapped"]; +const PUBLIC_ROUTES = ["/login", "/register", "/verify-email", "/forgot-password", "/reset-password", "/api/auth", "/api/register", "/api/internal", "/api/pdf", "/maintenance", "/share/wrapped"]; export function middleware(request: NextRequest) { const { pathname } = request.nextUrl; From 9ad4d762d7565a9a6cb6add1e9eec2d25d69035e Mon Sep 17 00:00:00 2001 From: dmitriylaukhin Date: Mon, 6 Jul 2026 12:39:01 +0500 Subject: [PATCH 13/21] Wrap /api/pdf handler in try/catch for JSON error contract Co-Authored-By: Claude Sonnet 5 --- src/app/api/pdf/route.ts | 43 ++++++++++++++++++++-------------------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/src/app/api/pdf/route.ts b/src/app/api/pdf/route.ts index a18b806..4a2ba37 100644 --- a/src/app/api/pdf/route.ts +++ b/src/app/api/pdf/route.ts @@ -35,28 +35,28 @@ async function resolveUserId(req: NextRequest): Promise { } export async function GET(req: NextRequest) { - const userId = await resolveUserId(req); - if (!userId) return jsonError(401, "Нужен API-ключ или вход в аккаунт школы"); - - if (!(await hasPaidAccess(userId))) { - return jsonError(403, "Инструмент доступен студентам платных курсов школы"); - } - - const parsed = querySchema.safeParse(Object.fromEntries(req.nextUrl.searchParams)); - if (!parsed.success) return jsonError(422, "Проверьте параметры: url, format (A4|Letter), theme (light|dark)"); - - if ((await getBurstUsage(userId)) >= BURST_LIMIT) { - return jsonError(429, "Слишком часто: подождите минуту"); - } - const limit = getMonthlyLimit(); - const used = await getMonthlyUsage(userId); - if (used >= limit) { - return jsonError(429, `Лимит ${limit} PDF в месяц исчерпан`, { - "X-Uses-Count": String(used), "X-Max-Uses": String(limit), - }); - } - try { + const userId = await resolveUserId(req); + if (!userId) return jsonError(401, "Нужен API-ключ или вход в аккаунт школы"); + + if (!(await hasPaidAccess(userId))) { + return jsonError(403, "Инструмент доступен студентам платных курсов школы"); + } + + const parsed = querySchema.safeParse(Object.fromEntries(req.nextUrl.searchParams)); + if (!parsed.success) return jsonError(422, "Проверьте параметры: url, format (A4|Letter), theme (light|dark)"); + + if ((await getBurstUsage(userId)) >= BURST_LIMIT) { + return jsonError(429, "Слишком часто: подождите минуту", { "X-Max-Uses": String(getMonthlyLimit()) }); + } + const limit = getMonthlyLimit(); + const used = await getMonthlyUsage(userId); + if (used >= limit) { + return jsonError(429, `Лимит ${limit} PDF в месяц исчерпан`, { + "X-Uses-Count": String(used), "X-Max-Uses": String(limit), + }); + } + const { pdf, title } = await generateCleanPdf(parsed.data); await prisma.toolUsage.create({ data: { userId, tool: PDF_TOOL_ID } }); @@ -75,6 +75,7 @@ export async function GET(req: NextRequest) { return jsonError(422, e.message); } if (e instanceof RenderError) { + console.error("[clean-pdf] render", e); return jsonError(504, "Не получилось отрендерить страницу, попробуйте позже"); } console.error("[clean-pdf]", e); From 668dd293973fdbddee771ed0f11bfd87a6f9ad4b Mon Sep 17 00:00:00 2001 From: dmitriylaukhin Date: Mon, 6 Jul 2026 12:44:29 +0500 Subject: [PATCH 14/21] Add regenerate action for clean-pdf API key --- src/lib/actions/pdf-key-actions.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 src/lib/actions/pdf-key-actions.ts diff --git a/src/lib/actions/pdf-key-actions.ts b/src/lib/actions/pdf-key-actions.ts new file mode 100644 index 0000000..504bc9d --- /dev/null +++ b/src/lib/actions/pdf-key-actions.ts @@ -0,0 +1,13 @@ +"use server"; +import { headers } from "next/headers"; +import { auth } from "@/lib/auth"; +import { hasPaidAccess } from "@/lib/clean-pdf/access"; +import { regenerateKey } from "@/lib/clean-pdf/keys"; + +export async function regeneratePdfApiKey(): Promise<{ ok: boolean; key?: string }> { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) return { ok: false }; + if (!(await hasPaidAccess(session.user.id))) return { ok: false }; + const key = await regenerateKey(session.user.id); + return { ok: true, key }; +} From 0cbea10e1a438414b413e07ba9a34757e33589c3 Mon Sep 17 00:00:00 2001 From: dmitriylaukhin Date: Mon, 6 Jul 2026 12:49:06 +0500 Subject: [PATCH 15/21] Add clean-pdf tool page with web form and Zotero section --- .../tools/clean-pdf/CleanPdfForm.tsx | 76 +++++++++++++++++++ .../tools/clean-pdf/ZoteroSection.tsx | 58 ++++++++++++++ src/app/(student)/tools/clean-pdf/page.tsx | 50 ++++++++++++ src/components/tools/ToolCard.tsx | 3 +- src/lib/tools/_shared/types.ts | 4 +- 5 files changed, 189 insertions(+), 2 deletions(-) create mode 100644 src/app/(student)/tools/clean-pdf/CleanPdfForm.tsx create mode 100644 src/app/(student)/tools/clean-pdf/ZoteroSection.tsx create mode 100644 src/app/(student)/tools/clean-pdf/page.tsx diff --git a/src/app/(student)/tools/clean-pdf/CleanPdfForm.tsx b/src/app/(student)/tools/clean-pdf/CleanPdfForm.tsx new file mode 100644 index 0000000..0a5fa35 --- /dev/null +++ b/src/app/(student)/tools/clean-pdf/CleanPdfForm.tsx @@ -0,0 +1,76 @@ +"use client"; +import { useState } from "react"; + +const field = "w-full p-2 text-sm"; +const fieldStyle = { border: "1px solid var(--border)", borderRadius: "2px", backgroundColor: "var(--background)", color: "var(--foreground)" } as const; + +export function CleanPdfForm({ initialUsed, limit }: { initialUsed: number; limit: number }) { + const [url, setUrl] = useState(""); + const [format, setFormat] = useState<"A4" | "Letter">("A4"); + const [theme, setTheme] = useState<"light" | "dark">("light"); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [used, setUsed] = useState(initialUsed); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!url.trim() || busy) return; + setBusy(true); + setError(null); + try { + const params = new URLSearchParams({ url: url.trim(), format, theme }); + const res = await fetch(`/api/pdf?${params}`); + if (!res.ok) { + const body = await res.json().catch(() => ({ error: `Ошибка ${res.status}` })); + setError(body.error ?? `Ошибка ${res.status}`); + return; + } + const usesCount = res.headers.get("X-Uses-Count"); + if (usesCount) setUsed(Number(usesCount)); + + const blob = await res.blob(); + const disposition = res.headers.get("Content-Disposition") ?? ""; + const match = disposition.match(/filename\*=UTF-8''([^;]+)/); + const filename = match ? decodeURIComponent(match[1]) : "document.pdf"; + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = filename; + a.click(); + URL.revokeObjectURL(a.href); + } catch { + setError("Сеть недоступна, попробуйте ещё раз"); + } finally { + setBusy(false); + } + } + + return ( +
+ +
+ + +
+
+ + Использовано {used} из {limit} в этом месяце +
+ {error &&

{error}

} +
+ ); +} diff --git a/src/app/(student)/tools/clean-pdf/ZoteroSection.tsx b/src/app/(student)/tools/clean-pdf/ZoteroSection.tsx new file mode 100644 index 0000000..20b4b14 --- /dev/null +++ b/src/app/(student)/tools/clean-pdf/ZoteroSection.tsx @@ -0,0 +1,58 @@ +"use client"; +import { useState, useTransition } from "react"; +import { CodeOutput } from "@/components/tools/CodeOutput"; +import { regeneratePdfApiKey } from "@/lib/actions/pdf-key-actions"; + +export function ZoteroSection({ apiKey, script, baseUrl }: { apiKey: string; script: string; baseUrl: string }) { + const [key, setKey] = useState(apiKey); + const [revealed, setRevealed] = useState(false); + const [pending, startTransition] = useTransition(); + + const shownKey = revealed ? key : key.slice(0, 8) + "…" + key.slice(-4); + const curlExample = `curl -H "Authorization: Bearer ${key}" \\\n "${baseUrl}/api/pdf?url=https://example.com/article&format=A4&theme=light" \\\n -o article.pdf`; + + function regenerate() { + if (!confirm("Старый ключ перестанет работать (в том числе в Zotero). Продолжить?")) return; + startTransition(async () => { + const res = await regeneratePdfApiKey(); + if (res.ok && res.key) { setKey(res.key); setRevealed(true); } + }); + } + + return ( +
+

Zotero и API

+ +
+
ВАШ КЛЮЧ
+
+ {shownKey} + + + +
+

+ Ключ персональный — не публикуйте его. Если ключ утёк, перевыпустите: старый сразу отключится. +

+
+ +
+
НАСТРОЙКА ZOTERO
+
    +
  1. Установите плагин Actions & Tags.
  2. +
  3. В настройках плагина нажмите «+» и создайте действие: Name — Чистый PDF, Operation — Script.
  4. +
  5. В поле Data вставьте скрипт ниже (ключ уже подставлен).
  6. +
  7. Menu Label — _чистый PDF, поставьте галочку In item Menu, нажмите Save.
  8. +
  9. Готово: правый клик на записи с URL → «_чистый PDF» — файл прикрепится к записи.
  10. +
+
+ + + +
+ ); +} diff --git a/src/app/(student)/tools/clean-pdf/page.tsx b/src/app/(student)/tools/clean-pdf/page.tsx new file mode 100644 index 0000000..2d767e9 --- /dev/null +++ b/src/app/(student)/tools/clean-pdf/page.tsx @@ -0,0 +1,50 @@ +import { headers } from "next/headers"; +import { auth } from "@/lib/auth"; +import { getMonthlyLimit, getMonthlyUsage, hasPaidAccess } from "@/lib/clean-pdf/access"; +import { getOrCreatePdfKey } from "@/lib/clean-pdf/keys"; +import { buildZoteroScript } from "@/lib/clean-pdf/zotero-script"; +import { CleanPdfForm } from "./CleanPdfForm"; +import { ZoteroSection } from "./ZoteroSection"; + +export const metadata = { title: "Чистый PDF — Obsidian Toolbox" }; + +export default async function CleanPdfToolPage() { + const session = await auth.api.getSession({ headers: await headers() }); + const userId = session?.user.id; + const paid = userId ? await hasPaidAccess(userId) : false; + + if (!userId || !paid) { + return ( +
+

Чистый PDF

+
+

+ Инструмент доступен студентам платных курсов школы. Если у вас есть купленный курс — проверьте, что вы вошли в нужный аккаунт. +

+
+
+ ); + } + + const [key, used] = await Promise.all([getOrCreatePdfKey(userId), getMonthlyUsage(userId)]); + const limit = getMonthlyLimit(); + const baseUrl = process.env.NEXT_PUBLIC_APP_URL ?? "https://school.second-brain.ru"; + const zoteroScript = buildZoteroScript({ apiKey: key, baseUrl }); + + return ( +
+

Чистый PDF

+

+ Превращает любую веб-страницу в аккуратный PDF: без рекламы, меню и мусора — только текст, картинки и оглавление. +

+ +
+ +
+ +
+ +
+
+ ); +} diff --git a/src/components/tools/ToolCard.tsx b/src/components/tools/ToolCard.tsx index 247c390..8b9ef34 100644 --- a/src/components/tools/ToolCard.tsx +++ b/src/components/tools/ToolCard.tsx @@ -1,5 +1,5 @@ import Link from "next/link"; -import { MessageSquareQuote, FileCode2, Palette, SlidersHorizontal, Table2, Database, Wrench, type LucideIcon } from "lucide-react"; +import { MessageSquareQuote, FileCode2, Palette, SlidersHorizontal, Table2, Database, FileText, Wrench, type LucideIcon } from "lucide-react"; import type { ToolMeta } from "@/lib/tools/_shared/types"; // Явная карта (не `import * as Icons`) — иначе весь набор lucide попадает в бандл. @@ -10,6 +10,7 @@ const ICONS: Record = { SlidersHorizontal, Table2, Database, + FileText, }; export function ToolCard({ tool }: { tool: ToolMeta }) { diff --git a/src/lib/tools/_shared/types.ts b/src/lib/tools/_shared/types.ts index 07be91b..09cdcc0 100644 --- a/src/lib/tools/_shared/types.ts +++ b/src/lib/tools/_shared/types.ts @@ -1,4 +1,4 @@ -export type ToolId = "callout" | "frontmatter" | "theme" | "style-settings" | "dataview" | "bases"; +export type ToolId = "callout" | "frontmatter" | "theme" | "style-settings" | "dataview" | "bases" | "clean-pdf"; export const TOOL_IDS: ToolId[] = [ "callout", @@ -7,6 +7,7 @@ export const TOOL_IDS: ToolId[] = [ "style-settings", "dataview", "bases", + "clean-pdf", ]; export interface ToolMeta { @@ -24,4 +25,5 @@ export const TOOLS: ToolMeta[] = [ { id: "style-settings", title: "Генератор Style Settings", description: "Комментарии-настройки для плагина Style Settings.", icon: "SlidersHorizontal" }, { id: "dataview", title: "Генератор Dataview", description: "Запросы DQL: таблицы, списки и задачи по заметкам.", icon: "Table2" }, { id: "bases", title: "Генератор Bases", description: "Базы Obsidian (.base): представления с фильтрами.", icon: "Database" }, + { id: "clean-pdf", title: "Чистый PDF", description: "Любая веб-страница → аккуратный PDF без рекламы и мусора. Интеграция с Zotero.", icon: "FileText" }, ]; From fd40b1e394976f49d8ead1d29931fe6abfe8d34e Mon Sep 17 00:00:00 2001 From: dmitriylaukhin Date: Mon, 6 Jul 2026 13:13:19 +0500 Subject: [PATCH 16/21] Recompute Zotero script reactively on key regenerate --- src/app/(student)/tools/clean-pdf/ZoteroSection.tsx | 4 +++- src/app/(student)/tools/clean-pdf/page.tsx | 4 +--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/app/(student)/tools/clean-pdf/ZoteroSection.tsx b/src/app/(student)/tools/clean-pdf/ZoteroSection.tsx index 20b4b14..74d9e31 100644 --- a/src/app/(student)/tools/clean-pdf/ZoteroSection.tsx +++ b/src/app/(student)/tools/clean-pdf/ZoteroSection.tsx @@ -2,14 +2,16 @@ import { useState, useTransition } from "react"; import { CodeOutput } from "@/components/tools/CodeOutput"; import { regeneratePdfApiKey } from "@/lib/actions/pdf-key-actions"; +import { buildZoteroScript } from "@/lib/clean-pdf/zotero-script"; -export function ZoteroSection({ apiKey, script, baseUrl }: { apiKey: string; script: string; baseUrl: string }) { +export function ZoteroSection({ apiKey, baseUrl }: { apiKey: string; baseUrl: string }) { const [key, setKey] = useState(apiKey); const [revealed, setRevealed] = useState(false); const [pending, startTransition] = useTransition(); const shownKey = revealed ? key : key.slice(0, 8) + "…" + key.slice(-4); const curlExample = `curl -H "Authorization: Bearer ${key}" \\\n "${baseUrl}/api/pdf?url=https://example.com/article&format=A4&theme=light" \\\n -o article.pdf`; + const script = buildZoteroScript({ apiKey: key, baseUrl }); function regenerate() { if (!confirm("Старый ключ перестанет работать (в том числе в Zotero). Продолжить?")) return; diff --git a/src/app/(student)/tools/clean-pdf/page.tsx b/src/app/(student)/tools/clean-pdf/page.tsx index 2d767e9..0c53054 100644 --- a/src/app/(student)/tools/clean-pdf/page.tsx +++ b/src/app/(student)/tools/clean-pdf/page.tsx @@ -2,7 +2,6 @@ import { headers } from "next/headers"; import { auth } from "@/lib/auth"; import { getMonthlyLimit, getMonthlyUsage, hasPaidAccess } from "@/lib/clean-pdf/access"; import { getOrCreatePdfKey } from "@/lib/clean-pdf/keys"; -import { buildZoteroScript } from "@/lib/clean-pdf/zotero-script"; import { CleanPdfForm } from "./CleanPdfForm"; import { ZoteroSection } from "./ZoteroSection"; @@ -29,7 +28,6 @@ export default async function CleanPdfToolPage() { const [key, used] = await Promise.all([getOrCreatePdfKey(userId), getMonthlyUsage(userId)]); const limit = getMonthlyLimit(); const baseUrl = process.env.NEXT_PUBLIC_APP_URL ?? "https://school.second-brain.ru"; - const zoteroScript = buildZoteroScript({ apiKey: key, baseUrl }); return (
@@ -43,7 +41,7 @@ export default async function CleanPdfToolPage() {
- +
); From f143f58ceaa8ad09f3060574244d980f14786ed8 Mon Sep 17 00:00:00 2001 From: dmitriylaukhin Date: Mon, 6 Jul 2026 13:30:24 +0500 Subject: [PATCH 17/21] Add browserless service to prod compose and document clean-pdf --- .env.example | 2 ++ TECHNICAL.md | 31 +++++++++++++++++++++++++++++++ docker-compose.prod.yml | 15 +++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/.env.example b/.env.example index 1021443..f0fe315 100644 --- a/.env.example +++ b/.env.example @@ -48,4 +48,6 @@ TOOLBOX_VISIBLE="" # Чистый PDF (browserless на staging/prod; локально — SSH-туннель на staging) BROWSER_WS_URL="ws://localhost:3333" +# Секрет browserless (TOKEN в его env) — на прод/staging генерировать: openssl rand -hex 24 +BROWSERLESS_TOKEN="" PDF_MONTHLY_LIMIT="100" diff --git a/TECHNICAL.md b/TECHNICAL.md index a8d90cf..603aad6 100644 --- a/TECHNICAL.md +++ b/TECHNICAL.md @@ -171,6 +171,37 @@ CSS-классы: `.card-aubade`, `.btn-aubade`, `.btn-aubade-accent`, `.tag-aub |---|---|---|---| | `POST` | `/api/auth/[...all]` | Better Auth handler | Все | | `POST` | `/api/admin/upload` | Загрузка файла в S3, возвращает `{ url, key }` | admin | +| `GET` | `/api/pdf` | Чистый PDF из URL (Bearer-ключ или сессия) — см. раздел ниже | платный студент, admin, curator | + +--- + +## Чистый PDF (`/tools/clean-pdf`, `/api/pdf`) + +Инструмент Obsidian Toolbox: превращает произвольный URL в чистый PDF (без рекламы и меню, с типографикой, оглавлением, A4/Letter, light/dark). Полный дизайн-документ: [`docs/specs/20260706-clean-pdf-design.md`](docs/specs/20260706-clean-pdf-design.md). + +**Пайплайн генерации:** + +``` +URL студента + → browserless (Chromium по WebSocket, playwright-core) загружает страницу + → Next.js забирает итоговый HTML + → Defuddle (JSDOM) выделяет основной контент + → HTML-шаблон (типографика, A4/Letter, light/dark, оглавление) + → Playwright page.pdf() в том же browserless + → application/pdf в ответе +``` + +Доступ: любой платный студент (есть `CourseEnrollment` вне `FREE_COURSE_SLUG`), admin/curator — без ограничений. Видимость страницы гейтится флагом `TOOLBOX_VISIBLE`, но сам `/api/pdf` работает независимо от него (доступ проверяется отдельно). Ключ для внешнего API — модель `PdfApiKey` (`sbpdf_`, ленивая генерация, регенерация инвалидирует старый). + +**Env-переменные:** + +| Переменная | Назначение | +|---|---| +| `BROWSER_WS_URL` | WebSocket-адрес browserless (`ws://browserless:3000` в compose, `ws://localhost:3333` при туннеле локально) | +| `BROWSERLESS_TOKEN` | Секрет browserless (`TOKEN` в его env) — общий и для сервиса, и для клиента в LMS | +| `PDF_MONTHLY_LIMIT` | Лимит генераций в месяц на студента (по умолчанию `100`), без пересборки | + +Контейнер `browserless` (`ghcr.io/browserless/chromium`) — внутренний, порт наружу не публикуется ни на staging, ни на проде. --- diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 1367c91..1baee72 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -13,9 +13,14 @@ services: NEXT_PUBLIC_APP_URL: "https://school.second-brain.ru" RESEND_API_KEY: "${RESEND_API_KEY}" EMAIL_FROM: "${EMAIL_FROM}" + BROWSER_WS_URL: "ws://browserless:3000" + BROWSERLESS_TOKEN: "${BROWSERLESS_TOKEN}" + PDF_MONTHLY_LIMIT: "${PDF_MONTHLY_LIMIT:-100}" depends_on: db: condition: service_healthy + browserless: + condition: service_started db: image: postgres:16-alpine @@ -32,5 +37,15 @@ services: timeout: 5s retries: 10 + browserless: + image: ghcr.io/browserless/chromium + restart: unless-stopped + environment: + TOKEN: "${BROWSERLESS_TOKEN}" + CONCURRENT: "2" + QUEUED: "10" + TIMEOUT: "120000" + mem_limit: 1g + volumes: postgres_data: From 02b16e311b9f01c3bfcd89110bb54d0ca818832a Mon Sep 17 00:00:00 2001 From: dmitriylaukhin Date: Mon, 6 Jul 2026 13:44:13 +0500 Subject: [PATCH 18/21] Make Zotero script comment dynamic and assert valid JS The generated script's setup comment hardcoded school.second-brain.ru even though baseUrl is already interpolated elsewhere in the template, so the comment would lie on any other host. Also add a regression test that the generated script parses as valid JavaScript, to catch escaping mistakes in the template literal. --- src/lib/clean-pdf/__tests__/zotero-script.test.ts | 5 +++++ src/lib/clean-pdf/zotero-script.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/lib/clean-pdf/__tests__/zotero-script.test.ts b/src/lib/clean-pdf/__tests__/zotero-script.test.ts index 22ca45f..8910d00 100644 --- a/src/lib/clean-pdf/__tests__/zotero-script.test.ts +++ b/src/lib/clean-pdf/__tests__/zotero-script.test.ts @@ -9,4 +9,9 @@ describe("buildZoteroScript", () => { expect(s).toContain("/api/pdf?url="); expect(s).not.toContain("YOUR_KEY"); }); + + it("генерирует синтаксически валидный JS", () => { + const s = buildZoteroScript({ apiKey: "sbpdf_test123", baseUrl: "https://school.second-brain.ru" }); + expect(() => new Function(s)).not.toThrow(); + }); }); diff --git a/src/lib/clean-pdf/zotero-script.ts b/src/lib/clean-pdf/zotero-script.ts index bac113b..f8c0464 100644 --- a/src/lib/clean-pdf/zotero-script.ts +++ b/src/lib/clean-pdf/zotero-script.ts @@ -1,7 +1,7 @@ // Адаптация скрипта Clean PDF (pdf.brainysnipe.ru/zotero-script.js) под школу. export function buildZoteroScript({ apiKey, baseUrl }: { apiKey: string; baseUrl: string }): string { return `// ── Настройки ────────────────────────────────────────────────────────────── -const API_KEY = '${apiKey}'; // персональный ключ из school.second-brain.ru/tools/clean-pdf +const API_KEY = '${apiKey}'; // персональный ключ из ${baseUrl}/tools/clean-pdf const SERVICE_URL = '${baseUrl}'; const FORMAT = 'A4'; // 'A4' или 'Letter' const THEME = 'light'; // 'light' или 'dark' From 1ccf994112f3b7aff0dc5dde3d9b305dffae9a59 Mon Sep 17 00:00:00 2001 From: dmitriylaukhin Date: Mon, 6 Jul 2026 13:44:41 +0500 Subject: [PATCH 19/21] Strip active content from extracted HTML before PDF print Defuddle-extracted article content is injected raw into the PDF HTML and rendered by a real browser (browserless). As defense-in-depth against a compromised or malicious source page, remove script/style/ iframe/object/embed elements and on* event-handler / javascript: href attributes from the parsed DOM before serializing it into the template. --- src/lib/clean-pdf/__tests__/template.test.ts | 8 ++++++++ src/lib/clean-pdf/template.ts | 11 +++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/lib/clean-pdf/__tests__/template.test.ts b/src/lib/clean-pdf/__tests__/template.test.ts index 2a74221..d97829c 100644 --- a/src/lib/clean-pdf/__tests__/template.test.ts +++ b/src/lib/clean-pdf/__tests__/template.test.ts @@ -42,6 +42,14 @@ describe("buildCleanHtml", () => { expect(new Set(ids).size).toBe(3); }); + it("вырезает script и inline-обработчики из контента", () => { + const html = buildCleanHtml({ ...base, contentHtml: `

t

x` }); + expect(html).not.toContain("