Files
admins 4734b99bea Document DNS-rebind residual and prod-enablement egress hardening
The security docs claimed the in-browser SSRF filter (context.route/
routeWebSocket) re-applies "the same filtering" as the pre-fetch DNS
check. That's inaccurate for hostnames: the browser-level filter only
blocks literal private IPs and localhost/.local/.internal suffixes —
it never re-resolves hostnames, so a same-hostname DNS-rebind (public
IP on first resolve, private IP on a later request from inside
browserless) is not closed at that layer. Correct the wording in the
design spec and TECHNICAL.md, and add a prominent note to both the
spec's deploy section and the plan's deploy notes: before flipping
TOOLBOX_VISIBLE on prod, harden the browserless container's network
egress (block 169.254.0.0/16 and RFC1918 ranges via host firewall or
a dedicated internal docker network) to close the residual at the
network layer. Also note that per-user limits currently count only
successful generations — failed renders are uncapped, a bounded
self-DoS risk worth a follow-up.
2026-07-06 13:47:00 +05:00

1671 lines
74 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Чистый 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<URL>` — бросает `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<URL> {
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: `<script>alert(1)</script>`, contentHtml: "<p>ок</p>" });
expect(html).not.toContain("<script>alert(1)</script>");
expect(html).toContain("&lt;script&gt;");
});
it("вставляет контент и шапку", () => {
const html = buildCleanHtml({ ...base, author: "Автор", site: "Example", contentHtml: "<p>Текст статьи</p>" });
expect(html).toContain("<p>Текст статьи</p>");
expect(html).toContain("Заголовок статьи");
expect(html).toContain("Автор");
expect(html).toContain("https://example.com/article");
});
it("строит оглавление при 3+ заголовках и проставляет якоря", () => {
const content = "<h2>Один</h2><p>a</p><h2>Два</h2><p>b</p><h3>Три</h3><p>c</p>";
const html = buildCleanHtml({ ...base, contentHtml: content });
expect(html).toContain("Содержание");
expect(html).toMatch(/<h2 id="[^"]+">Один<\/h2>/);
expect((html.match(/class="toc-item/g) ?? []).length).toBe(3);
});
it("не строит оглавление при <3 заголовках", () => {
const html = buildCleanHtml({ ...base, contentHtml: "<h2>Один</h2><p>a</p>" });
expect(html).not.toContain("Содержание");
});
it("уникализирует одинаковые якоря", () => {
const content = "<h2>Раздел</h2><h2>Раздел</h2><h2>Раздел</h2>";
const html = buildCleanHtml({ ...base, contentHtml: content });
const ids = [...html.matchAll(/<h2 id="([^"]+)"/g)].map((m) => m[1]);
expect(new Set(ids).size).toBe(3);
});
it("переключает тёмную тему", () => {
const light = buildCleanHtml({ ...base, contentHtml: "<p>x</p>" });
const dark = buildCleanHtml({ ...base, theme: "dark", contentHtml: "<p>x</p>" });
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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(`<body>${contentHtml}</body>`);
const doc = dom.window.document;
const used = new Set<string>();
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
? `<nav class="toc"><div class="toc-title">Содержание</div>${toc
.map((t) => `<a class="toc-item lvl${t.level}" href="#${t.id}">${escapeHtml(t.text)}</a>`)
.join("")}</nav>`
: "";
return `<!DOCTYPE html>
<html lang="ru" data-theme="${article.theme}">
<head>
<meta charset="utf-8">
<title>${escapeHtml(article.title)}</title>
<style>${CSS}</style>
</head>
<body>
<main>
<h1>${escapeHtml(article.title)}</h1>
${metaParts.length ? `<div class="meta">${metaParts.join(" · ")}</div>` : ""}
<div class="meta"><a href="${escapeHtml(article.url)}">${escapeHtml(article.url)}</a></div>
<hr class="head-rule">
${tocHtml}
${html}
</main>
</body>
</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_<base64url>, достаточная длина", () => {
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/<timestamp>_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<string>` — ленивое создание.
- `regenerateKey(userId: string): Promise<string>` — замена ключа.
- [ ] **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_<random>, показывается студенту на /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<string> {
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<string> {
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<boolean>` — обёртка с Prisma.
- `monthStartUtc(now: Date): Date`
- `getMonthlyLimit(): number` — env `PDF_MONTHLY_LIMIT`, default 100.
- `getMonthlyUsage(userId: string): Promise<number>` — COUNT ToolUsage `tool="clean-pdf"` с начала месяца (UTC).
- `getBurstUsage(userId: string): Promise<number>` — 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<boolean> {
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<number> {
return prisma.toolUsage.count({
where: { userId, tool: PDF_TOOL_ID, createdAt: { gte: monthStartUtc(new Date()) } },
});
}
export async function getBurstUsage(userId: string): Promise<number> {
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 (~1040 сек с учётом туннеля). Если 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<string, string>) {
return NextResponse.json({ error }, { status, headers: extra });
}
async function resolveUserId(req: NextRequest): Promise<string | null> {
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 (
<main className="mx-auto w-full max-w-5xl px-6 py-8">
<h1 className="text-2xl font-bold tracking-wide" style={{ color: "var(--foreground)" }}>Чистый PDF</h1>
<div className="mt-6 card-aubade p-4">
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>
Инструмент доступен студентам платных курсов школы. Если у вас есть купленный курс проверьте, что вы вошли в нужный аккаунт.
</p>
</div>
</main>
);
}
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 (
<main className="mx-auto w-full max-w-5xl px-6 py-8">
<h1 className="text-2xl font-bold tracking-wide" style={{ color: "var(--foreground)" }}>Чистый PDF</h1>
<p className="mt-1 text-sm" style={{ color: "var(--muted-foreground)" }}>
Превращает любую веб-страницу в аккуратный PDF: без рекламы, меню и мусора только текст, картинки и оглавление.
</p>
<div className="mt-6 card-aubade p-4">
<CleanPdfForm initialUsed={used} limit={limit} />
</div>
<div className="mt-6 card-aubade p-4">
<ZoteroSection apiKey={key} script={zoteroScript} baseUrl={baseUrl} />
</div>
</main>
);
}
```
- [ ] **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<string | null>(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 (
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
<label className="text-sm">Адрес статьи
<input className={field} style={fieldStyle} type="url" required placeholder="https://…"
value={url} onChange={(e) => setUrl(e.target.value)} />
</label>
<div className="grid grid-cols-2 gap-3">
<label className="text-sm">Формат
<select className={field} style={fieldStyle} value={format} onChange={(e) => setFormat(e.target.value as "A4" | "Letter")}>
<option value="A4">A4</option>
<option value="Letter">Letter</option>
</select>
</label>
<label className="text-sm">Тема
<select className={field} style={fieldStyle} value={theme} onChange={(e) => setTheme(e.target.value as "light" | "dark")}>
<option value="light">Светлая</option>
<option value="dark">Тёмная</option>
</select>
</label>
</div>
<div className="flex items-center gap-4">
<button type="submit" disabled={busy} className="btn-aubade px-4 py-2 text-sm font-bold disabled:opacity-50">
{busy ? "Генерируем… (до минуты)" : "Скачать PDF"}
</button>
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>Использовано {used} из {limit} в этом месяце</span>
</div>
{error && <p className="text-sm" style={{ color: "var(--destructive)" }}>{error}</p>}
</form>
);
}
```
Классы `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 (
<div className="flex flex-col gap-4">
<h2 className="text-lg font-bold" style={{ color: "var(--foreground)" }}>Zotero и API</h2>
<div className="flex flex-col gap-2">
<div className="text-xs font-bold tracking-wide" style={{ color: "var(--muted-foreground)" }}>ВАШ КЛЮЧ</div>
<div className="flex items-center gap-2">
<code className="p-2 text-sm" style={{ border: "1px solid var(--border)", borderRadius: "2px" }}>{shownKey}</code>
<button type="button" className="text-xs underline" onClick={() => setRevealed((v) => !v)}>
{revealed ? "скрыть" : "показать"}
</button>
<button type="button" className="text-xs underline" onClick={() => navigator.clipboard.writeText(key)}>скопировать</button>
<button type="button" className="text-xs underline" disabled={pending} onClick={regenerate}>
{pending ? "меняем…" : "перевыпустить"}
</button>
</div>
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
Ключ персональный не публикуйте его. Если ключ утёк, перевыпустите: старый сразу отключится.
</p>
</div>
<div className="flex flex-col gap-2 text-sm" style={{ color: "var(--foreground)" }}>
<div className="text-xs font-bold tracking-wide" style={{ color: "var(--muted-foreground)" }}>НАСТРОЙКА ZOTERO</div>
<ol className="list-decimal pl-5 flex flex-col gap-1">
<li>Установите плагин <a className="underline" href="https://github.com/windingwind/zotero-actions-tags/releases" target="_blank" rel="noreferrer">Actions &amp; Tags</a>.</li>
<li>В настройках плагина нажмите «+» и создайте действие: Name <b>Чистый PDF</b>, Operation <b>Script</b>.</li>
<li>В поле Data вставьте скрипт ниже (ключ уже подставлен).</li>
<li>Menu Label <b>_чистый PDF</b>, поставьте галочку <b>In item Menu</b>, нажмите Save.</li>
<li>Готово: правый клик на записи с URL «_чистый PDF» файл прикрепится к записи.</li>
</ol>
</div>
<CodeOutput code={script} tool="clean-pdf" label="СКРИПТ ДЛЯ ACTIONS & TAGS" />
<CodeOutput code={curlExample} tool="clean-pdf" label="ПРИМЕР ДЛЯ API (CURL)" />
</div>
);
}
```
- [ ] **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=<random>` (например `openssl rand -hex 24`) и при желании `PDF_MONTHLY_LIMIT`.
2. Миграция на проде: выполняется как обычно при деплое (`prisma migrate deploy` в entrypoint — проверить, что `add_pdf_api_key` применилась: `docker exec -i lms-sb-db-1 psql -U lms_user -d lms_db -c '\d "PdfApiKey"'`).
3. `docker pull ghcr.io/browserless/chromium` на Hoster.kz (интернет с сервера есть) + обновлённый compose.
> ⚠️ **Перед шагом 4 (включением `TOOLBOX_VISIBLE=true`) обязательно захардить сетевой egress контейнера `browserless` на проде** — заблокировать `169.254.0.0/16` (cloud-metadata) и RFC1918-диапазоны (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`) на уровне хост-файрвола или выделенной internal-only docker-сети. Browser-level SSRF-фильтр (`context.route`/`context.routeWebSocket`) не резолвит DNS заново и не закрывает same-hostname DNS-rebind (публичный IP на первом резолве, приватный — на повторном запросе изнутри browserless); сетевой egress-блок — единственный слой, который закрывает этот остаточный риск. Подробности — «Безопасность»/«Деплой» в `docs/specs/20260706-clean-pdf-design.md`.
4. Инструмент останется невидимым, пока на проде не выставлен `TOOLBOX_VISIBLE=true` — это отдельное продуктовое решение. `/api/pdf` при этом уже будет работать — это ок (доступ гейтится платным enrollment), но анонсировать до включения тулбокса не нужно.
5. Hot-standby Hetzner: подтянуть тот же compose (`/root/digital-household/lms-sb/docker-compose.prod.yml` обновится через git pull).
6. После релиза: обновить SBT-карточку `SBT/00-Стек/Сервисы/lms.md` (раздел про тулбокс + новые env) через capture-knowledge.
7. Follow-up (не блокирует релиз): месячный/burst-лимит сейчас считает только **успешные** генерации — неудачные попытки (таймаут, 5xx, зависший рендер) лимит не расходуют, потенциальный ограниченный self-DoS повторными запросами. Рассмотреть подсчёт попыток, а не только успехов, отдельной задачей.
## Самопроверка при завершении
- `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 (ручная проверка с живой библиотекой).