Files
lms-sb/CLAUDE.md
adminsandClaude Opus 5 5ad0bd98f1 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
2026-09-12 12:55:46 +05:00

20 KiB
Raw Permalink Blame History

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

Команды

# Разработка
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)

# База данных
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)