diff --git a/.env.example b/.env.example index 71c35ab..f0fe315 100644 --- a/.env.example +++ b/.env.example @@ -45,3 +45,9 @@ 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" +# Секрет browserless (TOKEN в его env) — на прод/staging генерировать: openssl rand -hex 24 +BROWSERLESS_TOKEN="" +PDF_MONTHLY_LIMIT="100" 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/ diff --git a/TECHNICAL.md b/TECHNICAL.md index a8d90cf..8e0d0e7 100644 --- a/TECHNICAL.md +++ b/TECHNICAL.md @@ -171,6 +171,39 @@ 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, ни на проде. + +**SSRF-защита — честно про пределы:** до рендера URL проверяется резолвом DNS (блок приватных/зарезервированных диапазонов), плюс внутри browserless страница перехватывается фильтром буквальных приватных IP и `localhost`/`*.local`/`*.internal`. Этот browser-level фильтр **не** переразрешает хостнеймы — same-hostname DNS-rebind (публичный IP на первом резолве, приватный на повторном запросе изнутри browserless) он не закрывает. Полный разбор и требование захардить сетевой egress `browserless` перед включением `TOOLBOX_VISIBLE` на проде — см. «Безопасность» и «Деплой» в [`docs/specs/20260706-clean-pdf-design.md`](docs/specs/20260706-clean-pdf-design.md). --- 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: 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/docs/specs/20260706-clean-pdf-design.md b/docs/specs/20260706-clean-pdf-design.md index 64a6c84..d2838d5 100644 --- a/docs/specs/20260706-clean-pdf-design.md +++ b/docs/specs/20260706-clean-pdf-design.md @@ -82,11 +82,12 @@ model PdfApiKey { - Только `http://` и `https://`. - Перед загрузкой — резолв DNS и блокировка приватных/зарезервированных диапазонов: localhost/127.x, 10.x, 172.16–31.x, 192.168.x, 169.254.x (метаданные облаков), ::1, fc00::/7, плюс docker-хостнеймы стенда (`db`, `app`, `browserless`). -- Внутри browserless — перехват сетевых запросов страницы с той же фильтрацией (защита от редиректов и подгрузок на внутренние адреса). +- Внутри browserless — перехват сетевых запросов страницы (`context.route`/`context.routeWebSocket`): блокирует буквальные приватные/зарезервированные IP и хосты `localhost`/`*.local`/`*.internal`. **Честно про предел этой защиты:** она не резолвит DNS заново — обычный хостнейм (не IP-литерал) проходит проверку без разрешения адреса. Значит, DNS-rebind на тот же хостнейм (первый резолв на этапе `assertPublicUrl` — публичный IP; повторный запрос со страницы внутри browserless — уже приватный IP того же имени) **не блокируется** этим browser-level фильтром. Остаточный риск закрывается на сетевом уровне — см. «Деплой». - Порт browserless наружу не публикуется, доступен только приложению по внутренней сети compose. - Куки/учётные данные пользователя на целевую страницу не передаются. - Санитайз имени файла в `Content-Disposition`. - Лимиты: 100/мес (env) + burst 5/мин на пользователя; сверху — очередь browserless (2 конкурентных рендера). +- ⚠️ Follow-up (не блокирует релиз): оба счётчика лимита считают только **успешные** генерации (`ToolUsage` пишется после успешного рендера) — неудачные попытки (таймаут, 5xx с целевого сайта, зависший рендер) лимит не расходуют. Потенциальный ограниченный self-DoS повторными запросами к тяжёлым/неотвечающим URL. Рассмотреть подсчёт попыток, а не только успехов, отдельной задачей. ## Деплой @@ -96,6 +97,8 @@ model PdfApiKey { - Стандартная схема деплоя LMS: сборка на Hetzner → `docker save | ssh | docker load` на Hoster.kz; browserless на Hoster.kz — обычный `docker pull`. - Hot-standby на Hetzner получает тот же compose (репликация БД уже покрывает `PdfApiKey` и `ToolUsage`). +> ⚠️ **Перед включением `TOOLBOX_VISIBLE` на проде обязательно захардить сетевой egress контейнера `browserless`** — заблокировать `169.254.0.0/16` (cloud-metadata) и RFC1918-диапазоны (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`) на уровне хост-файрвола или выделенной internal-only docker-сети. Именно это закрывает DNS-rebind остаточный риск, описанный выше в «Безопасность» — browser-level фильтр (`context.route`) его не закрывает, потому что не переразрешает хостнеймы. + ## Тестирование - Юнит: SSRF-валидатор (таблица адресов → допуск/блок), пайплайн Defuddle → HTML-шаблон на фикстурах. diff --git a/docs/superpowers/plans/20260706-clean-pdf.md b/docs/superpowers/plans/20260706-clean-pdf.md index 73659b7..3ae2c2e 100644 --- a/docs/superpowers/plans/20260706-clean-pdf.md +++ b/docs/superpowers/plans/20260706-clean-pdf.md @@ -1654,9 +1654,13 @@ git commit -m "Add browserless service to prod compose and document clean-pdf" 1. В `.env` на Hoster.kz добавить `BROWSERLESS_TOKEN=` (например `openssl rand -hex 24`) и при желании `PDF_MONTHLY_LIMIT`. 2. Миграция на проде: выполняется как обычно при деплое (`prisma migrate deploy` в entrypoint — проверить, что `add_pdf_api_key` применилась: `docker exec -i lms-sb-db-1 psql -U lms_user -d lms_db -c '\d "PdfApiKey"'`). 3. `docker pull ghcr.io/browserless/chromium` на Hoster.kz (интернет с сервера есть) + обновлённый compose. + +> ⚠️ **Перед шагом 4 (включением `TOOLBOX_VISIBLE=true`) обязательно захардить сетевой egress контейнера `browserless` на проде** — заблокировать `169.254.0.0/16` (cloud-metadata) и RFC1918-диапазоны (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`) на уровне хост-файрвола или выделенной internal-only docker-сети. Browser-level SSRF-фильтр (`context.route`/`context.routeWebSocket`) не резолвит DNS заново и не закрывает same-hostname DNS-rebind (публичный IP на первом резолве, приватный — на повторном запросе изнутри browserless); сетевой egress-блок — единственный слой, который закрывает этот остаточный риск. Подробности — «Безопасность»/«Деплой» в `docs/specs/20260706-clean-pdf-design.md`. + 4. Инструмент останется невидимым, пока на проде не выставлен `TOOLBOX_VISIBLE=true` — это отдельное продуктовое решение. `/api/pdf` при этом уже будет работать — это ок (доступ гейтится платным enrollment), но анонсировать до включения тулбокса не нужно. 5. Hot-standby Hetzner: подтянуть тот же compose (`/root/digital-household/lms-sb/docker-compose.prod.yml` обновится через git pull). 6. После релиза: обновить SBT-карточку `SBT/00-Стек/Сервисы/lms.md` (раздел про тулбокс + новые env) через capture-knowledge. +7. Follow-up (не блокирует релиз): месячный/burst-лимит сейчас считает только **успешные** генерации — неудачные попытки (таймаут, 5xx, зависший рендер) лимит не расходуют, потенциальный ограниченный self-DoS повторными запросами. Рассмотреть подсчёт попыток, а не только успехов, отдельной задачей. ## Самопроверка при завершении 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/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/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..b1ee081 --- /dev/null +++ b/src/app/(student)/tools/clean-pdf/ZoteroSection.tsx @@ -0,0 +1,72 @@ +"use client"; +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, baseUrl }: { apiKey: string; baseUrl: string }) { + const [key, setKey] = useState(apiKey); + const [revealed, setRevealed] = useState(false); + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(null); + + 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; + setError(null); + startTransition(async () => { + try { + const res = await regeneratePdfApiKey(); + if (res.ok && res.key) { + setKey(res.key); + setRevealed(true); + } else { + setError("Не удалось перевыпустить ключ, попробуйте ещё раз"); + } + } catch { + setError("Не удалось перевыпустить ключ, попробуйте ещё раз"); + } + }); + } + + return ( +
+

Zotero и API

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

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

+ {error &&

{error}

} +
+ +
+
НАСТРОЙКА 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..0c53054 --- /dev/null +++ b/src/app/(student)/tools/clean-pdf/page.tsx @@ -0,0 +1,48 @@ +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 { 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"; + + return ( +
+

Чистый PDF

+

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

+ +
+ +
+ +
+ +
+
+ ); +} diff --git a/src/app/api/pdf/route.ts b/src/app/api/pdf/route.ts new file mode 100644 index 0000000..4a2ba37 --- /dev/null +++ b/src/app/api/pdf/route.ts @@ -0,0 +1,84 @@ +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) { + 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 } }); + + 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) { + console.error("[clean-pdf] render", e); + return jsonError(504, "Не получилось отрендерить страницу, попробуйте позже"); + } + console.error("[clean-pdf]", e); + return jsonError(504, "Не получилось сгенерировать 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/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 }; +} 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/__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__/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/__tests__/ssrf.test.ts b/src/lib/clean-pdf/__tests__/ssrf.test.ts new file mode 100644 index 0000000..4ab54e0 --- /dev/null +++ b/src/lib/clean-pdf/__tests__/ssrf.test.ts @@ -0,0 +1,70 @@ +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", + "::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", "::ffff:808:808"]; + + 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); + }); + + 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/__tests__/template.test.ts b/src/lib/clean-pdf/__tests__/template.test.ts new file mode 100644 index 0000000..d97829c --- /dev/null +++ b/src/lib/clean-pdf/__tests__/template.test.ts @@ -0,0 +1,67 @@ +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("вырезает script и inline-обработчики из контента", () => { + const html = buildCleanHtml({ ...base, contentHtml: `

t

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