4734b99bea
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.
1671 lines
74 KiB
Markdown
1671 lines
74 KiB
Markdown
# Чистый 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("<script>");
|
||
});
|
||
|
||
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, "&")
|
||
.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(`<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 (~10–40 сек с учётом туннеля). Если URL статьи умер — заменить на любую живую статью с habr.com в тесте. Если туннель не поднялся (`connection refused`) — проверить, что browserless на staging запущен (Task 1 Step 4).
|
||
|
||
- [ ] **Step 4: Сохранить образец глазами**
|
||
|
||
Временный прогон: в конце интеграционного теста можно добавить `require("fs").writeFileSync("/tmp/sample.pdf", pdf)` локально, открыть, убедиться: контент чистый, шапка с URL, поля нормальные. Строку не коммитить.
|
||
|
||
- [ ] **Step 5: Убедиться, что обычный `npm run test` интеграционный тест пропускает**
|
||
|
||
```bash
|
||
npm run test
|
||
```
|
||
|
||
Expected: `generate.int.test.ts` — skipped, остальные PASS.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/lib/clean-pdf && git commit -m "Add PDF generation pipeline via browserless and Defuddle"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: API-роут /api/pdf + middleware
|
||
|
||
**Files:**
|
||
- Create: `src/app/api/pdf/route.ts`
|
||
- Modify: `src/middleware.ts` (строка 4, массив `PUBLIC_ROUTES`)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `generateCleanPdf`, `EmptyContentError`, `RenderError` (Task 7); `BlockedUrlError` (Task 2); `hasPaidAccess`, `getMonthlyUsage`, `getBurstUsage`, `getMonthlyLimit`, `BURST_LIMIT`, `PDF_TOOL_ID` (Task 6); `safePdfFilename` (Task 3); Prisma `pdfApiKey` (Task 5).
|
||
- Produces: `GET /api/pdf?url&format&theme` — `application/pdf` + `X-Uses-Count`/`X-Max-Uses`, ошибки JSON `{ error }` (401/403/422/429/504).
|
||
|
||
- [ ] **Step 1: Добавить роут в PUBLIC_ROUTES**
|
||
|
||
В `src/middleware.ts` в массив `PUBLIC_ROUTES` добавить `"/api/pdf"`:
|
||
|
||
```ts
|
||
const PUBLIC_ROUTES = ["/login", "/register", "/verify-email", "/forgot-password", "/reset-password", "/api/auth", "/api/register", "/api/internal", "/api/pdf", "/maintenance", "/share/wrapped"];
|
||
```
|
||
|
||
- [ ] **Step 2: Написать роут**
|
||
|
||
```ts
|
||
// src/app/api/pdf/route.ts
|
||
import { NextRequest, NextResponse } from "next/server";
|
||
import { z } from "zod";
|
||
import { auth } from "@/lib/auth";
|
||
import { prisma } from "@/lib/prisma";
|
||
import {
|
||
BURST_LIMIT, PDF_TOOL_ID, getBurstUsage, getMonthlyLimit, getMonthlyUsage, hasPaidAccess,
|
||
} from "@/lib/clean-pdf/access";
|
||
import { BlockedUrlError } from "@/lib/clean-pdf/ssrf";
|
||
import { EmptyContentError, RenderError, generateCleanPdf } from "@/lib/clean-pdf/generate";
|
||
import { safePdfFilename } from "@/lib/clean-pdf/template";
|
||
import { PDF_KEY_PREFIX } from "@/lib/clean-pdf/api-key";
|
||
|
||
export const dynamic = "force-dynamic";
|
||
|
||
const querySchema = z.object({
|
||
url: z.string().min(1).max(2000),
|
||
format: z.enum(["A4", "Letter"]).default("A4"),
|
||
theme: z.enum(["light", "dark"]).default("light"),
|
||
});
|
||
|
||
function jsonError(status: number, error: string, extra?: Record<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 & 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 (ручная проверка с живой библиотекой).
|