Document clean-pdf state, correct SSRF limits and prod gate

Audit of the shipped Chistyy PDF feature against the actual code found
three documentation defects and one misplaced gate:

- The SSRF write-up understated the hole. assertPublicUrl resolves DNS
  exactly once, for the initial URL; the in-browser filter never resolves
  hostnames at all. Any new hostname after the first navigation (redirect,
  subresource, fetch, ws://) goes unchecked - a DNS rebind is not even
  required. Corrected in TECHNICAL.md and the design spec.
- The prod gate was tied to TOOLBOX_VISIBLE, but /api/pdf sits in
  PUBLIC_ROUTES and authenticates itself, so the feature goes live the
  moment browserless and BROWSER_WS_URL appear on prod - before the flag.
  Gate is now tied to the renderer.
- TECHNICAL.md claimed the browserless port is published on neither
  staging nor prod. It is published on dev/staging (127.0.0.1:3333) and
  the SSH tunnel depends on it.
- AGENTS.md described a src/proxy.ts that does not exist; route protection
  lives in src/middleware.ts.

Also adds a state snapshot (docs/plans) and a "grabli uklada" section to
CLAUDE.md covering the non-obvious conventions already enforced in code:
the two ToolUsage ids, the vitest include pattern, page.pdf() without a
timeout option, context.route not seeing WebSockets, and NEXT_PUBLIC_*
being inlined at build time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sy7vY7WQ1A3q1MkgsDd8VB
This commit is contained in:
2026-09-12 12:55:46 +05:00
co-authored by Claude Opus 5
parent 03aaec76c2
commit 5ad0bd98f1
5 changed files with 141 additions and 15 deletions
+67 -5
View File
@@ -18,7 +18,7 @@
| TipTap | 2.x | WYSIWYG-редактор уроков |
| @kinescope/react-kinescope-player | latest | Видеоплеер |
| Resend | latest | Email-уведомления |
| AWS SDK (S3) | 3.x | Hetzner Object Storage |
| AWS SDK (S3) | 3.x | Backblaze B2 (раздача через Bunny CDN) |
| Zod | 3.x | Валидация данных |
| Docker Compose | 2.x | Локальная разработка и деплой |
@@ -66,7 +66,7 @@ lms-system/
│ │ ├── auth.ts # Better Auth config (сервер)
│ │ ├── auth-client.ts # Better Auth client (браузер)
│ │ ├── prisma.ts # Prisma singleton client
│ │ ├── s3.ts # Hetzner Object Storage клиент
│ │ ├── s3.ts # Backblaze B2 (S3-совместимый) клиент
│ │ ├── email.ts # Resend email helpers
│ │ └── utils.ts # cn() и прочие утилиты
│ ├── types/
@@ -135,7 +135,7 @@ docker compose -f docker-compose.prod.yml logs -f app
- Перед `prisma migrate deploy` на production — делать бэкап БД
### Файлы и контент
- Загружаемые файлы (ДЗ, PDF) — только через Hetzner Object Storage, никогда на диск VPS
- Загружаемые файлы (ДЗ, PDF, картинки комментариев) — только в Backblaze B2, никогда на диск VPS
- Секреты (API-ключи, токены, строки подключения) — **только в `.env.local`**, в коде запрещено
- `.env.example` всегда обновлять при добавлении новых переменных (без реальных значений)
@@ -168,8 +168,9 @@ BETTER_AUTH_URL="http://localhost:3000"
RESEND_API_KEY=""
EMAIL_FROM="noreply@school.second-brain.ru"
# Hetzner Object Storage (S3-совместимый)
S3_ENDPOINT="https://fsn1.your-objectstorage.com"
# Backblaze B2 (S3-совместимый), раздача через Bunny CDN
S3_ENDPOINT="https://s3.eu-central-003.backblazeb2.com"
S3_CDN_URL="https://files.second-brain.ru"
S3_BUCKET="lms-uploads"
S3_ACCESS_KEY=""
S3_SECRET_KEY=""
@@ -181,6 +182,67 @@ S3_REGION="eu-central"
---
## Грабли уклада (проверено 20260912)
Неочевидные соглашения, уже действующие в коде. Сломать их легко «улучшением».
### Тесты
- `vitest.config.ts` ищет тесты **только** по `src/lib/**/__tests__/**/*.test.ts`. Тест рядом с исходником или в `src/app/**` молча не подбирается — зелёный `npm test` не значит, что твой тест выполнился. Проверяй, что файл попал в прогон.
- Интеграционный тест генерации PDF выключен по умолчанию: `describe.runIf(process.env.RUN_PDF_INTEGRATION === "1")` + нужен живой `BROWSER_WS_URL`. Гейт не снимать — тест ходит в интернет и в browserless.
- Решения выносим в чистую функцию с внедряемым IO, обёртка с IO остаётся тонкой (`decidePaidAccess` рядом с `hasPaidAccess`; `assertPublicUrl(raw, resolve)`). Prisma и dns в тестах **не мокаем**.
### Маршруты
- **`src/proxy.ts` не существует** — вся защита маршрутов в `src/middleware.ts`, экспорт `middleware`. Не заводить второй файл.
- Новый API-роут, который авторизуется сам (Bearer-ключ, внутренний секрет), обязан попасть в `PUBLIC_ROUTES` — иначе middleware отдаст редирект на `/login` раньше роута, и в браузере с кукой это не воспроизведётся. Обратная сторона: сверка идёт по `startsWith`, поэтому запись `/api/pdf` уже открывает любой будущий `/api/pdf-*` — такой роут обязан проверять доступ внутри себя.
### Чистый PDF (`/tools/clean-pdf`, `/api/pdf`)
- **Два разных значения `ToolUsage.tool`**: `clean-pdf-generate` (`PDF_TOOL_ID`) — только успешные генерации, по ним считаются месячный лимит и burst; `clean-pdf` — аналитика копирований из `CopyButton`. Не объединять: иначе каждое нажатие «Скопировать» списывает генерацию. `clean-pdf-generate` намеренно отсутствует в `TOOL_IDS`, чтобы публичный Server Action не мог списать квоту.
- Квота списывается **только после успешной** генерации, месячное окно — от начала месяца по UTC (`monthStartUtc`). Рефакторинг «сначала резервируем квоту, потом рендерим» начнёт списывать за каждую неоткрывшуюся страницу.
- `page.pdf()` в playwright-core не принимает `timeout` — оборачивать своим `withTimeout` (Promise.race), иначе зависшая печать держит слот `CONCURRENT` у browserless.
- `context.route("**/*")` **не видит WebSocket** — нужен отдельный `context.routeWebSocket`. Два похожих блока подряд — не дублирование, это вторая линия SSRF-обороны; удалять только осознанно.
- `isPrivateAddress(ip)`**fail-closed гейт, а не предикат**: на всё, что не разбирается как IP, возвращает `true`. Не переиспользовать как «адрес из приватного диапазона».
- В `resolveUserId` ветка Bearer терминальная: если заголовок `Authorization` есть, фолбэка на сессию нет ни при каком исходе. Не добавлять «если ключ не подошёл — попробуем куку»: запрос с чужим ключом из залогиненного браузера начнёт выполняться от имени владельца сессии.
- Заголовки ответа — публичный контракт с уже розданными ключами: `X-Uses-Count`, `X-Max-Uses` и двойной `Content-Disposition` (ASCII-фолбэк + RFC 5987 `filename*`). Их читают Zotero-скрипт и форма на сайте.
- Карта ошибок: `BlockedUrlError`/`EmptyContentError` → 422, `RenderError` → 504, остальное → 504, **никогда 500**. Текст `{error}` показывается студенту дословно — только по-русски, без внутренних деталей.
- HTML от Defuddle санируется собственным проходом в `template.prepareContent` (JSDOM: `script`/`style`/`iframe`/`object`/`embed`, все `on*`, `javascript:`) — намеренно **не** через `rehype-sanitize`, который заточен под Markdown-пайплайн комментариев.
- `zotero-script.ts` генерирует JS шаблонной строкой: учетверённые бэкслеши (`\\\\` в исходнике = один `\` в выданном скрипте). Ошибка экранирования не падает, а тихо ломает регулярки в выданном студенту скрипте — правил строку, прогони тест на валидность (`new Function(script)`).
### Env
- `NEXT_PUBLIC_*` инлайнятся **на сборке**. `NEXT_PUBLIC_APP_URL` задан только как runtime-env в `docker-compose.prod.yml`, в образ не попадает → работает захардкоженный фолбэк на прод-адрес. Следствие: на staging студенту выдаются Zotero-скрипт и curl-пример с **прод**-адресом. Правишь — передавай через `--build-arg`, как `NEXT_PUBLIC_TURNSTILE_SITE_KEY`.
### Тариф — это отдельный курс со своей копией уроков
`obsidian` и `obsidian-full` (как и `zotero` / `zotero-full`) — **две разные записи `Course`, у каждой свой полный комплект `Module` и `Lesson`**. Это не представления одного курса.
- **Правка контента применяется в обе копии.** Починил текст в «Всё включено» — в базовом тарифе он остался прежним. Копии уже расходились в проде: блок с благодарностью автору был только в дешёвом тарифе.
- **Комментарии сыплются под уроки обеих копий.** Ответ под уроком `zotero-full` студенты `zotero` не увидят; выборка «неотвеченные» должна идти по всем курсам сразу.
- Перед массовой правкой — `SELECT` по обоим slug, потом `UPDATE ... WHERE id IN (...)`.
### Markdown рендерится не везде
| Где | Что рендерится |
|---|---|
| Комментарии к урокам (`lesson-comments.tsx`) | Markdown + GFM: разметка, таблицы, голый адрес становится ссылкой |
| Отзыв куратора на ДЗ (`homework-section.tsx`) | простой текст (`whitespace-pre-wrap` + `linkify`) |
| Раздел вопросов (`QuestionThread.tsx`, `QuestionSplitView.tsx`) | простой текст (`whitespace-pre-wrap` + `linkify`) |
`**жирный**` в отзыве на ДЗ студент увидит звёздочками. Ссылку там же писать голым адресом можно — `linkify` её поднимет.
Безопасность рендера комментариев (не ослаблять «улучшением»): `rehype-sanitize` режет сырой HTML и `javascript:`; картинки с чужих доменов **не грузятся** — компонент `img` отдаёт их ссылкой, чтобы адрес читателя не утекал на сторонний сервер. Свои картинки — через `/api/student/comment-upload`.
### Файлы курсов живут на двух разных доменах
- `files.second-brain.ru` — Bunny CDN → Backblaze B2. Вложения уроков, ДЗ, картинки комментариев. Кеш короткий, правка «на месте» доезжает за час.
- `filez.second-brain.ru` — Selectel CDN → origin на Hoster.kz `/root/filez-static/`. Бонусы, архивы, шаблоны и CSS курсов. ⚠️ Кеш до 30 дней, **query-параметры игнорируются**: чтобы обновление дошло сразу, публиковать под новым путём или именем, а не `?v=2`.
Домены различаются одной буквой — легко перепутать при диагностике.
---
## Чек-лист перед каждым коммитом
- [ ] `npm run lint` — нет ошибок ESLint