diff --git a/docs/specs/20260706-clean-pdf-design.md b/docs/specs/20260706-clean-pdf-design.md index dc14cfe..64a6c84 100644 --- a/docs/specs/20260706-clean-pdf-design.md +++ b/docs/specs/20260706-clean-pdf-design.md @@ -54,7 +54,7 @@ model PdfApiKey { - Ключ создаётся лениво при первом заходе на страницу инструмента. - Перегенерация заменяет ключ (старый перестаёт работать сразу). -- Учёт использований — существующая `ToolUsage` с `tool = "clean-pdf"`; месячный счётчик = COUNT за календарный месяц. +- Учёт использований — существующая `ToolUsage` с `tool = "clean-pdf-generate"`; месячный счётчик = COUNT за календарный месяц. Именно отдельный идентификатор: `CopyButton` на странице логирует копирования как `tool = "clean-pdf"`, и они не должны тратить лимит генераций. - Проверка платного доступа выполняется на каждый запрос → рефанд или блокировка аккаунта автоматически отключает и веб, и ключ. Источник истины один — БД LMS. ## API diff --git a/docs/superpowers/plans/20260706-clean-pdf.md b/docs/superpowers/plans/20260706-clean-pdf.md new file mode 100644 index 0000000..2efb138 --- /dev/null +++ b/docs/superpowers/plans/20260706-clean-pdf.md @@ -0,0 +1,1624 @@ +# Чистый 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`. + +--- + +### 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" +``` + +- [ ] **Step 4: Поднять и проверить browserless** + +```bash +docker compose up -d browserless && sleep 5 && curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3333/docs +``` + +Expected: `200`. Если имена env-переменных не подхватились (проверка: `docker compose logs browserless | head -30` — не должно быть предупреждений о неизвестных переменных), свериться с `http://localhost:3333/docs` и поправить. + +- [ ] **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` добавить: + +``` +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: Прогнать миграцию на dev-базе** + +```bash +docker compose up -d db && npx prisma migrate dev --name add_pdf_api_key +``` + +Expected: миграция создана и применена, Prisma Client перегенерирован (`src/generated/prisma/`). + +- [ ] **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** + +```bash +docker compose up -d browserless +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–30 сек). Если URL статьи умер — заменить на любую живую статью с habr.com в тесте. + +- [ ] **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: Живая проверка через dev-сервер** + +```bash +docker compose up -d db browserless +npm run dev & +sleep 8 +# 1) Без авторизации → 401 +curl -s -o /dev/null -w "%{http_code}\n" "http://localhost:3000/api/pdf?url=https://example.com" +# 2) С мусорным ключом → 401 +curl -s -H "Authorization: Bearer sbpdf_wrong" -o /dev/null -w "%{http_code}\n" "http://localhost:3000/api/pdf?url=https://example.com" +``` + +Expected: `401` и `401`, оба JSON. Затем взять реальный ключ (после Task 5 создать вручную: + +```bash +docker compose exec db psql -U lms_user -d lms_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" \ + "http://localhost:3000/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` открывается. Проверить SSRF: + +```bash +curl -s -H "Authorization: Bearer sbpdf_devtest123456789012345678901234" "http://localhost:3000/api/pdf?url=http://192.168.1.1/" ; echo +curl -s -H "Authorization: Bearer sbpdf_devtest123456789012345678901234" "http://localhost:3000/api/pdf?url=http://localhost:5432/"; echo +``` + +Expected: оба `{"error":"Адрес недоступен"}` (422). Остановить dev-сервер. + +- [ ] **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. +
  3. В настройках плагина нажмите «+» и создайте действие: Name — Чистый PDF, Operation — Script.
  4. +
  5. В поле Data вставьте скрипт ниже (ключ уже подставлен).
  6. +
  7. Menu Label — _чистый PDF, поставьте галочку In item Menu, нажмите Save.
  8. +
  9. Готово: правый клик на записи с URL → «_чистый PDF» — файл прикрепится к записи.
  10. +
+
+ + + +
+ ); +} +``` + +- [ ] **Step 5: Проверка сборки и тестов** + +```bash +npm run lint && npm run type-check && npm run test +``` + +Expected: PASS. + +- [ ] **Step 6: Живая проверка в браузере** + +```bash +docker compose up -d db browserless && npm run dev +``` + +Вручную (или через agent-browser в `--headed`): войти студентом с платным курсом → /tools → карточка «Чистый PDF» видна → открыть → сгенерировать PDF реальной статьи → файл скачался, счётчик увеличился → ключ показать/скопировать/перевыпустить (после перевыпуска старый ключ по curl даёт 401) → у аккаунта только с бесплатным курсом страница показывает заглушку, а /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" +``` + +--- + +## Деплой-заметки (выполняются при релизе, не частью этого плана) + +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 (ручная проверка с живой библиотекой).