# Чистый PDF — снимок состояния **Дата снимка:** 20260912 **Фаза:** слито в `main`, работает на staging, на прод не выкачено **Охват:** только фича «Чистый PDF». Состояние остальных частей LMS здесь не отражено. --- ## Коротко Инструмент `URL → чистый PDF` в Obsidian Toolbox: веб-форма в кабинете, персональный API-ключ + скрипт для Zotero (Actions & Tags), открытый curl API. Рендер — отдельный контейнер browserless (Chromium по CDP), контент выделяет Defuddle, печатает `page.pdf()`. Сделано 20260706 по плану из 11 задач (субагентное исполнение с ревью после каждой). Merge `53caf54`, 21 коммит. Финальное whole-branch ревью — READY TO MERGE. ## Состояние сред (проверено 20260912) | Среда | Состояние | |---|---| | `main` | Фича на месте и цела: файлы побайтово совпадают с merge `53caf54`; `/api/pdf` в `PUBLIC_ROUTES`, регистрация инструмента и модель `PdfApiKey` не пострадали от августовских коммитов | | **Прод** (Hoster.kz) | **Выключено.** Контейнера `browserless` нет, `BROWSER_WS_URL`/`BROWSERLESS_TOKEN`/`TOOLBOX_VISIBLE` в `/root/lms-sb/.env` не заданы. Репозиторный `docker-compose.prod.yml` намеренно расходится с живым `/root/lms-sb/docker-compose.yml` | | **Staging** (Hetzner) | **Работает.** Стенд стоит ровно на `53caf54` и отстал от `main` на августовские коммиты. `TOOLBOX_VISIBLE=true`, контейнеры `lms-staging-app-1` и `lms-staging-browserless-1` живы с 20260706 | Проверено E2E на staging 20260706: 401 без ключа, 403 без платного доступа, 200 с реальным PDF (576 КБ, 4 страницы), 422 на SSRF-пробы (`192.168.x`, `localhost:5432`, `[::ffff:169.254.169.254]`). Страница и перевыпуск ключа проверены в живом браузере. ## Что где лежит - Код: `src/lib/clean-pdf/` (`ssrf.ts`, `template.ts`, `generate.ts`, `access.ts`, `keys.ts`, `api-key.ts`, `zotero-script.ts`), роут `src/app/api/pdf/route.ts`, страница `src/app/(student)/tools/clean-pdf/`, действие `src/lib/actions/pdf-key-actions.ts` - Данные: модель `PdfApiKey`, миграция `20260706120000_add_pdf_api_key`; учёт — существующая `ToolUsage` - Документы: спека `docs/specs/20260706-clean-pdf-design.md`, план `docs/superpowers/plans/20260706-clean-pdf.md`, раздел в `TECHNICAL.md` - Уклад и грабли: раздел «Грабли уклада» в `CLAUDE.md` - В базе знаний: `SBT/00-Стек/Сервисы/clean-pdf.md`, `SBT/03-Опыт/Постмортемы/20260706-clean-pdf-ssrf-обходы.md`, гейт в `SBT/02-Стандарты/Процессы/lms-deploy.md` ## ⚠️ Гейт перед прод-релизом **Закрыть исходящий трафик контейнера `browserless`** — заблокировать `169.254.0.0/16` и RFC1918 через `internal`-сеть compose или `DOCKER-USER`. На 20260912 не сделано ни в репозитории (у сервиса нет `networks`/`internal`), ни на хосте (`DOCKER-USER` пуста). **Гейт привязан к рендереру, а не к флагу.** `TOOLBOX_VISIBLE` прячет только страницы `/tools/*`; `/api/pdf` лежит в `PUBLIC_ROUTES` и авторизуется сам, поэтому фича оживёт для любого платного студента в момент появления `browserless` и `BROWSER_WS_URL` — ещё до поднятия флага. Причина: `assertPublicUrl` резолвит DNS **один раз, для исходного URL**, а фильтр внутри браузера хостнеймы не резолвит вовсе. Непроверенным уходит любой новый хостнейм после первого перехода (редирект, субресурс, `fetch`, `ws://`) — DNS-rebind для обхода не нужен. ## Хвосты (по убыванию важности) 1. **Egress-изоляция browserless** — гейт выше. Блокирует прод-релиз. 2. **Фикс WebSocket-обхода (`routeWebSocket`) не покрыт тестом.** Баг с v4-mapped IPv6 закрыт юнит-тестами `ssrf.test.ts`, а браузерный фильтр не тестируется вовсе (единственный тест генерации за `RUN_PDF_INTEGRATION`). Регрессия пройдёт молча. 3. **Образ browserless не запинен** — `ghcr.io/browserless/chromium` без тега в обоих compose. Контейнер исполняет контент атакующего; обновление меняет Chromium и поведение CDP-слоя, на котором держатся фильтры. 4. **`NEXT_PUBLIC_APP_URL` не доезжает до образа** — задан только как runtime-env в compose, а `NEXT_PUBLIC_*` инлайнятся на сборке. Работает захардкоженный фолбэк на прод-адрес: на staging студенту выдаются Zotero-скрипт и curl с **прод**-URL. Лечится `--build-arg`, как у `NEXT_PUBLIC_TURNSTILE_SITE_KEY`. 5. **Ключи `PdfApiKey` лежат в БД открытым текстом** (поиск прямым `findUnique`). Дампы, реплика на standby и PITR содержат рабочие ключи. Ущерб ограничен (только генерация в пределах лимита, доступ проверяется на каждый запрос), но при релизе стоит перейти на хеш. 6. **Лимит не расходуется неуспешными генерациями** — `ToolUsage` пишется строго после успешного рендера, поэтому ни месячная квота, ни burst не тратятся на отказы. Ограниченный self-DoS тяжёлыми URL. Рассмотреть подсчёт попыток. 7. **`RenderError` обёрнута только часть вызовов браузера** (`connectOverCDP`, `goto`, таймаут печати). `newContext`, `newPage`, `content`, `route`/`routeWebSocket` и reject самого `pdf()` дают сырую ошибку Playwright — её ловит catch-all роута и отдаёт 504. Маркеров TODO/FIXME в коде фичи нет — хвосты живут в документах. ## Если возвращаемся к работе 1. Прочитать этот файл и «Грабли уклада» в `CLAUDE.md`. 2. Перед любым касанием прода — `SBT/02-Стандарты/Процессы/lms-deploy.md` (там гейт). 3. Staging отстал от `main` — перед проверками подтянуть ветку и передеплоить (`bash ~/Documents/Claude/scripts/deploy-staging.sh`; скрипт иногда не пересоздаёт контейнер, тогда вручную `docker compose up -d app`).