Files
lms-sb/TECHNICAL.md
T
adminsandClaude Opus 5.5 74d51f72bf Remove Resend API key from TECHNICAL.md
The key was masked on the build server on 20261105 but never committed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 14:39:42 +05:00

308 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 |
### Деплой
```bash
# На сервере: /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_…(ключ в .env, не в документации)
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` по умолчанию. В этом проекте **переключён на bcrypt** (в `auth.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`](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`](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