# Чистый PDF (Clean PDF) — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Инструмент «URL → чистый PDF» в Obsidian Toolbox LMS: веб-форма в кабинете, персональный API-ключ для Zotero/curl, генерация через browserless + Defuddle + Playwright. **Architecture:** Всё внутри LMS (Next.js 16). Рендер — отдельный контейнер `ghcr.io/browserless/chromium` в docker-compose, приложение подключается по CDP через `playwright-core`. Контент чистится Defuddle (JSDOM), верстается в HTML-шаблон, печатается `page.pdf()`. Ключи — модель `PdfApiKey`, учёт — существующая `ToolUsage` (`tool="clean-pdf"`). **Tech Stack:** Next.js 16.2.2 (App Router), TypeScript strict, Prisma 7, Better Auth 1.6, Tailwind v4, vitest, playwright-core, defuddle, jsdom, browserless v2. **Spec:** `docs/specs/20260706-clean-pdf-design.md` — прочитать перед началом. ## Global Constraints - Prisma 7: импорт только из `@/generated/prisma/client`, НЕ `@prisma/client`. - Better Auth (НЕ NextAuth): сессия — `auth.api.getSession({ headers: await headers() })`. - Tailwind v4: никакого `tailwind.config.ts`; стили — классы + CSS-переменные ДС-2 (`var(--foreground)` и т.п., см. существующие tool-страницы). - shadcn v4 на Base UI: нет `asChild`. - Новый публичный API-роут обязан попасть в `PUBLIC_ROUTES` в `src/middleware.ts`, иначе 307 → /login. - Тексты интерфейса — русские, без англицизмов; commit-сообщения — английские (как в истории репо). - Раздел /tools закрыт флагом `TOOLBOX_VISIBLE` (layout уже есть — ничего не менять). - Лимиты: `PDF_MONTHLY_LIMIT` (default 100), burst 5/мин, browserless `CONCURRENT=2`. - Проверки перед каждым коммитом: `npm run lint && npm run type-check && npm run test`. - Работать в ветке `feature/clean-pdf`. - **Окружение:** на Mac НЕТ Docker. Контейнеры (postgres, browserless) живут на staging-сервере Hetzner: `root@178.104.27.196`, стек `/root/digital-household/lms-staging/` (docker-compose: `lms-staging-app-1` порт 3011 → https://staging.school.second-brain.ru, `lms-staging-db-1`). Сборочный клон репо на сервере: `/root/lms-staging-build/`. Деплой staging: `bash ~/Documents/Claude/scripts/deploy-staging.sh` (перед этим в `/root/lms-staging-build` должна быть выкачана нужная ветка). Миграции применяет entrypoint контейнера (`prisma migrate deploy`) при старте. Юнит-тесты, lint, type-check, build — локально на Mac; доступ к browserless с Mac — через SSH-туннель `ssh -f -N -L 3333:localhost:3333 root@178.104.27.196`. Все шаги плана с `docker compose ...` локально НЕ выполняются — их staging-эквиваленты прописаны в задачах. --- ### Task 1: Зависимости, browserless в dev, конфиги **Files:** - Modify: `package.json` (через npm install) - Modify: `docker-compose.yml` - Modify: `next.config.ts` - Modify: `vitest.config.ts` - Modify: `.env.local` (не коммитится) **Interfaces:** - Produces: env `BROWSER_WS_URL`, `PDF_MONTHLY_LIMIT`; поднятый browserless на `ws://localhost:3333`; расширенный include vitest (`src/lib/**/__tests__/**`). - [ ] **Step 1: Создать ветку** ```bash cd ~/Documents/Claude/lms-system && git checkout -b feature/clean-pdf ``` - [ ] **Step 2: Поставить зависимости** ```bash npm install defuddle jsdom playwright-core && npm install -D @types/jsdom ``` Expected: package.json/package-lock.json обновлены, без ошибок peer-deps. - [ ] **Step 3: browserless в dev-compose (на будущее, локально не проверяется)** В `docker-compose.yml` добавить сервис (после `db`, до `volumes`): ```yaml browserless: image: ghcr.io/browserless/chromium restart: unless-stopped ports: - "127.0.0.1:3333:3000" environment: CONCURRENT: "2" QUEUED: "10" TIMEOUT: "120000" ``` На Mac Docker нет — этот файл правим для будущей локальной разработки, НЕ поднимаем. - [ ] **Step 4: browserless в staging-стек на Hetzner** На сервере в `/root/digital-household/lms-staging/docker-compose.yml` добавить сервис `browserless` (тот же блок, что в Step 3 — с `ports: "127.0.0.1:3333:3000"`, порт нужен для SSH-туннеля с Mac), а в сервис `app` — `depends_on: browserless: condition: service_started` не добавлять (staging app перезапускается деплой-скриптом, зависимость не обязательна). В `/root/digital-household/lms-staging/.env` добавить: ``` BROWSER_WS_URL=ws://browserless:3000 PDF_MONTHLY_LIMIT=100 ``` Правку compose на сервере делать так: скачать файл (`scp`), поправить локально, залить обратно — НЕ heredoc в ssh. Затем: ```bash ssh root@178.104.27.196 "cd /root/digital-household/lms-staging && docker compose up -d browserless && sleep 5 && curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3333/docs" ``` Expected: `200`. Проверить логи: `ssh root@178.104.27.196 "docker logs lms-staging-browserless-1 --tail 20"` — без ошибок конфигурации. - [ ] **Step 5: serverExternalPackages в next.config.ts** ```ts serverExternalPackages: ["@prisma/client", "@prisma/adapter-pg", "pg", "jsdom", "playwright-core", "defuddle"], ``` - [ ] **Step 6: Расширить include vitest** В `vitest.config.ts`: ```ts include: ["src/lib/**/__tests__/**/*.test.ts"], ``` - [ ] **Step 7: env для dev** В `.env.local` добавить (browserless доступен с Mac через SSH-туннель: `ssh -f -N -L 3333:localhost:3333 root@178.104.27.196`): ``` BROWSER_WS_URL=ws://localhost:3333 PDF_MONTHLY_LIMIT=100 TOOLBOX_VISIBLE=true ``` - [ ] **Step 8: Проверить, что ничего не сломалось** ```bash npm run lint && npm run type-check && npm run test ``` Expected: все существующие тесты (6 файлов tools) проходят. - [ ] **Step 9: Commit** ```bash git add package.json package-lock.json docker-compose.yml next.config.ts vitest.config.ts git commit -m "Add clean-pdf dependencies and browserless dev service" ``` --- ### Task 2: SSRF-валидатор **Files:** - Create: `src/lib/clean-pdf/ssrf.ts` - Test: `src/lib/clean-pdf/__tests__/ssrf.test.ts` **Interfaces:** - Produces: - `class BlockedUrlError extends Error` — причина отказа в `message`. - `isPrivateAddress(ip: string): boolean` — true для приватных/зарезервированных IP (v4 и v6). - `assertPublicUrl(raw: string, resolve?: LookupFn): Promise` — бросает `BlockedUrlError`, иначе возвращает распарсенный URL. `type LookupFn = (hostname: string, opts: { all: true }) => Promise<{ address: string; family: number }[]>`. - [ ] **Step 1: Write the failing test** ```ts // src/lib/clean-pdf/__tests__/ssrf.test.ts 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); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `npx vitest run src/lib/clean-pdf/__tests__/ssrf.test.ts` Expected: FAIL — модуль не существует. - [ ] **Step 3: Write minimal implementation** ```ts // src/lib/clean-pdf/ssrf.ts 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 }[]>; // [начало включительно, конец включительно] в виде 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 v4ToInt(ip: string): number { return ip.split(".").reduce((acc, o) => acc * 256 + Number(o), 0); } 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; } ``` Внимание: `V4_PRIVATE` использует функцию `v4ToInt` до её объявления в файле — это допустимо (function hoisting), но если линтер ругается, перенести объявление `v4ToInt` выше константы. - [ ] **Step 4: Run test to verify it passes** Run: `npx vitest run src/lib/clean-pdf/__tests__/ssrf.test.ts` Expected: PASS (все кейсы). - [ ] **Step 5: Commit** ```bash git add src/lib/clean-pdf && git commit -m "Add SSRF URL validator for clean-pdf" ``` --- ### Task 3: HTML-шаблон PDF (типографика, темы, оглавление) **Files:** - Create: `src/lib/clean-pdf/template.ts` - Test: `src/lib/clean-pdf/__tests__/template.test.ts` **Interfaces:** - Consumes: ничего из прошлых задач. - Produces: - `interface CleanArticle { title: string; author?: string; site?: string; url: string; contentHtml: string; theme: "light" | "dark" }` - `buildCleanHtml(article: CleanArticle): string` — полный HTML-документ для печати. - `safePdfFilename(title: string): string` — безопасное имя файла без расширения. - [ ] **Step 1: Write the failing test** ```ts // src/lib/clean-pdf/__tests__/template.test.ts 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); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `npx vitest run src/lib/clean-pdf/__tests__/template.test.ts` Expected: FAIL — модуль не существует. - [ ] **Step 3: Write implementation** ```ts // src/lib/clean-pdf/template.ts 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}
`; } ``` - [ ] **Step 4: Run test to verify it passes** Run: `npx vitest run src/lib/clean-pdf/__tests__/template.test.ts` Expected: PASS. Если тест `safePdfFilename` расходится на точной строке — поправить ожидание под фактическую замену, смысл: нет `\\ / : " * ? < > |`, не пусто, ≤140. - [ ] **Step 5: Commit** ```bash git add src/lib/clean-pdf && git commit -m "Add PDF HTML template with typography, themes and TOC" ``` --- ### Task 4: Генератор ключей и Zotero-скрипт **Files:** - Create: `src/lib/clean-pdf/api-key.ts` - Create: `src/lib/clean-pdf/zotero-script.ts` - Test: `src/lib/clean-pdf/__tests__/api-key.test.ts` - Test: `src/lib/clean-pdf/__tests__/zotero-script.test.ts` **Interfaces:** - Produces: - `PDF_KEY_PREFIX = "sbpdf_"`, `generatePdfKey(): string` - `buildZoteroScript(opts: { apiKey: string; baseUrl: string }): string` — готовый скрипт для Actions & Tags. - [ ] **Step 1: Write the failing tests** ```ts // src/lib/clean-pdf/__tests__/api-key.test.ts 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()); }); }); ``` ```ts // src/lib/clean-pdf/__tests__/zotero-script.test.ts 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"); }); }); ``` - [ ] **Step 2: Run tests to verify they fail** Run: `npx vitest run src/lib/clean-pdf/__tests__/api-key.test.ts src/lib/clean-pdf/__tests__/zotero-script.test.ts` Expected: FAIL — модули не существуют. - [ ] **Step 3: Write implementations** ```ts // src/lib/clean-pdf/api-key.ts import { randomBytes } from "node:crypto"; export const PDF_KEY_PREFIX = "sbpdf_"; export function generatePdfKey(): string { return PDF_KEY_PREFIX + randomBytes(24).toString("base64url"); } ``` ```ts // src/lib/clean-pdf/zotero-script.ts // Адаптация скрипта 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(() => {}); } } })();`; } ``` Внимание на экранирование `\\\\` внутри template literal: в итоговом скрипте должны получиться JS-регекспы с `\\` (проверить глазами вывод теста при падении). - [ ] **Step 4: Run tests to verify they pass** Run: `npx vitest run src/lib/clean-pdf/__tests__/api-key.test.ts src/lib/clean-pdf/__tests__/zotero-script.test.ts` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add src/lib/clean-pdf && git commit -m "Add API key generator and Zotero script builder" ``` --- ### Task 5: Модель PdfApiKey + миграция + хранилище ключей **Files:** - Modify: `prisma/schema.prisma` - Create: `prisma/migrations/_add_pdf_api_key/` (генерирует Prisma) - Create: `src/lib/clean-pdf/keys.ts` **Interfaces:** - Consumes: `generatePdfKey()` из Task 4. - Produces: - Prisma-модель `PdfApiKey` (`prisma.pdfApiKey`). - `getOrCreatePdfKey(userId: string): Promise` — ленивое создание. - `regenerateKey(userId: string): Promise` — замена ключа. - [ ] **Step 1: Добавить модель в schema.prisma** После модели `ToolUsage` (строка ~84): ```prisma 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 } ``` В модель `User` (в блок relations, после `toolUsages ToolUsage[]`): ```prisma pdfApiKey PdfApiKey? ``` - [ ] **Step 2: Написать миграцию вручную и перегенерировать клиент** Локальной БД нет (`prisma migrate dev` невозможен) — миграцию пишем руками по образцу существующих (`prisma/migrations/20260623120000_add_tool_usage/migration.sql`), применит её `prisma migrate deploy` в entrypoint при деплое staging/prod. Создать `prisma/migrations/20260706120000_add_pdf_api_key/migration.sql`: ```sql -- Чистый 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; ``` Затем перегенерировать клиент (БД не нужна): ```bash npx prisma generate ``` Expected: клиент в `src/generated/prisma/` обновился, `prisma.pdfApiKey` доступен в типах. - [ ] **Step 3: Написать keys.ts** ```ts // src/lib/clean-pdf/keys.ts 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; } ``` - [ ] **Step 4: Проверка типов и тестов** ```bash npm run type-check && npm run test ``` Expected: PASS (модель попала в сгенерированный клиент). - [ ] **Step 5: Commit** ```bash git add prisma src/lib/clean-pdf/keys.ts git commit -m "Add PdfApiKey model and key storage helpers" ``` (`src/generated/prisma` в .gitignore — сгенерированный клиент не коммитим; на сборке его создаёт `npx prisma generate` в Dockerfile.) --- ### Task 6: Доступ и лимиты **Files:** - Create: `src/lib/clean-pdf/access.ts` - Test: `src/lib/clean-pdf/__tests__/access.test.ts` **Interfaces:** - Produces: - `decidePaidAccess(input: { role: string; banned: boolean; enrollments: { courseSlug: string; expiresAt: Date | null }[]; freeSlug: string; now?: Date }): boolean` — чистая функция. - `hasPaidAccess(userId: string): Promise` — обёртка с Prisma. - `monthStartUtc(now: Date): Date` - `getMonthlyLimit(): number` — env `PDF_MONTHLY_LIMIT`, default 100. - `getMonthlyUsage(userId: string): Promise` — COUNT ToolUsage `tool="clean-pdf"` с начала месяца (UTC). - `getBurstUsage(userId: string): Promise` — COUNT за последние 60 секунд. - `PDF_TOOL_ID = "clean-pdf-generate"`, `BURST_LIMIT = 5`. ⚠️ Идентификатор учёта генераций — именно `"clean-pdf-generate"`, НЕ `"clean-pdf"`: на странице инструмента стоят `CodeOutput`/`CopyButton`, которые при копировании логируют `ToolUsage` с `tool="clean-pdf"` (аналитика копирований). Если считать генерации тем же значением, каждое копирование скрипта съедало бы месячный лимит. - [ ] **Step 1: Write the failing test** ```ts // src/lib/clean-pdf/__tests__/access.test.ts 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"); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `npx vitest run src/lib/clean-pdf/__tests__/access.test.ts` Expected: FAIL. - [ ] **Step 3: Write implementation** ```ts // src/lib/clean-pdf/access.ts 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) } }, }); } ``` - [ ] **Step 4: Run test to verify it passes** Run: `npx vitest run src/lib/clean-pdf/__tests__/access.test.ts` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add src/lib/clean-pdf && git commit -m "Add paid-access and usage limit helpers for clean-pdf" ``` --- ### Task 7: Пайплайн генерации PDF **Files:** - Create: `src/lib/clean-pdf/generate.ts` - Test (интеграционный, по флагу): `src/lib/clean-pdf/__tests__/generate.int.test.ts` **Interfaces:** - Consumes: `assertPublicUrl`, `isPrivateAddress`, `BlockedUrlError` (Task 2); `buildCleanHtml` (Task 3). - Produces: - `class EmptyContentError extends Error` - `class RenderError extends Error` - `generateCleanPdf(opts: { url: string; format: "A4" | "Letter"; theme: "light" | "dark" }): Promise<{ pdf: Buffer; title: string }>` - [ ] **Step 1: Write implementation** (Юнит-тестов нет — вся логика с ветвлениями вынесена в Task 2/3; этот модуль — тонкая склейка с браузером. Проверка — интеграционным тестом в Step 2.) ```ts // src/lib/clean-pdf/generate.ts 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 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"; 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); if (!article?.content || (article.wordCount ?? 0) < 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(() => {}); const pdf = await pdfPage.pdf({ format: opts.format, printBackground: true, margin: { top: "15mm", bottom: "18mm", left: "15mm", right: "15mm" }, timeout: PDF_TIMEOUT_MS, }); return { pdf, title }; } finally { await browser.close().catch(() => {}); } } ``` Примечания: - `browser.close()` при CDP-подключении закрывает сессию browserless (не убивает сам контейнер) — обязателен в `finally`, иначе течёт слот `CONCURRENT`. - Тип результата Defuddle: если TS ругается на поля (`site`/`domain`), посмотреть реальные типы в `node_modules/defuddle/dist` и поправить обращения — НЕ добавлять `any`. - `page.pdf({ outline: true })` в спеке упоминался — НЕ добавлять сейчас: закладки PDF через CDP-подключение нестабильны; видимое оглавление уже есть в шаблоне. - [ ] **Step 2: Write integration test (запускается только с флагом)** ```ts // src/lib/clean-pdf/__tests__/generate.int.test.ts 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); }); ``` - [ ] **Step 3: Run integration test (browserless на staging через туннель)** ```bash ssh -f -N -L 3333:localhost:3333 root@178.104.27.196 2>/dev/null || true RUN_PDF_INTEGRATION=1 BROWSER_WS_URL=ws://localhost:3333 npx vitest run src/lib/clean-pdf/__tests__/generate.int.test.ts ``` Expected: PASS (~10–40 сек с учётом туннеля). Если URL статьи умер — заменить на любую живую статью с habr.com в тесте. Если туннель не поднялся (`connection refused`) — проверить, что browserless на staging запущен (Task 1 Step 4). - [ ] **Step 4: Сохранить образец глазами** Временный прогон: в конце интеграционного теста можно добавить `require("fs").writeFileSync("/tmp/sample.pdf", pdf)` локально, открыть, убедиться: контент чистый, шапка с URL, поля нормальные. Строку не коммитить. - [ ] **Step 5: Убедиться, что обычный `npm run test` интеграционный тест пропускает** ```bash npm run test ``` Expected: `generate.int.test.ts` — skipped, остальные PASS. - [ ] **Step 6: Commit** ```bash git add src/lib/clean-pdf && git commit -m "Add PDF generation pipeline via browserless and Defuddle" ``` --- ### Task 8: API-роут /api/pdf + middleware **Files:** - Create: `src/app/api/pdf/route.ts` - Modify: `src/middleware.ts` (строка 4, массив `PUBLIC_ROUTES`) **Interfaces:** - Consumes: `generateCleanPdf`, `EmptyContentError`, `RenderError` (Task 7); `BlockedUrlError` (Task 2); `hasPaidAccess`, `getMonthlyUsage`, `getBurstUsage`, `getMonthlyLimit`, `BURST_LIMIT`, `PDF_TOOL_ID` (Task 6); `safePdfFilename` (Task 3); Prisma `pdfApiKey` (Task 5). - Produces: `GET /api/pdf?url&format&theme` — `application/pdf` + `X-Uses-Count`/`X-Max-Uses`, ошибки JSON `{ error }` (401/403/422/429/504). - [ ] **Step 1: Добавить роут в PUBLIC_ROUTES** В `src/middleware.ts` в массив `PUBLIC_ROUTES` добавить `"/api/pdf"`: ```ts const PUBLIC_ROUTES = ["/login", "/register", "/verify-email", "/forgot-password", "/reset-password", "/api/auth", "/api/register", "/api/internal", "/api/pdf", "/maintenance", "/share/wrapped"]; ``` - [ ] **Step 2: Написать роут** ```ts // src/app/api/pdf/route.ts 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, попробуйте позже"); } } ``` - [ ] **Step 3: Проверка типов + линт** ```bash npm run lint && npm run type-check ``` Expected: PASS. - [ ] **Step 4: Живая проверка на staging** Запушить ветку и задеплоить её на staging: ```bash git push -u origin feature/clean-pdf ssh root@178.104.27.196 "cd /root/lms-staging-build && git fetch && git checkout feature/clean-pdf && git pull origin feature/clean-pdf" bash ~/Documents/Claude/scripts/deploy-staging.sh ``` Expected: деплой зелёный (`✅ Staging OK`); в логах старта `Running database migrations...` без ошибок — миграция `add_pdf_api_key` применилась. ```bash BASE=https://staging.school.second-brain.ru # 1) Без авторизации → 401 curl -s -o /dev/null -w "%{http_code}\n" "$BASE/api/pdf?url=https://example.com" # 2) С мусорным ключом → 401 curl -s -H "Authorization: Bearer sbpdf_wrong" -o /dev/null -w "%{http_code}\n" "$BASE/api/pdf?url=https://example.com" ``` Expected: `401` и `401`, оба JSON. Затем вставить тестовый ключ админу в staging-БД: ```bash ssh root@178.104.27.196 "docker exec -i lms-staging-db-1 psql -U lms_staging_user -d lms_staging_db -c \"INSERT INTO \\\"PdfApiKey\\\" (id, \\\"userId\\\", key, \\\"createdAt\\\", \\\"updatedAt\\\") SELECT 'pdfkey-test-1', id, 'sbpdf_devtest123456789012345678901234', now(), now() FROM \\\"User\\\" WHERE role='admin' LIMIT 1;\"" curl -s -D - -H "Authorization: Bearer sbpdf_devtest123456789012345678901234" \ "$BASE/api/pdf?url=https://habr.com/ru/articles/942236/" -o /tmp/api-test.pdf | head -15 ``` Expected: `200`, `content-type: application/pdf`, заголовки `X-Uses-Count: 1`, `X-Max-Uses: 100`; `/tmp/api-test.pdf` открывается (`file /tmp/api-test.pdf` → PDF). Если экранирование psql через ssh мешает — положить SQL в файл и передать через stdin: `ssh root@178.104.27.196 "docker exec -i lms-staging-db-1 psql -U lms_staging_user -d lms_staging_db" < /tmp/insert-key.sql`. Проверить SSRF: ```bash curl -s -H "Authorization: Bearer sbpdf_devtest123456789012345678901234" "$BASE/api/pdf?url=http://192.168.1.1/" ; echo curl -s -H "Authorization: Bearer sbpdf_devtest123456789012345678901234" "$BASE/api/pdf?url=http://localhost:5432/"; echo ``` Expected: оба `{"error":"Адрес недоступен"}` (422). - [ ] **Step 5: Commit** ```bash git add src/app/api/pdf src/middleware.ts git commit -m "Add /api/pdf route with key/session auth and limits" ``` --- ### Task 9: Server action перегенерации ключа **Files:** - Create: `src/lib/actions/pdf-key-actions.ts` **Interfaces:** - Consumes: `regenerateKey` (Task 5), `hasPaidAccess` (Task 6). - Produces: `regeneratePdfApiKey(): Promise<{ ok: boolean; key?: string }>` — server action для клиентского компонента. - [ ] **Step 1: Написать action** По образцу `src/lib/actions/tool-usage.ts` (auth-first): ```ts // src/lib/actions/pdf-key-actions.ts "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 }; } ``` - [ ] **Step 2: Проверка** ```bash npm run lint && npm run type-check ``` Expected: PASS. - [ ] **Step 3: Commit** ```bash git add src/lib/actions/pdf-key-actions.ts && git commit -m "Add regenerate action for clean-pdf API key" ``` --- ### Task 10: Страница /tools/clean-pdf **Files:** - Modify: `src/lib/tools/_shared/types.ts` (ToolId, TOOL_IDS, TOOLS) - Modify: `src/components/tools/ToolCard.tsx` (карта иконок) - Create: `src/app/(student)/tools/clean-pdf/page.tsx` - Create: `src/app/(student)/tools/clean-pdf/CleanPdfForm.tsx` - Create: `src/app/(student)/tools/clean-pdf/ZoteroSection.tsx` **Interfaces:** - Consumes: `getOrCreatePdfKey` (Task 5); `hasPaidAccess`, `getMonthlyUsage`, `getMonthlyLimit` (Task 6); `buildZoteroScript` (Task 4); `regeneratePdfApiKey` (Task 9); компоненты `CodeOutput` (`{ code, tool, label }`), `CopyButton` (`{ text, tool }`). - Produces: карточка «Чистый PDF» на /tools и рабочая страница /tools/clean-pdf. - [ ] **Step 1: Зарегистрировать инструмент** В `src/lib/tools/_shared/types.ts`: ```ts export type ToolId = "callout" | "frontmatter" | "theme" | "style-settings" | "dataview" | "bases" | "clean-pdf"; ``` В `TOOL_IDS` добавить `"clean-pdf"`. В `TOOLS` добавить: ```ts { id: "clean-pdf", title: "Чистый PDF", description: "Любая веб-страница → аккуратный PDF без рекламы и мусора. Интеграция с Zotero.", icon: "FileText" }, ``` В `src/components/tools/ToolCard.tsx` добавить `FileText` в импорт из lucide-react и в карту `ICONS`. - [ ] **Step 2: Страница (server component)** ```tsx // src/app/(student)/tools/clean-pdf/page.tsx 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: без рекламы, меню и мусора — только текст, картинки и оглавление.

); } ``` - [ ] **Step 3: Форма (client component)** ```tsx // src/app/(student)/tools/clean-pdf/CleanPdfForm.tsx "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}

}
); } ``` Классы `btn-aubade`, `card-aubade` и переменная `--destructive` существуют в `src/app/globals.css` (проверено) — использовать их. - [ ] **Step 4: Секция Zotero (client component)** ```tsx // src/app/(student)/tools/clean-pdf/ZoteroSection.tsx "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. В настройках плагина нажмите «+» и создайте действие: Name — Чистый PDF, Operation — Script.
  3. В поле Data вставьте скрипт ниже (ключ уже подставлен).
  4. Menu Label — _чистый PDF, поставьте галочку In item Menu, нажмите Save.
  5. Готово: правый клик на записи с URL → «_чистый PDF» — файл прикрепится к записи.
); } ``` - [ ] **Step 5: Проверка сборки и тестов** ```bash npm run lint && npm run type-check && npm run test ``` Expected: PASS. - [ ] **Step 6: Живая проверка в браузере (staging)** Задеплоить текущее состояние ветки на staging (как в Task 8 Step 4: push → checkout ветки в `/root/lms-staging-build` → `deploy-staging.sh`). Через agent-browser в `--headed` (или вручную) на `https://staging.school.second-brain.ru`: войти админом (креды — memory `reference_lms_admin_credentials.md`; staging-БД содержит копию пользователей) → /tools → карточка «Чистый PDF» видна → открыть → сгенерировать PDF реальной статьи → файл скачался, счётчик увеличился → ключ показать/скопировать/перевыпустить (после перевыпуска старый ключ по curl даёт 401). Проверка заглушки для бесплатного аккаунта: найти в staging-БД пользователя без платных enrollment (или временно создать) и убедиться, что страница показывает заглушку, а /api/pdf с его сессией отдаёт 403. - [ ] **Step 7: Commit** ```bash git add src/lib/tools/_shared/types.ts src/components/tools/ToolCard.tsx "src/app/(student)/tools/clean-pdf" git commit -m "Add clean-pdf tool page with web form and Zotero section" ``` --- ### Task 11: Прод-конфигурация и документация **Files:** - Modify: `docker-compose.prod.yml` - Modify: `TECHNICAL.md` (раздел про инструменты/окружение) **Interfaces:** - Consumes: всё предыдущее. - Produces: готовый к деплою compose; задокументированные env. - [ ] **Step 1: browserless в прод-compose** В `docker-compose.prod.yml`: добавить сервис `browserless` (БЕЗ ports — только внутренняя сеть) и env для `app`: ```yaml app: # ... существующие поля без изменений ... environment: # ... существующие переменные ... 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 browserless: image: ghcr.io/browserless/chromium restart: unless-stopped environment: TOKEN: "${BROWSERLESS_TOKEN}" CONCURRENT: "2" QUEUED: "10" TIMEOUT: "120000" mem_limit: 1g ``` - [ ] **Step 2: Задокументировать** В `TECHNICAL.md` добавить короткий раздел «Чистый PDF»: пайплайн (browserless → Defuddle → шаблон → pdf), env-переменные (`BROWSER_WS_URL`, `BROWSERLESS_TOKEN`, `PDF_MONTHLY_LIMIT`), ссылка на спеку `docs/specs/20260706-clean-pdf-design.md`. - [ ] **Step 3: Полная проверка** ```bash npm run lint && npm run type-check && npm run test && npm run build ``` Expected: всё PASS, build без ошибок (проверяет, что jsdom/playwright-core не ломают standalone-сборку). - [ ] **Step 4: Commit** ```bash git add docker-compose.prod.yml TECHNICAL.md git commit -m "Add browserless service to prod compose and document clean-pdf" ``` --- ## Деплой-заметки (выполняются при релизе, не частью этого плана) 0. Staging уже получил browserless и env в Task 1 — на релизе речь только о проде (Hoster.kz). 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` — это отдельное продуктовое решение. `/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. ## Самопроверка при завершении - `npm run lint && npm run type-check && npm run test && npm run build` — зелёные. - Интеграционный тест: `RUN_PDF_INTEGRATION=1 BROWSER_WS_URL=ws://localhost:3333 npx vitest run src/lib/clean-pdf/__tests__/generate.int.test.ts` — PASS. - SSRF-проверки из Task 8 Step 4 — 422. - Скрипт Zotero из блока на странице реально работает в Zotero 7 (ручная проверка с живой библиотекой).