Files
lms-sb/docs/specs/20260706-clean-pdf-design.md
T
2026-07-06 10:10:11 +05:00

112 lines
9.6 KiB
Markdown
Raw 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 — инструмент «URL → PDF» в LMS
Дата: 20260706
Статус: дизайн утверждается
Референс: Clean PDF (`pdf.brainysnipe.ru`) из поста https://t.me/flowing_abyss/94, инструкция https://flowing-abyss.com/Clean-PDF-for-Zotero
## Что делаем
Инструмент для студентов школы: превращает любую веб-страницу в чистый PDF — без рекламы, меню и мусора, с нормальной типографикой, оглавлением, форматами A4/Letter и светлой/тёмной темой. Два сценария плюс открытый API:
1. **Веб-форма** в кабинете (`/tools/clean-pdf`): вставил URL — скачал PDF.
2. **Zotero**: персональный API-ключ + скрипт для плагина Actions & Tags — правый клик на записи → PDF генерируется и прикрепляется к ней.
3. **Открытый API** для продвинутых: `curl` с Bearer-ключом, документация на странице инструмента.
## Принятые решения
| Вопрос | Решение |
|---|---|
| Архитектура | Всё внутри LMS (подход B): страница и API в Next.js, рендер — отдельный контейнер browserless в том же compose |
| Кому доступ | Любой платный студент: есть `CourseEnrollment` на курс со slug ≠ `FREE_COURSE_SLUG`; admin/curator — без ограничений |
| Видимость | Часть Obsidian Toolbox, живёт под существующим флагом `TOOLBOX_VISIBLE`; включение на проде — отдельное решение позже |
| Лимит | 100 генераций/месяц на студента, env `PDF_MONTHLY_LIMIT` (без пересборки), плюс burst-лимит 5/мин |
## Пайплайн генерации
```
URL студента
→ Chromium (browserless) загружает страницу (JS отрабатывает)
→ Next.js забирает итоговый HTML
→ Defuddle (npm, через JSDOM) выделяет главный контент
→ чистый HTML-шаблон: типографика, A4/Letter, light/dark, оглавление из заголовков
→ Playwright page.pdf() в том же browserless
→ файл application/pdf в ответ
```
- **Defuddle** — библиотека kepano (автор Obsidian), та же, что у референса: https://github.com/kepano/defuddle
- **browserless/chromium** — отдельный контейнер в docker-compose: `MAX_CONCURRENT_SESSIONS=2`, встроенная очередь, `mem_limit` ~1 ГБ, порт наружу не публикуется.
- Next.js подключается по WebSocket через `playwright-core` (только клиент, Chromium в образ LMS не попадает — образ не растёт).
- Таймаут всего пайплайна ~90 сек; понятные JSON-ошибки: URL недоступен, лимит исчерпан, нет платного доступа.
## Данные (Prisma)
Новая модель:
```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>, показывается студенту
createdAt DateTime @default(now())
}
```
- Ключ создаётся лениво при первом заходе на страницу инструмента.
- Перегенерация заменяет ключ (старый перестаёт работать сразу).
- Учёт использований — существующая `ToolUsage` с `tool = "clean-pdf-generate"`; месячный счётчик = COUNT за календарный месяц. Именно отдельный идентификатор: `CopyButton` на странице логирует копирования как `tool = "clean-pdf"`, и они не должны тратить лимит генераций.
- Проверка платного доступа выполняется на каждый запрос → рефанд или блокировка аккаунта автоматически отключает и веб, и ключ. Источник истины один — БД LMS.
## API
`GET /api/pdf?url=<...>&format=A4|Letter&theme=light|dark`
- **Авторизация, два пути**: заголовок `Authorization: Bearer sbpdf_...` (Zotero, curl) **или** активная сессия Better Auth (веб-форма — студенту ключ для веба не нужен).
- Роут добавляется в `PUBLIC_ROUTES` в `middleware.ts` (иначе 307 → /login — известные грабли).
- Ответ: `application/pdf` + заголовки `X-Uses-Count` / `X-Max-Uses` (их же показывает Zotero-скрипт).
- Ошибки: JSON `{ error }` c честными статусами (401 нет ключа/сессии, 403 нет платного доступа, 429 лимит, 422 плохой URL, 504 не отрендерилось).
## Страница /tools/clean-pdf
По образцу существующих инструментов тулбокса (реестр `TOOLS`, `ToolCard`, страница + клиентская форма):
1. **Форма**: поле URL, выбор формата и темы, кнопка «Скачать PDF», счётчик «использовано N из 100 в этом месяце».
2. **Блок «Zotero»**: персональный ключ (показать/скопировать/перегенерировать), пошаговая инструкция по Actions & Tags, готовый скрипт с уже подставленными адресом школы и ключом студента (адаптация скрипта референса: `SERVICE_URL = https://school.second-brain.ru`, endpoint `/api/pdf`).
3. **Блок «API»**: пример curl.
Дизайн — строго ДС-2 (перед вёрсткой перечитать `SBT/02-Стандарты/Дизайн-LMS/DESIGN.md`).
## Безопасность
Сервис скачивает произвольные URL изнутри прод-стенда — главный риск SSRF:
- Только `http://` и `https://`.
- Перед загрузкой — резолв DNS и блокировка приватных/зарезервированных диапазонов: localhost/127.x, 10.x, 172.1631.x, 192.168.x, 169.254.x (метаданные облаков), ::1, fc00::/7, плюс docker-хостнеймы стенда (`db`, `app`, `browserless`).
- Внутри browserless — перехват сетевых запросов страницы с той же фильтрацией (защита от редиректов и подгрузок на внутренние адреса).
- Порт browserless наружу не публикуется, доступен только приложению по внутренней сети compose.
- Куки/учётные данные пользователя на целевую страницу не передаются.
- Санитайз имени файла в `Content-Disposition`.
- Лимиты: 100/мес (env) + burst 5/мин на пользователя; сверху — очередь browserless (2 конкурентных рендера).
## Деплой
- `docker-compose.yml` (dev) и `docker-compose.prod.yml`: сервис `browserless` (образ `ghcr.io/browserless/chromium`), внутренняя сеть, лимиты памяти.
- env: `BROWSER_WS_URL`, `PDF_MONTHLY_LIMIT=100`, существующий `TOOLBOX_VISIBLE`.
- Prisma-миграция (`PdfApiKey`).
- Стандартная схема деплоя LMS: сборка на Hetzner → `docker save | ssh | docker load` на Hoster.kz; browserless на Hoster.kz — обычный `docker pull`.
- Hot-standby на Hetzner получает тот же compose (репликация БД уже покрывает `PdfApiKey` и `ToolUsage`).
## Тестирование
- Юнит: SSRF-валидатор (таблица адресов → допуск/блок), пайплайн Defuddle → HTML-шаблон на фикстурах.
- Интеграция на staging: рендер 3–5 реальных статей (лонгрид, статья с картинками, JS-тяжёлая страница), проверка лимитов и обоих путей авторизации.
- Zotero-скрипт — ручная проверка на живой библиотеке.
- Веб-форма — e2e через agent-browser в `--headed` (headless режет антиспам — известные грабли).
## Вне рамок (YAGNI)
- Отдельный домен `pdf.second-brain.ru` — не нужен, всё на `school.second-brain.ru`.
- Хранение сгенерированных PDF на сервере — файл отдаётся сразу и не сохраняется.
- Пер-курсовый гейтинг, тарифные лимиты, платные квоты — не сейчас.
- Освещение в рассылке/анонсы — после включения `TOOLBOX_VISIBLE` на проде, отдельной задачей.