Files
lms-sb/TECHNICAL.md
T
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

18 KiB
Raw Blame History

TECHNICAL — LMS Second Brain

Живая документация проекта. Обновляется по мере разработки.
Роадмап и планирование — в ROADMAP.md. Здесь — факты о том, как всё устроено.


Инфраструктура

Компонент Значение
Сервер Hetzner VPS — 8 vCPU / 16 GB RAM / 320 GB NVMe
IP 178.104.27.196
Домен LMS https://school.second-brain.ru
Reverse proxy Caddy (auto HTTPS через Let's Encrypt)
Порт приложения 3010 (внутри контейнера — 3000)
БД PostgreSQL 16 (контейнер lms-sb-db-1)
Object Storage Hetzner Object Storage, регион Nuremberg
Бакет second-brain-lms (публичный, read-only)
Endpoint S3 https://nbg1.your-objectstorage.com
Git-репозиторий https://git.second-brain.ru/admins/lms-sb
Email-сервис Resend, домен mailsend.second-brain.ru (verified)
From-адрес noreply@mailsend.second-brain.ru

Деплой

# На сервере: /root/digital-household/lms-sb/
git pull ...
docker compose -f docker-compose.prod.yml up -d --build

При старте контейнер автоматически запускает prisma migrate deploy, затем node server.js.

.env на сервере

Файл /root/digital-household/lms-sb/.env:

DB_PASSWORD=lms_cd5041e961a3050db359aa15
BETTER_AUTH_SECRET=<secret>
RESEND_API_KEY=re_VX7TCnjs_N2geRqvveHjVfbsSyr4VbmzL
EMAIL_FROM=noreply@mailsend.second-brain.ru
S3_ENDPOINT=https://nbg1.your-objectstorage.com
S3_BUCKET=second-brain-lms
S3_ACCESS_KEY=<ключ>
S3_SECRET_KEY=<секрет>
S3_REGION=eu-central

Стек

Слой Технология Версия
Фреймворк Next.js (App Router) 16.2.2
Язык TypeScript 5.x
UI React 19
Стили Tailwind CSS (CSS-based config) 4.x
UI-компоненты shadcn/ui (базируется на Base UI, не Radix) latest
ORM Prisma 7.x
Auth Better Auth 1.6.0
WYSIWYG TipTap 2.x
Drag-and-drop @dnd-kit latest
Шрифт Fira Mono (400/500/700, Latin + Cyrillic) Google Fonts
Email Resend latest
S3 @aws-sdk/client-s3 3.x
БД PostgreSQL 16

Важные нюансы стека

  • shadcn/ui v4 использует @base-ui/react, а не Radix. Нет asChild. Триггеры — обычные элементы.
  • Prisma 7 не генерирует index.ts. Импорт: from "@/generated/prisma/client", не from "@/generated/prisma".
  • Prisma 7 требует адаптер: new PrismaPg({ connectionString }) — иначе PrismaClient() бросает ошибку.
  • Better Auth использует scrypt по умолчанию. В этом проекте переключён на bcryptauth.ts настроены password.hash / password.verify).
  • NEXT_PUBLIC_* переменные запекаются при сборке. auth-client.ts не использует baseURL — клиент сам берёт window.location.origin.
  • Next.js 16 использует proxy.ts вместо middleware.ts (и экспортируемая функция называется proxy, не middleware).
  • Tailwind v4: конфиг только в CSS через @import "tailwindcss" и @theme. Нет tailwind.config.ts.

Дизайн-система

Стиль: Second Brain Aubade — типографский, монохромный, с газетным характером.

Токен Значение
Шрифт Fira Mono (весь UI)
Фон страницы #F5F5F0 (тёплый off-white)
Текст основной #323232 (тёмный уголь)
Текст вторичный #666666
Поверхность / surface #E8E8E0
Акцент / highlight #E8F0D8 (зелёный)
Divider / border #AAAAAA
Hover #D8D8D0
Фон сайдбара (тёмный) #2A2A28
Активный пункт сайдбара #E8F0D8 (зелёный)

Aubade-эффект — фирменный стиль карточек и кнопок:

  • Border: 2px solid #AAAAAA
  • Box-shadow: 4px 4px 0 0 #AAAAAA (смещение без размытия)
  • Hover: transform: translate(-2px, -2px) + shadow 6px 6px
  • Active (кнопка): transform: translate(2px, 2px) + shadow убирается

CSS-классы: .card-aubade, .btn-aubade, .btn-aubade-accent, .tag-aubade


Требования к медиафайлам

Обложка курса (Course.coverImage)

Параметр Требование
Соотношение сторон 16 : 9 (горизонтальный прямоугольник)
Рекомендуемое разрешение 1280 × 720 px (HD) или 1920 × 1080 px (Full HD)
Минимальное разрешение 800 × 450 px
Максимальный размер файла 5 MB
Форматы JPG, PNG, WebP
Цветовое пространство sRGB
Где хранится Hetzner Object Storage, бакет second-brain-lms, путь uploads/<uuid>.ext
Доступ Публичный URL (прямая ссылка на файл)

Пример URL: https://nbg1.your-objectstorage.com/second-brain-lms/uploads/abc123.jpg

Изображения в уроках (TipTap)

Параметр Требование
Соотношение сторон Любое — TipTap встраивает как <img> с max-width: 100%
Рекомендуемая ширина 1200 px (контент-зона урока)
Максимальный размер файла 10 MB
Форматы JPG, PNG, GIF, WebP
Где хранится Hetzner Object Storage, путь uploads/<uuid>.ext

PDF и файлы к уроку (Этап 2+)

Параметр Требование
Форматы PDF, ZIP, DOCX, XLSX, PPTX
Максимальный размер 100 MB
Где хранится Hetzner Object Storage, путь lessons/<lessonId>/files/<uuid>.ext

Аватары пользователей (если добавим)

Параметр Требование
Соотношение сторон 1 : 1 (квадрат)
Рекомендуемый размер 256 × 256 px
Максимальный размер файла 2 MB
Форматы JPG, PNG, WebP

Роли и доступ

Роль Маршруты Описание
admin /admin/*, /curator/*, /dashboard Полный доступ
curator /curator/*, /dashboard Проверка ДЗ, комментарии
student /dashboard, /courses/* Просмотр курсов, прогресс

Защита маршрутов — в src/proxy.ts + проверка сессии в каждом layout/page.


API-маршруты

Метод Путь Описание Кто может
POST /api/auth/[...all] Better Auth handler Все
POST /api/admin/upload Загрузка файла в S3, возвращает { url, key } admin
GET /api/pdf Чистый PDF из URL (Bearer-ключ или сессия) — см. раздел ниже платный студент, admin, curator

Чистый PDF (/tools/clean-pdf, /api/pdf)

Инструмент Obsidian Toolbox: превращает произвольный URL в чистый PDF (без рекламы и меню, с типографикой, оглавлением, A4/Letter, light/dark). Полный дизайн-документ: docs/specs/20260706-clean-pdf-design.md.

Пайплайн генерации:

URL студента
  → browserless (Chromium по WebSocket, playwright-core) загружает страницу
  → Next.js забирает итоговый HTML
  → Defuddle (JSDOM) выделяет основной контент
  → HTML-шаблон (типографика, A4/Letter, light/dark, оглавление)
  → Playwright page.pdf() в том же browserless
  → application/pdf в ответе

Доступ: любой платный студент — есть CourseEnrollment на курс вне FREE_COURSE_SLUG и не истёкший (expiresAt пуст или в будущем); admin/curator проходят гейт без курсов. banned перекрывает всё, включая admin. «Без ограничений» — только про гейт доступа: квоты (burst 5/мин и PDF_MONTHLY_LIMIT) считаются по userId одинаково для всех ролей. Видимость страницы гейтится флагом TOOLBOX_VISIBLE, но сам /api/pdf работает независимо от него (доступ проверяется отдельно). Ключ для внешнего API — модель PdfApiKey (sbpdf_<random>, ленивая генерация, регенерация инвалидирует старый).

Env-переменные:

Переменная Назначение
BROWSER_WS_URL WebSocket-адрес browserless (ws://browserless:3000 в compose, ws://localhost:3333 при туннеле локально)
BROWSERLESS_TOKEN Секрет browserless (TOKEN в его env) — общий и для сервиса, и для клиента в LMS
PDF_MONTHLY_LIMIT Лимит генераций в месяц на студента (по умолчанию 100), без пересборки
TOOLBOX_VISIBLE Видимость раздела /tools/* и ссылок на него. На /api/pdf не влияет
FREE_COURSE_SLUG Какой курс считается бесплатным (дефолт obsidian-start) — от него зависит, что считать платным enrollment
NEXT_PUBLIC_APP_URL Базовый адрес в Zotero-скрипте и curl-примере. ⚠️ NEXT_PUBLIC_* инлайнится на сборке, а задан только как runtime-env в compose → в образе работает фолбэк на прод-адрес (на staging скрипт выдаётся с прод-URL)

Контейнер browserless (ghcr.io/browserless/chromium, образ не запинен по версии). На проде порт не публикуется вовсе — доступ только по внутренней сети compose. В dev и на staging порт публикуется на loopback хоста (127.0.0.1:3333:3000) — именно на этом держится SSH-туннель для интеграционного теста; наружу он при этом не торчит.

SSRF-защита — честно про пределы: исходный URL проверяется резолвом DNS (assertPublicUrl: блок приватных и зарезервированных диапазонов, включая v4-mapped IPv6), а внутри browserless запросы страницы фильтруются по context.route + context.routeWebSocket.

Предел у этой защиты шире, чем «DNS-rebind»: assertPublicUrl вызывается ровно один раз — для исходного URL, а browser-level фильтр хостнеймы не резолвит вовсе — сверяет только строки localhost/*.local/*.internal и литеральные IP. Значит без проверки уходит любой новый хостнейм после первого перехода: цель HTTP-редиректа, субресурс страницы, fetch/XHR из JS, ws://. Достаточно редиректа на внутреннее имя — DNS-rebind даже не требуется.

Закрывается это только на сетевом уровне. Перед тем как поднять контейнер browserless на проде (а не перед флагом TOOLBOX_VISIBLE/api/pdf живёт независимо от него), нужно заблокировать исходящий трафик рендерера в 169.254.0.0/16 и RFC1918. Полный разбор — «Безопасность» и «Деплой» в docs/specs/20260706-clean-pdf-design.md и процесс SBT/02-Стандарты/Процессы/lms-deploy.md.


Структура БД (ключевые таблицы)

User            — id, email, name, role, emailVerified
Session         — Better Auth sessions
Account         — Better Auth credentials (bcrypt password)
Verification    — Better Auth email verification tokens

Category        — id, title, slug, order
Course          — id, slug, title, description, coverImage, published, order, categoryId
Module          — id, courseId, title, order
Lesson          — id, moduleId, title, content (JSON), kinescopeId, published, order
LessonFile      — id, lessonId, name, url, size

CourseEnrollment — userId + courseId (PK), enrolledAt, expiresAt
AccessLog       — id, courseId, userId, action, method, grantedById, note, createdAt
LessonProgress  — userId + lessonId (PK), completedAt

Quiz            — id, lessonId, showAnswers
QuizQuestion    — id, quizId, text, type (SINGLE/MULTIPLE/TEXT), order
QuizOption      — id, questionId, text, isCorrect, order
QuizAttempt     — id, userId, quizId, score, answers (JSON), completedAt

Homework        — id, lessonId, description
HomeworkSubmission — id, homeworkId, userId, text, files (JSON), submittedAt
HomeworkFeedback   — id, submissionId, curatorId, text, createdAt

LessonComment   — id, lessonId, userId, text, deleted, createdAt

Миграции: prisma/migrations/никогда не редактировать вручную.


Тестовые аккаунты (seed)

Email Пароль Роль
admin@second-brain.ru Password123! admin
curator@second-brain.ru Password123! curator
student@second-brain.ru Password123! student

Что сделано (по этапам)

Этап 0 — Каркас + Auth

  • Next.js 16.2.2 + TypeScript + Tailwind v4
  • PostgreSQL 16 + Prisma 7 + полная LMS-схема
  • Better Auth: email/password, роли, сессии
  • proxy.ts: защита маршрутов
  • Дашборды для 3 ролей (admin / curator / student)
  • Dockerfile multi-stage + docker-compose.prod.yml
  • Caddy: school.second-brain.ru → порт 3010

Этап 1 — CRUD курсов в админке

  • Список курсов: /admin/courses
  • Создание курса (диалог), редактирование, удаление
  • Обложка курса: загрузка в S3, требования — см. раздел «Медиафайлы»
  • Модули: drag-and-drop сортировка, CRUD
  • Уроки: drag-and-drop сортировка, CRUD
  • Редактор урока: TipTap (Bold, Italic, H2/H3, списки, цитата, код, ссылки, изображения)
  • Загрузка изображений в урок → S3
  • Поле Kinescope ID (текстовое)
  • Публикация / скрытие курса и урока
  • Управление доступом к курсу (выдать / отозвать)
  • Страница пользователей: /admin/users
  • Дизайн Second Brain Aubade (Fira Mono, #F5F5F0, карточки с тенью)

Этап 1.5 — Расширенное управление доступом

  • Категории курсов: /admin/categories, CRUD, привязка к курсу
  • Срок доступа: поле expiresAt при энролле, просроченный подсвечивается красным
  • Страница ученика /admin/users/[userId]: мультиэнролл (несколько курсов + срок)
  • История доступа: таблица AccessLog, отображается на странице курса и ученика
  • Hetzner Object Storage подключён: бакет second-brain-lms, Nuremberg

Этап 3 — Прогресс, ДЗ, Email

  • Прогресс студента: кнопка «Отметить как пройденный», галочки и прогресс-бар в сайдбаре, прогресс на дашборде
  • Домашние задания: редактор ДЗ в уроке (admin), сдача текстом + файлами (student), проверка и фидбек (curator/admin)
  • Куратор-панель: /curator/dashboard со статистикой, /curator/homework список, страница проверки
  • Админ имеет доступ к куратор-маршрутам через пункт «ДЗ на проверку» в сайдбаре
  • Email уведомления через Resend: доступ к курсу, новая работа, фидбек получен, приветствие

Известные ограничения / технический долг

  • requireEmailVerification: true в Better Auth — seed-пользователи вставлены напрямую через SQL с emailVerified = true
  • Загрузка файлов через /api/admin/upload — нет ограничения по размеру на уровне Next.js (только S3). При необходимости добавить middleware с проверкой Content-Length
  • Drag-and-drop обновляет порядок через Server Actions — при быстрых последовательных перетаскиваниях возможны race conditions (некритично для MVP)
  • expiresAt проверяется только в UI (красная подсветка). Блокировка доступа по сроку на уровне middleware — в рамках Этапа 2