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
276 lines
20 KiB
Markdown
276 lines
20 KiB
Markdown
# CLAUDE.md — LMS Second Brain
|
||
|
||
> Читай AGENTS.md перед тем как писать любой Next.js код — версия отличается от обучающих данных.
|
||
|
||
## Стек и версии
|
||
|
||
| Технология | Версия | Назначение |
|
||
|---|---|---|
|
||
| Next.js | 16.2.2 (App Router) | Full-stack фреймворк |
|
||
| Node.js | 20 LTS | Runtime |
|
||
| TypeScript | 5.x | Язык |
|
||
| React | 19 | UI |
|
||
| PostgreSQL | 16 | База данных |
|
||
| Prisma | 7.x | ORM + миграции |
|
||
| Better Auth | latest | Аутентификация и сессии |
|
||
| Tailwind CSS | 4.x (CSS-based config) | Стили (нет tailwind.config.ts) |
|
||
| shadcn/ui | latest | UI-компоненты |
|
||
| TipTap | 2.x | WYSIWYG-редактор уроков |
|
||
| @kinescope/react-kinescope-player | latest | Видеоплеер |
|
||
| Resend | latest | Email-уведомления |
|
||
| AWS SDK (S3) | 3.x | Backblaze B2 (раздача через Bunny CDN) |
|
||
| Zod | 3.x | Валидация данных |
|
||
| Docker Compose | 2.x | Локальная разработка и деплой |
|
||
|
||
**Важно по Tailwind v4:** конфиг — только в CSS через `@import "tailwindcss"` и `@theme`. Нет `tailwind.config.ts`. Кастомизация — через CSS переменные в `globals.css`.
|
||
|
||
**Важно по Better Auth:** не NextAuth. Сессии — cookie-based. Роли через плагин `admin`. Подробнее: https://www.better-auth.com/docs
|
||
|
||
---
|
||
|
||
## Структура каталогов
|
||
|
||
```
|
||
lms-system/
|
||
├── src/
|
||
│ ├── app/ # Next.js App Router
|
||
│ │ ├── (auth)/ # Вход, регистрация, подтверждение email
|
||
│ │ │ ├── login/
|
||
│ │ │ ├── register/
|
||
│ │ │ └── verify-email/
|
||
│ │ ├── (student)/ # Личный кабинет ученика
|
||
│ │ │ ├── dashboard/
|
||
│ │ │ ├── courses/[slug]/
|
||
│ │ │ └── courses/[slug]/lessons/[lessonId]/
|
||
│ │ ├── curator/ # Панель куратора
|
||
│ │ │ ├── dashboard/
|
||
│ │ │ └── homework/
|
||
│ │ ├── admin/ # Панель администратора
|
||
│ │ │ ├── courses/
|
||
│ │ │ ├── users/
|
||
│ │ │ └── settings/
|
||
│ │ └── api/ # API-маршруты
|
||
│ │ ├── auth/ # Better Auth handler
|
||
│ │ ├── courses/
|
||
│ │ ├── lessons/
|
||
│ │ ├── progress/
|
||
│ │ ├── homework/
|
||
│ │ └── upload/
|
||
│ ├── components/
|
||
│ │ ├── ui/ # shadcn/ui (не редактировать вручную)
|
||
│ │ ├── editor/ # TipTap WYSIWYG
|
||
│ │ ├── player/ # Kinescope Player wrapper
|
||
│ │ ├── course/ # Компоненты курса
|
||
│ │ └── layout/ # Header, Sidebar, Footer
|
||
│ ├── lib/
|
||
│ │ ├── auth.ts # Better Auth config (сервер)
|
||
│ │ ├── auth-client.ts # Better Auth client (браузер)
|
||
│ │ ├── prisma.ts # Prisma singleton client
|
||
│ │ ├── s3.ts # Backblaze B2 (S3-совместимый) клиент
|
||
│ │ ├── email.ts # Resend email helpers
|
||
│ │ └── utils.ts # cn() и прочие утилиты
|
||
│ ├── types/
|
||
│ │ └── index.ts # Общие TypeScript-типы
|
||
│ └── middleware.ts # Auth middleware (защита маршрутов)
|
||
├── prisma/
|
||
│ ├── schema.prisma # Схема БД
|
||
│ ├── seed.ts # Seed-скрипт
|
||
│ └── migrations/ # НИКОГДА не редактировать вручную
|
||
├── public/
|
||
│ └── images/
|
||
├── docker-compose.yml # Локальная разработка
|
||
├── docker-compose.prod.yml # Production
|
||
├── Dockerfile
|
||
├── .env.example # Шаблон переменных (без секретов)
|
||
├── .env.local # Локальные секреты (в .gitignore)
|
||
├── CLAUDE.md
|
||
├── ROADMAP.md
|
||
└── PROJECT_BRIEF.md
|
||
```
|
||
|
||
---
|
||
|
||
## Команды
|
||
|
||
```bash
|
||
# Разработка
|
||
npm run dev # Запустить dev-сервер (localhost:3000)
|
||
docker compose up -d # Поднять PostgreSQL локально
|
||
|
||
# Сборка и проверка
|
||
npm run build # Production build
|
||
npm run lint # ESLint
|
||
npm run type-check # TypeScript без сборки (tsc --noEmit)
|
||
|
||
# База данных (Prisma)
|
||
npx prisma migrate dev --name <название> # Создать и применить миграцию
|
||
npx prisma migrate deploy # Применить миграции в production
|
||
npx prisma studio # GUI для просмотра БД
|
||
npx prisma generate # Пересоздать клиент после изменений schema
|
||
npx prisma db seed # Запустить seed-скрипт
|
||
|
||
# Docker (production)
|
||
docker compose -f docker-compose.prod.yml up -d --build
|
||
docker compose -f docker-compose.prod.yml logs -f app
|
||
```
|
||
|
||
---
|
||
|
||
## Правила работы
|
||
|
||
### Деплой и окружение
|
||
- Production-домен: **school.second-brain.ru**
|
||
- Git-сервер: **Gitea — https://git.second-brain.ru/admins/lms-sb** (токен: в `.env.local` не хранить, спросить у пользователя)
|
||
- **После завершения каждого этапа из ROADMAP.md** — делать `git push` в Gitea
|
||
|
||
### Коммиты
|
||
- Один коммит = одна логически завершённая единица работы (маршрут, компонент, миграция)
|
||
- Сообщения коммитов — на английском, в повелительном наклонении: `Add lesson progress tracking`, `Fix auth redirect`
|
||
- Перед коммитом всегда запускать `npm run lint && npm run type-check`
|
||
|
||
### Миграции базы данных
|
||
- **НИКОГДА** не редактировать файлы в `prisma/migrations/` вручную
|
||
- **ВСЕГДА** спрашивать пользователя перед созданием миграции, меняющей или удаляющей существующие поля
|
||
- Называть миграции по-английски, snake_case: `add_lesson_progress`, `add_user_roles`
|
||
- Перед `prisma migrate deploy` на production — делать бэкап БД
|
||
|
||
### Файлы и контент
|
||
- Загружаемые файлы (ДЗ, PDF, картинки комментариев) — только в Backblaze B2, никогда на диск VPS
|
||
- Секреты (API-ключи, токены, строки подключения) — **только в `.env.local`**, в коде запрещено
|
||
- `.env.example` всегда обновлять при добавлении новых переменных (без реальных значений)
|
||
|
||
### Код
|
||
- UI-строки (заголовки, кнопки, сообщения) — на **русском**
|
||
- Имена переменных, функций, файлов, комментарии в коде — на **английском**
|
||
- Компоненты shadcn/ui — добавлять через `npx shadcn@latest add <component>`, не копировать вручную
|
||
- Не добавлять абстракции "на будущее" — только то, что нужно для текущего этапа
|
||
- Server Actions — использовать для форм и мутаций (auth, прогресс, ДЗ)
|
||
|
||
### Маршруты и роли
|
||
- Защита маршрутов — через `middleware.ts` (Better Auth), не в каждом компоненте отдельно
|
||
- Роли: `student`, `curator`, `admin` — проверять через `auth()` или `authClient.useSession()`
|
||
- Admin-маршруты (`/admin/*`) доступны только роли `admin`
|
||
- Curator-маршруты (`/curator/*`) доступны ролям `curator` и `admin`
|
||
|
||
---
|
||
|
||
## Переменные окружения (`.env.example`)
|
||
|
||
```env
|
||
# База данных
|
||
DATABASE_URL="postgresql://lms_user:password@localhost:5432/lms_db"
|
||
|
||
# Better Auth
|
||
BETTER_AUTH_SECRET="generate-with-openssl-rand-base64-32"
|
||
BETTER_AUTH_URL="http://localhost:3000"
|
||
|
||
# Email (Resend)
|
||
RESEND_API_KEY=""
|
||
EMAIL_FROM="noreply@school.second-brain.ru"
|
||
|
||
# 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=""
|
||
S3_REGION="eu-central"
|
||
|
||
# Kinescope (добавить при получении платного плана)
|
||
# KINESCOPE_API_KEY=""
|
||
```
|
||
|
||
---
|
||
|
||
## Грабли уклада (проверено 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
|
||
- [ ] `npm run type-check` — нет ошибок TypeScript
|
||
- [ ] Новые `.env` переменные добавлены в `.env.example` (без значений)
|
||
- [ ] Миграция БД одобрена пользователем (если есть)
|
||
- [ ] Нет `console.log` в production-коде (только `console.error` для реальных ошибок)
|
||
- [ ] Нет захардкоженных секретов, URL-ов, ID
|
||
|
||
---
|
||
|
||
## Модель данных (основные сущности)
|
||
|
||
```
|
||
User (id, email, name, role, emailVerified) — управляется Better Auth
|
||
Session (id, userId, expiresAt, ...) — управляется Better Auth
|
||
Course (id, slug, title, description, coverImage, published)
|
||
Module (id, courseId, title, order)
|
||
Lesson (id, moduleId, title, order, content, kinescopeId, published)
|
||
CourseEnrollment (userId, courseId, enrolledAt)
|
||
LessonProgress (userId, lessonId, completedAt)
|
||
Quiz (id, lessonId)
|
||
QuizQuestion (id, quizId, text, type)
|
||
QuizOption (id, questionId, text, isCorrect)
|
||
QuizAttempt (id, userId, quizId, score, completedAt)
|
||
Homework (id, lessonId, description)
|
||
HomeworkSubmission (id, homeworkId, userId, text, files[], submittedAt)
|
||
HomeworkFeedback (id, submissionId, curatorId, text, createdAt)
|
||
LessonComment (id, lessonId, userId, text, createdAt)
|
||
```
|