Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8223cbb963 | ||
|
|
273cb2d1b4 | ||
|
|
5ad0bd98f1 | ||
|
|
03aaec76c2 | ||
|
|
a92592ec63 | ||
|
|
8fc9708233 | ||
|
|
20a25695bc | ||
|
|
005cd329b2 | ||
|
|
917253990b | ||
|
|
2498d0f8da | ||
|
|
08e212365d | ||
|
|
92fd88964e | ||
|
|
33c0d3cafd | ||
|
|
5d94949810 | ||
|
|
98cc023709 | ||
|
|
53caf54b03 | ||
|
|
4734b99bea | ||
|
|
118fa3961f | ||
|
|
1ccf994112 | ||
|
|
02b16e311b | ||
|
|
f143f58cea | ||
|
|
fd40b1e394 | ||
|
|
0cbea10e1a | ||
|
|
668dd29397 | ||
|
|
9ad4d762d7 | ||
|
|
c50a295290 | ||
|
|
0c56b0b809 | ||
|
|
89383fab1d | ||
|
|
ef46cdbe29 | ||
|
|
b49680c118 | ||
|
|
1d5b4f9251 | ||
|
|
6316c09993 | ||
|
|
9a74339087 | ||
|
|
2c619be043 | ||
|
|
52073fd914 | ||
|
|
f5768331cf | ||
|
|
541853bb1d | ||
|
|
e795c4d655 | ||
|
|
aaf84ea931 | ||
|
|
16b17685f6 | ||
|
|
5b81a7b594 |
@@ -45,3 +45,9 @@ WOW_MOMENT_LESSON_ID="obs-start-l2-006"
|
|||||||
# Obsidian Toolbox (/tools): "true" — показывать точки входа и роуты (staging);
|
# Obsidian Toolbox (/tools): "true" — показывать точки входа и роуты (staging);
|
||||||
# не задано/иное — скрыто, /tools редиректит на /dashboard (prod, пока дорабатываем)
|
# не задано/иное — скрыто, /tools редиректит на /dashboard (prod, пока дорабатываем)
|
||||||
TOOLBOX_VISIBLE=""
|
TOOLBOX_VISIBLE=""
|
||||||
|
|
||||||
|
# Чистый PDF (browserless на staging/prod; локально — SSH-туннель на staging)
|
||||||
|
BROWSER_WS_URL="ws://localhost:3333"
|
||||||
|
# Секрет browserless (TOKEN в его env) — на прод/staging генерировать: openssl rand -hex 24
|
||||||
|
BROWSERLESS_TOKEN=""
|
||||||
|
PDF_MONTHLY_LIMIT="100"
|
||||||
|
|||||||
@@ -48,3 +48,9 @@ next-env.d.ts
|
|||||||
|
|
||||||
# Claude Code local plugins (external git repos, не коммитим)
|
# Claude Code local plugins (external git repos, не коммитим)
|
||||||
.claude/plugins/
|
.claude/plugins/
|
||||||
|
|
||||||
|
# agent-browser auth state
|
||||||
|
lms-auth.json
|
||||||
|
|
||||||
|
# SDD scratch
|
||||||
|
.superpowers/
|
||||||
|
|||||||
@@ -36,8 +36,7 @@ Production: **https://school.second-brain.ru**
|
|||||||
Эти технологии отличаются от того, что содержится в обучающих данных большинства моделей. **Читай документацию перед написанием кода.**
|
Эти технологии отличаются от того, что содержится в обучающих данных большинства моделей. **Читай документацию перед написанием кода.**
|
||||||
|
|
||||||
### Next.js 16.2.2
|
### Next.js 16.2.2
|
||||||
- Используется `proxy.ts` вместо `middleware.ts`
|
- Защита маршрутов — `src/proxy.ts` **НЕ существует**: весь код в `src/middleware.ts`, экспорт называется `middleware` (проверено 20260912). Next.js 16 поддерживает `proxy.ts`, но в этом проекте он не заведён — не создавать второй файл, править `middleware.ts`
|
||||||
- Экспортируемая функция называется `proxy`, не `middleware`
|
|
||||||
- Перед написанием кода смотри `node_modules/next/dist/docs/`
|
- Перед написанием кода смотри `node_modules/next/dist/docs/`
|
||||||
|
|
||||||
### Tailwind CSS v4
|
### Tailwind CSS v4
|
||||||
@@ -119,8 +118,7 @@ lms-system/
|
|||||||
│ │ ├── email.ts # Resend email helpers
|
│ │ ├── email.ts # Resend email helpers
|
||||||
│ │ └── utils.ts # cn() и утилиты
|
│ │ └── utils.ts # cn() и утилиты
|
||||||
│ ├── types/ # TypeScript-типы
|
│ ├── types/ # TypeScript-типы
|
||||||
│ ├── proxy.ts # Auth middleware (защита маршрутов)
|
│ └── middleware.ts # Auth middleware (защита маршрутов) + PUBLIC_ROUTES
|
||||||
│ └── middleware.ts # Обёртка над proxy
|
|
||||||
├── prisma/
|
├── prisma/
|
||||||
│ ├── schema.prisma # Схема БД (~314 строк)
|
│ ├── schema.prisma # Схема БД (~314 строк)
|
||||||
│ ├── seed.ts # Тестовые данные
|
│ ├── seed.ts # Тестовые данные
|
||||||
@@ -142,7 +140,7 @@ lms-system/
|
|||||||
| `curator` | `/curator/*`, `/dashboard` | Проверка ДЗ, комментарии |
|
| `curator` | `/curator/*`, `/dashboard` | Проверка ДЗ, комментарии |
|
||||||
| `student` | `/dashboard`, `/courses/*` | Просмотр курсов, прогресс |
|
| `student` | `/dashboard`, `/courses/*` | Просмотр курсов, прогресс |
|
||||||
|
|
||||||
Защита маршрутов — в `src/proxy.ts` + проверка сессии в layout/page.
|
Защита маршрутов — в `src/middleware.ts` (массив `PUBLIC_ROUTES`, сверка по `startsWith`) + проверка сессии в layout/page.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
| TipTap | 2.x | WYSIWYG-редактор уроков |
|
| TipTap | 2.x | WYSIWYG-редактор уроков |
|
||||||
| @kinescope/react-kinescope-player | latest | Видеоплеер |
|
| @kinescope/react-kinescope-player | latest | Видеоплеер |
|
||||||
| Resend | latest | Email-уведомления |
|
| Resend | latest | Email-уведомления |
|
||||||
| AWS SDK (S3) | 3.x | Hetzner Object Storage |
|
| AWS SDK (S3) | 3.x | Backblaze B2 (раздача через Bunny CDN) |
|
||||||
| Zod | 3.x | Валидация данных |
|
| Zod | 3.x | Валидация данных |
|
||||||
| Docker Compose | 2.x | Локальная разработка и деплой |
|
| Docker Compose | 2.x | Локальная разработка и деплой |
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ lms-system/
|
|||||||
│ │ ├── auth.ts # Better Auth config (сервер)
|
│ │ ├── auth.ts # Better Auth config (сервер)
|
||||||
│ │ ├── auth-client.ts # Better Auth client (браузер)
|
│ │ ├── auth-client.ts # Better Auth client (браузер)
|
||||||
│ │ ├── prisma.ts # Prisma singleton client
|
│ │ ├── prisma.ts # Prisma singleton client
|
||||||
│ │ ├── s3.ts # Hetzner Object Storage клиент
|
│ │ ├── s3.ts # Backblaze B2 (S3-совместимый) клиент
|
||||||
│ │ ├── email.ts # Resend email helpers
|
│ │ ├── email.ts # Resend email helpers
|
||||||
│ │ └── utils.ts # cn() и прочие утилиты
|
│ │ └── utils.ts # cn() и прочие утилиты
|
||||||
│ ├── types/
|
│ ├── types/
|
||||||
@@ -135,7 +135,7 @@ docker compose -f docker-compose.prod.yml logs -f app
|
|||||||
- Перед `prisma migrate deploy` на production — делать бэкап БД
|
- Перед `prisma migrate deploy` на production — делать бэкап БД
|
||||||
|
|
||||||
### Файлы и контент
|
### Файлы и контент
|
||||||
- Загружаемые файлы (ДЗ, PDF) — только через Hetzner Object Storage, никогда на диск VPS
|
- Загружаемые файлы (ДЗ, PDF, картинки комментариев) — только в Backblaze B2, никогда на диск VPS
|
||||||
- Секреты (API-ключи, токены, строки подключения) — **только в `.env.local`**, в коде запрещено
|
- Секреты (API-ключи, токены, строки подключения) — **только в `.env.local`**, в коде запрещено
|
||||||
- `.env.example` всегда обновлять при добавлении новых переменных (без реальных значений)
|
- `.env.example` всегда обновлять при добавлении новых переменных (без реальных значений)
|
||||||
|
|
||||||
@@ -168,8 +168,9 @@ BETTER_AUTH_URL="http://localhost:3000"
|
|||||||
RESEND_API_KEY=""
|
RESEND_API_KEY=""
|
||||||
EMAIL_FROM="noreply@school.second-brain.ru"
|
EMAIL_FROM="noreply@school.second-brain.ru"
|
||||||
|
|
||||||
# Hetzner Object Storage (S3-совместимый)
|
# Backblaze B2 (S3-совместимый), раздача через Bunny CDN
|
||||||
S3_ENDPOINT="https://fsn1.your-objectstorage.com"
|
S3_ENDPOINT="https://s3.eu-central-003.backblazeb2.com"
|
||||||
|
S3_CDN_URL="https://files.second-brain.ru"
|
||||||
S3_BUCKET="lms-uploads"
|
S3_BUCKET="lms-uploads"
|
||||||
S3_ACCESS_KEY=""
|
S3_ACCESS_KEY=""
|
||||||
S3_SECRET_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
|
- [ ] `npm run lint` — нет ошибок ESLint
|
||||||
|
|||||||
@@ -171,6 +171,46 @@ CSS-классы: `.card-aubade`, `.btn-aubade`, `.btn-aubade-accent`, `.tag-aub
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `POST` | `/api/auth/[...all]` | Better Auth handler | Все |
|
| `POST` | `/api/auth/[...all]` | Better Auth handler | Все |
|
||||||
| `POST` | `/api/admin/upload` | Загрузка файла в S3, возвращает `{ url, key }` | admin |
|
| `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`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+21
-2
@@ -13,24 +13,43 @@ services:
|
|||||||
NEXT_PUBLIC_APP_URL: "https://school.second-brain.ru"
|
NEXT_PUBLIC_APP_URL: "https://school.second-brain.ru"
|
||||||
RESEND_API_KEY: "${RESEND_API_KEY}"
|
RESEND_API_KEY: "${RESEND_API_KEY}"
|
||||||
EMAIL_FROM: "${EMAIL_FROM}"
|
EMAIL_FROM: "${EMAIL_FROM}"
|
||||||
|
BROWSER_WS_URL: "ws://browserless:3000"
|
||||||
|
BROWSERLESS_TOKEN: "${BROWSERLESS_TOKEN}"
|
||||||
|
PDF_MONTHLY_LIMIT: "${PDF_MONTHLY_LIMIT:-100}"
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
browserless:
|
||||||
|
condition: service_started
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: postgres:16-alpine
|
# ⚠️ PG18 сменил раскладку: том монтируется в /var/lib/postgresql (НЕ .../data),
|
||||||
|
# кластер лежит в подкаталоге 18/docker. Мажор прода сделан 09.07.2026.
|
||||||
|
# Не откатывать тег и mount по отдельности — иначе initdb на непустом томе → рестарт-луп.
|
||||||
|
# В этом каталоге на Hetzner том lms-sb_postgres_data = hot-standby (тоже PG18).
|
||||||
|
image: postgres:18-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: lms_user
|
POSTGRES_USER: lms_user
|
||||||
POSTGRES_PASSWORD: "${DB_PASSWORD}"
|
POSTGRES_PASSWORD: "${DB_PASSWORD}"
|
||||||
POSTGRES_DB: lms_db
|
POSTGRES_DB: lms_db
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U lms_user -d lms_db"]
|
test: ["CMD-SHELL", "pg_isready -U lms_user -d lms_db"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
|
|
||||||
|
browserless:
|
||||||
|
image: ghcr.io/browserless/chromium
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
TOKEN: "${BROWSERLESS_TOKEN}"
|
||||||
|
CONCURRENT: "2"
|
||||||
|
QUEUED: "10"
|
||||||
|
TIMEOUT: "120000"
|
||||||
|
mem_limit: 1g
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
|||||||
@@ -11,5 +11,15 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql
|
- postgres_data:/var/lib/postgresql
|
||||||
|
|
||||||
|
browserless:
|
||||||
|
image: ghcr.io/browserless/chromium
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:3333:3000"
|
||||||
|
environment:
|
||||||
|
CONCURRENT: "2"
|
||||||
|
QUEUED: "10"
|
||||||
|
TIMEOUT: "120000"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Чистый PDF — снимок состояния
|
||||||
|
|
||||||
|
**Дата снимка:** 20260912
|
||||||
|
**Фаза:** слито в `main`, работает на staging, на прод не выкачено
|
||||||
|
**Охват:** только фича «Чистый PDF». Состояние остальных частей LMS здесь не отражено.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Коротко
|
||||||
|
|
||||||
|
Инструмент `URL → чистый PDF` в Obsidian Toolbox: веб-форма в кабинете, персональный API-ключ + скрипт для Zotero (Actions & Tags), открытый curl API. Рендер — отдельный контейнер browserless (Chromium по CDP), контент выделяет Defuddle, печатает `page.pdf()`.
|
||||||
|
|
||||||
|
Сделано 20260706 по плану из 11 задач (субагентное исполнение с ревью после каждой). Merge `53caf54`, 21 коммит. Финальное whole-branch ревью — READY TO MERGE.
|
||||||
|
|
||||||
|
## Состояние сред (проверено 20260912)
|
||||||
|
|
||||||
|
| Среда | Состояние |
|
||||||
|
|---|---|
|
||||||
|
| `main` | Фича на месте и цела: файлы побайтово совпадают с merge `53caf54`; `/api/pdf` в `PUBLIC_ROUTES`, регистрация инструмента и модель `PdfApiKey` не пострадали от августовских коммитов |
|
||||||
|
| **Прод** (Hoster.kz) | **Выключено.** Контейнера `browserless` нет, `BROWSER_WS_URL`/`BROWSERLESS_TOKEN`/`TOOLBOX_VISIBLE` в `/root/lms-sb/.env` не заданы. Репозиторный `docker-compose.prod.yml` намеренно расходится с живым `/root/lms-sb/docker-compose.yml` |
|
||||||
|
| **Staging** (Hetzner) | **Работает.** Стенд стоит ровно на `53caf54` и отстал от `main` на августовские коммиты. `TOOLBOX_VISIBLE=true`, контейнеры `lms-staging-app-1` и `lms-staging-browserless-1` живы с 20260706 |
|
||||||
|
|
||||||
|
Проверено E2E на staging 20260706: 401 без ключа, 403 без платного доступа, 200 с реальным PDF (576 КБ, 4 страницы), 422 на SSRF-пробы (`192.168.x`, `localhost:5432`, `[::ffff:169.254.169.254]`). Страница и перевыпуск ключа проверены в живом браузере.
|
||||||
|
|
||||||
|
## Что где лежит
|
||||||
|
|
||||||
|
- Код: `src/lib/clean-pdf/` (`ssrf.ts`, `template.ts`, `generate.ts`, `access.ts`, `keys.ts`, `api-key.ts`, `zotero-script.ts`), роут `src/app/api/pdf/route.ts`, страница `src/app/(student)/tools/clean-pdf/`, действие `src/lib/actions/pdf-key-actions.ts`
|
||||||
|
- Данные: модель `PdfApiKey`, миграция `20260706120000_add_pdf_api_key`; учёт — существующая `ToolUsage`
|
||||||
|
- Документы: спека `docs/specs/20260706-clean-pdf-design.md`, план `docs/superpowers/plans/20260706-clean-pdf.md`, раздел в `TECHNICAL.md`
|
||||||
|
- Уклад и грабли: раздел «Грабли уклада» в `CLAUDE.md`
|
||||||
|
- В базе знаний: `SBT/00-Стек/Сервисы/clean-pdf.md`, `SBT/03-Опыт/Постмортемы/20260706-clean-pdf-ssrf-обходы.md`, гейт в `SBT/02-Стандарты/Процессы/lms-deploy.md`
|
||||||
|
|
||||||
|
## ⚠️ Гейт перед прод-релизом
|
||||||
|
|
||||||
|
**Закрыть исходящий трафик контейнера `browserless`** — заблокировать `169.254.0.0/16` и RFC1918 через `internal`-сеть compose или `DOCKER-USER`. На 20260912 не сделано ни в репозитории (у сервиса нет `networks`/`internal`), ни на хосте (`DOCKER-USER` пуста).
|
||||||
|
|
||||||
|
**Гейт привязан к рендереру, а не к флагу.** `TOOLBOX_VISIBLE` прячет только страницы `/tools/*`; `/api/pdf` лежит в `PUBLIC_ROUTES` и авторизуется сам, поэтому фича оживёт для любого платного студента в момент появления `browserless` и `BROWSER_WS_URL` — ещё до поднятия флага.
|
||||||
|
|
||||||
|
Причина: `assertPublicUrl` резолвит DNS **один раз, для исходного URL**, а фильтр внутри браузера хостнеймы не резолвит вовсе. Непроверенным уходит любой новый хостнейм после первого перехода (редирект, субресурс, `fetch`, `ws://`) — DNS-rebind для обхода не нужен.
|
||||||
|
|
||||||
|
## Хвосты (по убыванию важности)
|
||||||
|
|
||||||
|
1. **Egress-изоляция browserless** — гейт выше. Блокирует прод-релиз.
|
||||||
|
2. **Фикс WebSocket-обхода (`routeWebSocket`) не покрыт тестом.** Баг с v4-mapped IPv6 закрыт юнит-тестами `ssrf.test.ts`, а браузерный фильтр не тестируется вовсе (единственный тест генерации за `RUN_PDF_INTEGRATION`). Регрессия пройдёт молча.
|
||||||
|
3. **Образ browserless не запинен** — `ghcr.io/browserless/chromium` без тега в обоих compose. Контейнер исполняет контент атакующего; обновление меняет Chromium и поведение CDP-слоя, на котором держатся фильтры.
|
||||||
|
4. **`NEXT_PUBLIC_APP_URL` не доезжает до образа** — задан только как runtime-env в compose, а `NEXT_PUBLIC_*` инлайнятся на сборке. Работает захардкоженный фолбэк на прод-адрес: на staging студенту выдаются Zotero-скрипт и curl с **прод**-URL. Лечится `--build-arg`, как у `NEXT_PUBLIC_TURNSTILE_SITE_KEY`.
|
||||||
|
5. **Ключи `PdfApiKey` лежат в БД открытым текстом** (поиск прямым `findUnique`). Дампы, реплика на standby и PITR содержат рабочие ключи. Ущерб ограничен (только генерация в пределах лимита, доступ проверяется на каждый запрос), но при релизе стоит перейти на хеш.
|
||||||
|
6. **Лимит не расходуется неуспешными генерациями** — `ToolUsage` пишется строго после успешного рендера, поэтому ни месячная квота, ни burst не тратятся на отказы. Ограниченный self-DoS тяжёлыми URL. Рассмотреть подсчёт попыток.
|
||||||
|
7. **`RenderError` обёрнута только часть вызовов браузера** (`connectOverCDP`, `goto`, таймаут печати). `newContext`, `newPage`, `content`, `route`/`routeWebSocket` и reject самого `pdf()` дают сырую ошибку Playwright — её ловит catch-all роута и отдаёт 504.
|
||||||
|
|
||||||
|
Маркеров TODO/FIXME в коде фичи нет — хвосты живут в документах.
|
||||||
|
|
||||||
|
## Если возвращаемся к работе
|
||||||
|
|
||||||
|
1. Прочитать этот файл и «Грабли уклада» в `CLAUDE.md`.
|
||||||
|
2. Перед любым касанием прода — `SBT/02-Стандарты/Процессы/lms-deploy.md` (там гейт).
|
||||||
|
3. Staging отстал от `main` — перед проверками подтянуть ветку и передеплоить (`bash ~/Documents/Claude/scripts/deploy-staging.sh`; скрипт иногда не пересоздаёт контейнер, тогда вручную `docker compose up -d app`).
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# Чистый PDF — инструмент «URL → PDF» в LMS
|
||||||
|
|
||||||
|
Дата: 20260706
|
||||||
|
Статус: дизайн утверждается
|
||||||
|
Референс: Clean PDF (`pdf.brainysnipe.ru`) из поста https://t.me/flowing_abyss/94, инструкция https://flowing-abyss.com/Clean-PDF-for-Zotero
|
||||||
|
|
||||||
|
## Что делаем
|
||||||
|
|
||||||
|
Инструмент для студентов школы: превращает любую веб-страницу в чистый PDF — без рекламы, меню и мусора, с нормальной типографикой, оглавлением, форматами A4/Letter и светлой/тёмной темой. Два сценария плюс открытый API:
|
||||||
|
|
||||||
|
1. **Веб-форма** в кабинете (`/tools/clean-pdf`): вставил URL — скачал PDF.
|
||||||
|
2. **Zotero**: персональный API-ключ + скрипт для плагина Actions & Tags — правый клик на записи → PDF генерируется и прикрепляется к ней.
|
||||||
|
3. **Открытый API** для продвинутых: `curl` с Bearer-ключом, документация на странице инструмента.
|
||||||
|
|
||||||
|
## Принятые решения
|
||||||
|
|
||||||
|
| Вопрос | Решение |
|
||||||
|
|---|---|
|
||||||
|
| Архитектура | Всё внутри LMS (подход B): страница и API в Next.js, рендер — отдельный контейнер browserless в том же compose |
|
||||||
|
| Кому доступ | Любой платный студент: есть `CourseEnrollment` на курс со slug ≠ `FREE_COURSE_SLUG`; admin/curator — без ограничений |
|
||||||
|
| Видимость | Часть Obsidian Toolbox, живёт под существующим флагом `TOOLBOX_VISIBLE`; включение на проде — отдельное решение позже |
|
||||||
|
| Лимит | 100 генераций/месяц на студента, env `PDF_MONTHLY_LIMIT` (без пересборки), плюс burst-лимит 5/мин |
|
||||||
|
|
||||||
|
## Пайплайн генерации
|
||||||
|
|
||||||
|
```
|
||||||
|
URL студента
|
||||||
|
→ Chromium (browserless) загружает страницу (JS отрабатывает)
|
||||||
|
→ Next.js забирает итоговый HTML
|
||||||
|
→ Defuddle (npm, через JSDOM) выделяет главный контент
|
||||||
|
→ чистый HTML-шаблон: типографика, A4/Letter, light/dark, оглавление из заголовков
|
||||||
|
→ Playwright page.pdf() в том же browserless
|
||||||
|
→ файл application/pdf в ответ
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Defuddle** — библиотека kepano (автор Obsidian), та же, что у референса: https://github.com/kepano/defuddle
|
||||||
|
- **browserless/chromium** — отдельный контейнер в docker-compose: `MAX_CONCURRENT_SESSIONS=2`, встроенная очередь, `mem_limit` ~1 ГБ, порт наружу не публикуется.
|
||||||
|
- Next.js подключается по WebSocket через `playwright-core` (только клиент, Chromium в образ LMS не попадает — образ не растёт).
|
||||||
|
- Таймаут всего пайплайна ~90 сек; понятные JSON-ошибки: URL недоступен, лимит исчерпан, нет платного доступа.
|
||||||
|
|
||||||
|
## Данные (Prisma)
|
||||||
|
|
||||||
|
Новая модель:
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model PdfApiKey {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String @unique
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
key String @unique // формат sbpdf_<random>, показывается студенту
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Ключ создаётся лениво при первом заходе на страницу инструмента.
|
||||||
|
- Перегенерация заменяет ключ (старый перестаёт работать сразу).
|
||||||
|
- Учёт использований — существующая `ToolUsage` с `tool = "clean-pdf-generate"`; месячный счётчик = COUNT за календарный месяц. Именно отдельный идентификатор: `CopyButton` на странице логирует копирования как `tool = "clean-pdf"`, и они не должны тратить лимит генераций.
|
||||||
|
- Проверка платного доступа выполняется на каждый запрос → рефанд или блокировка аккаунта автоматически отключает и веб, и ключ. Источник истины один — БД LMS.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
`GET /api/pdf?url=<...>&format=A4|Letter&theme=light|dark`
|
||||||
|
|
||||||
|
- **Авторизация, два пути**: заголовок `Authorization: Bearer sbpdf_...` (Zotero, curl) **или** активная сессия Better Auth (веб-форма — студенту ключ для веба не нужен).
|
||||||
|
- Роут добавляется в `PUBLIC_ROUTES` в `middleware.ts` (иначе 307 → /login — известные грабли).
|
||||||
|
- Ответ: `application/pdf` + заголовки `X-Uses-Count` / `X-Max-Uses` (их же показывает Zotero-скрипт).
|
||||||
|
- Ошибки: JSON `{ error }` c честными статусами (401 нет ключа/сессии, 403 нет платного доступа, 429 лимит, 422 плохой URL, 504 не отрендерилось).
|
||||||
|
|
||||||
|
## Страница /tools/clean-pdf
|
||||||
|
|
||||||
|
По образцу существующих инструментов тулбокса (реестр `TOOLS`, `ToolCard`, страница + клиентская форма):
|
||||||
|
|
||||||
|
1. **Форма**: поле URL, выбор формата и темы, кнопка «Скачать PDF», счётчик «использовано N из 100 в этом месяце».
|
||||||
|
2. **Блок «Zotero»**: персональный ключ (показать/скопировать/перегенерировать), пошаговая инструкция по Actions & Tags, готовый скрипт с уже подставленными адресом школы и ключом студента (адаптация скрипта референса: `SERVICE_URL = https://school.second-brain.ru`, endpoint `/api/pdf`).
|
||||||
|
3. **Блок «API»**: пример curl.
|
||||||
|
|
||||||
|
Дизайн — строго ДС-2 (перед вёрсткой перечитать `SBT/02-Стандарты/Дизайн-LMS/DESIGN.md`).
|
||||||
|
|
||||||
|
## Безопасность
|
||||||
|
|
||||||
|
Сервис скачивает произвольные URL изнутри прод-стенда — главный риск SSRF:
|
||||||
|
|
||||||
|
- Только `http://` и `https://`.
|
||||||
|
- Перед загрузкой — резолв DNS и блокировка приватных/зарезервированных диапазонов: localhost/127.x, 10.x, 172.16–31.x, 192.168.x, 169.254.x (метаданные облаков), ::1, fc00::/7, плюс docker-хостнеймы стенда (`db`, `app`, `browserless`).
|
||||||
|
- Внутри browserless — перехват сетевых запросов страницы (`context.route`/`context.routeWebSocket`): блокирует буквальные приватные/зарезервированные IP и хосты `localhost`/`*.local`/`*.internal`. **Честно про предел этой защиты:** `assertPublicUrl` резолвит DNS **ровно один раз — для исходного URL**, а browser-level фильтр хостнеймы не резолвит вовсе. Значит без проверки уходит **любой новый хостнейм после первого перехода**: цель редиректа, субресурс, `fetch` из JS, `ws://`. DNS-rebind для обхода даже не нужен — достаточно редиректа на внутреннее имя. Остаточный риск закрывается только на сетевом уровне — см. «Деплой». *(уточнено 20260912 аудитом кода)*
|
||||||
|
- Порт browserless наружу не публикуется, доступен только приложению по внутренней сети compose.
|
||||||
|
- Куки/учётные данные пользователя на целевую страницу не передаются.
|
||||||
|
- Санитайз имени файла в `Content-Disposition`.
|
||||||
|
- Лимиты: 100/мес (env) + burst 5/мин на пользователя; сверху — очередь browserless (2 конкурентных рендера).
|
||||||
|
- ⚠️ Follow-up (не блокирует релиз): оба счётчика лимита считают только **успешные** генерации (`ToolUsage` пишется после успешного рендера) — неудачные попытки (таймаут, 5xx с целевого сайта, зависший рендер) лимит не расходуют. Потенциальный ограниченный self-DoS повторными запросами к тяжёлым/неотвечающим URL. Рассмотреть подсчёт попыток, а не только успехов, отдельной задачей.
|
||||||
|
|
||||||
|
## Деплой
|
||||||
|
|
||||||
|
- `docker-compose.yml` (dev) и `docker-compose.prod.yml`: сервис `browserless` (образ `ghcr.io/browserless/chromium`), внутренняя сеть, лимиты памяти.
|
||||||
|
- env: `BROWSER_WS_URL`, `PDF_MONTHLY_LIMIT=100`, существующий `TOOLBOX_VISIBLE`.
|
||||||
|
- Prisma-миграция (`PdfApiKey`).
|
||||||
|
- Стандартная схема деплоя LMS: сборка на Hetzner → `docker save | ssh | docker load` на Hoster.kz; browserless на Hoster.kz — обычный `docker pull`.
|
||||||
|
- Hot-standby на Hetzner получает тот же compose (репликация БД уже покрывает `PdfApiKey` и `ToolUsage`).
|
||||||
|
|
||||||
|
> ⚠️ **Перед тем как поднять контейнер `browserless` на проде, обязательно захардить его сетевой egress** — заблокировать `169.254.0.0/16` (cloud-metadata) и RFC1918 (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`) на уровне хост-файрвола (`DOCKER-USER`) или выделенной internal-only docker-сети.
|
||||||
|
>
|
||||||
|
> **Гейт привязан к рендереру, а не к флагу** *(уточнено 20260912)*: `TOOLBOX_VISIBLE` прячет только страницы `/tools/*`, а роут `/api/pdf` лежит в `PUBLIC_ROUTES` и авторизуется сам — он станет рабочим для любого платного студента в момент появления `browserless` и `BROWSER_WS_URL`, ещё до поднятия флага. Именно это закрывает остаточный SSRF-риск из раздела «Безопасность»: browser-level фильтр его не закрывает, потому что не переразрешает хостнеймы.
|
||||||
|
|
||||||
|
## Тестирование
|
||||||
|
|
||||||
|
- Юнит: SSRF-валидатор (таблица адресов → допуск/блок), пайплайн Defuddle → HTML-шаблон на фикстурах.
|
||||||
|
- Интеграция на staging: рендер 3–5 реальных статей (лонгрид, статья с картинками, JS-тяжёлая страница), проверка лимитов и обоих путей авторизации.
|
||||||
|
- Zotero-скрипт — ручная проверка на живой библиотеке.
|
||||||
|
- Веб-форма — e2e через agent-browser в `--headed` (headless режет антиспам — известные грабли).
|
||||||
|
|
||||||
|
## Вне рамок (YAGNI)
|
||||||
|
|
||||||
|
- Отдельный домен `pdf.second-brain.ru` — не нужен, всё на `school.second-brain.ru`.
|
||||||
|
- Хранение сгенерированных PDF на сервере — файл отдаётся сразу и не сохраняется.
|
||||||
|
- Пер-курсовый гейтинг, тарифные лимиты, платные квоты — не сейчас.
|
||||||
|
- Освещение в рассылке/анонсы — после включения `TOOLBOX_VISIBLE` на проде, отдельной задачей.
|
||||||
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -3,7 +3,7 @@ import type { NextConfig } from "next";
|
|||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
transpilePackages: ["unified", "remark-parse"],
|
transpilePackages: ["unified", "remark-parse"],
|
||||||
serverExternalPackages: ["@prisma/client", "@prisma/adapter-pg", "pg"],
|
serverExternalPackages: ["@prisma/client", "@prisma/adapter-pg", "pg", "jsdom", "playwright-core", "defuddle"],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
Generated
+1616
-3
File diff suppressed because it is too large
Load Diff
@@ -34,16 +34,22 @@
|
|||||||
"better-auth": "^1.6.0",
|
"better-auth": "^1.6.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"defuddle": "^0.19.1",
|
||||||
"disposable-email-domains": "^1.0.62",
|
"disposable-email-domains": "^1.0.62",
|
||||||
"force-graph": "^1.51.4",
|
"force-graph": "^1.51.4",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"iconv-lite": "^0.7.2",
|
"iconv-lite": "^0.7.2",
|
||||||
|
"jsdom": "^29.1.1",
|
||||||
"lucide-react": "^1.7.0",
|
"lucide-react": "^1.7.0",
|
||||||
"next": "16.2.2",
|
"next": "16.2.2",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
|
"playwright-core": "^1.61.1",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"rehype-sanitize": "^6.0.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"remark-parse": "^11.0.0",
|
"remark-parse": "^11.0.0",
|
||||||
"resend": "^6.10.0",
|
"resend": "^6.10.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
@@ -54,6 +60,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/jsdom": "^28.0.3",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/pg": "^8.20.0",
|
"@types/pg": "^8.20.0",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- Чистый PDF — персональный API-ключ студента (Zotero/curl)
|
||||||
|
CREATE TABLE "PdfApiKey" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"key" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "PdfApiKey_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "PdfApiKey_userId_key" ON "PdfApiKey"("userId");
|
||||||
|
CREATE UNIQUE INDEX "PdfApiKey_key_key" ON "PdfApiKey"("key");
|
||||||
|
|
||||||
|
ALTER TABLE "PdfApiKey" ADD CONSTRAINT "PdfApiKey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- Комментарии под уроками: метка «изменено» (ставится только при редактировании текста)
|
||||||
|
ALTER TABLE "LessonComment" ADD COLUMN "editedAt" TIMESTAMP(3);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- Идентификатор клика Google Ads на пользователе.
|
||||||
|
-- Рекламу по обучающим запросам ведём на бесплатный вход в школу, поэтому
|
||||||
|
-- конверсию «регистрация» отправляем в Google серверно (src/lib/ads-conversion.ts),
|
||||||
|
-- а не тегом на странице: после регистрации человека сразу редиректит в кабинет.
|
||||||
|
--
|
||||||
|
-- IF NOT EXISTS — колонка уже заведена на проде вручную 20260813, чтобы включить
|
||||||
|
-- сбор до выката образа. На чистой базе миграция создаст её обычным порядком.
|
||||||
|
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "gclid" TEXT;
|
||||||
+19
-3
@@ -29,6 +29,10 @@ model User {
|
|||||||
utmSource String?
|
utmSource String?
|
||||||
utmMedium String?
|
utmMedium String?
|
||||||
utmCampaign String?
|
utmCampaign String?
|
||||||
|
// Идентификатор клика Google Ads. Нужен, чтобы отправить конверсию
|
||||||
|
// «регистрация» серверно: рекламный трафик ведём на бесплатный вход,
|
||||||
|
// и без него Google не узнает, какие клики дают студентов.
|
||||||
|
gclid String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -47,6 +51,7 @@ model User {
|
|||||||
questionMessages StudentQuestionMessage[]
|
questionMessages StudentQuestionMessage[]
|
||||||
wrappedRuns WrappedRun[]
|
wrappedRuns WrappedRun[]
|
||||||
toolUsages ToolUsage[]
|
toolUsages ToolUsage[]
|
||||||
|
pdfApiKey PdfApiKey?
|
||||||
}
|
}
|
||||||
|
|
||||||
// Obsidian Wrapped — агрегаты прогона (БЕЗ имён/текстов заметок; обработка волта 100% client-side)
|
// Obsidian Wrapped — агрегаты прогона (БЕЗ имён/текстов заметок; обработка волта 100% client-side)
|
||||||
@@ -82,6 +87,16 @@ model ToolUsage {
|
|||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Чистый PDF — персональный API-ключ студента (Zotero/curl)
|
||||||
|
model PdfApiKey {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String @unique
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
key String @unique // sbpdf_<random>, показывается студенту на /tools/clean-pdf
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
model Session {
|
model Session {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String
|
userId String
|
||||||
@@ -342,12 +357,13 @@ model HomeworkFeedback {
|
|||||||
// ─────────────────────────────────────────────
|
// ─────────────────────────────────────────────
|
||||||
|
|
||||||
model LessonComment {
|
model LessonComment {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
lessonId String
|
lessonId String
|
||||||
userId String
|
userId String
|
||||||
text String
|
text String
|
||||||
deleted Boolean @default(false)
|
deleted Boolean @default(false)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
editedAt DateTime?
|
||||||
parentId String?
|
parentId String?
|
||||||
|
|
||||||
lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade)
|
lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade)
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export default async function RegisterPage({
|
|||||||
utmSource={str(sp.utm_source)}
|
utmSource={str(sp.utm_source)}
|
||||||
utmMedium={str(sp.utm_medium)}
|
utmMedium={str(sp.utm_medium)}
|
||||||
utmCampaign={str(sp.utm_campaign)}
|
utmCampaign={str(sp.utm_campaign)}
|
||||||
|
gclid={str(sp.gclid)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,9 +30,10 @@ interface Props {
|
|||||||
utmSource?: string;
|
utmSource?: string;
|
||||||
utmMedium?: string;
|
utmMedium?: string;
|
||||||
utmCampaign?: string;
|
utmCampaign?: string;
|
||||||
|
gclid?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RegisterForm({ showTermsCheckbox, privacyPolicyUrl, termsUrl, offerUrl, archetype, utmSource, utmMedium, utmCampaign }: Props) {
|
export function RegisterForm({ showTermsCheckbox, privacyPolicyUrl, termsUrl, offerUrl, archetype, utmSource, utmMedium, utmCampaign, gclid }: Props) {
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
@@ -127,6 +128,7 @@ export function RegisterForm({ showTermsCheckbox, privacyPolicyUrl, termsUrl, of
|
|||||||
utmSource,
|
utmSource,
|
||||||
utmMedium,
|
utmMedium,
|
||||||
utmCampaign,
|
utmCampaign,
|
||||||
|
gclid,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import { headers } from "next/headers";
|
|
||||||
import { auth } from "@/lib/auth";
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { revalidatePath } from "next/cache";
|
|
||||||
|
|
||||||
export async function addComment(lessonId: string, slug: string, text: string, parentId?: string) {
|
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
|
||||||
if (!session) throw new Error("Unauthorized");
|
|
||||||
|
|
||||||
const trimmed = text.trim();
|
|
||||||
if (!trimmed || trimmed.length > 2000) throw new Error("Invalid text");
|
|
||||||
|
|
||||||
// Verify user has access to this lesson's course
|
|
||||||
const lesson = await prisma.lesson.findUnique({
|
|
||||||
where: { id: lessonId },
|
|
||||||
select: { module: { select: { course: { select: { id: true } } } } },
|
|
||||||
});
|
|
||||||
if (!lesson) throw new Error("Lesson not found");
|
|
||||||
|
|
||||||
const isAdmin = session.user.role === "admin";
|
|
||||||
const isCurator = session.user.role === "curator";
|
|
||||||
if (!isAdmin && !isCurator) {
|
|
||||||
const enrollment = await prisma.courseEnrollment.findUnique({
|
|
||||||
where: {
|
|
||||||
userId_courseId: {
|
|
||||||
userId: session.user.id,
|
|
||||||
courseId: lesson.module.course.id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!enrollment) throw new Error("Not enrolled");
|
|
||||||
if (enrollment.expiresAt && enrollment.expiresAt < new Date()) throw new Error("Access expired");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parentId) {
|
|
||||||
if (!isAdmin && !isCurator) throw new Error("Forbidden");
|
|
||||||
const parent = await prisma.lessonComment.findUnique({ where: { id: parentId } });
|
|
||||||
if (!parent || parent.lessonId !== lessonId) throw new Error("Invalid parent");
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.lessonComment.create({
|
|
||||||
data: { lessonId, userId: session.user.id, text: trimmed, ...(parentId ? { parentId } : {}) },
|
|
||||||
});
|
|
||||||
|
|
||||||
revalidatePath(`/courses/${slug}/lessons/${lessonId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteComment(commentId: string, lessonId: string, slug: string) {
|
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
|
||||||
if (!session) throw new Error("Unauthorized");
|
|
||||||
|
|
||||||
const comment = await prisma.lessonComment.findUnique({ where: { id: commentId } });
|
|
||||||
if (!comment) throw new Error("Not found");
|
|
||||||
|
|
||||||
const canDelete =
|
|
||||||
comment.userId === session.user.id ||
|
|
||||||
session.user.role === "curator" ||
|
|
||||||
session.user.role === "admin";
|
|
||||||
if (!canDelete) throw new Error("Forbidden");
|
|
||||||
|
|
||||||
await prisma.lessonComment.update({
|
|
||||||
where: { id: commentId },
|
|
||||||
data: { deleted: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
revalidatePath(`/courses/${slug}/lessons/${lessonId}`);
|
|
||||||
}
|
|
||||||
@@ -149,9 +149,8 @@ export default async function LessonPage({ params }: Props) {
|
|||||||
{lesson.files.map((file) => (
|
{lesson.files.map((file) => (
|
||||||
<a
|
<a
|
||||||
key={file.id}
|
key={file.id}
|
||||||
href={file.url}
|
href={`/api/lesson-files/${file.id}/download`}
|
||||||
target="_blank"
|
download={file.name}
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex items-center gap-3 px-4 py-3 text-sm transition-colors hover:[border-color:var(--foreground)]"
|
className="flex items-center gap-3 px-4 py-3 text-sm transition-colors hover:[border-color:var(--foreground)]"
|
||||||
style={{ border: "2px solid var(--border)" }}
|
style={{ border: "2px solid var(--border)" }}
|
||||||
>
|
>
|
||||||
@@ -290,6 +289,7 @@ export default async function LessonPage({ params }: Props) {
|
|||||||
comments={comments}
|
comments={comments}
|
||||||
currentUserId={session.user.id}
|
currentUserId={session.user.id}
|
||||||
currentUserRole={session.user.role ?? "student"}
|
currentUserRole={session.user.role ?? "student"}
|
||||||
|
imageHost={process.env.S3_CDN_URL ?? ""}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"use client";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
const field = "w-full p-2 text-sm";
|
||||||
|
const fieldStyle = { border: "1px solid var(--border)", borderRadius: "2px", backgroundColor: "var(--background)", color: "var(--foreground)" } as const;
|
||||||
|
|
||||||
|
export function CleanPdfForm({ initialUsed, limit }: { initialUsed: number; limit: number }) {
|
||||||
|
const [url, setUrl] = useState("");
|
||||||
|
const [format, setFormat] = useState<"A4" | "Letter">("A4");
|
||||||
|
const [theme, setTheme] = useState<"light" | "dark">("light");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [used, setUsed] = useState(initialUsed);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!url.trim() || busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ url: url.trim(), format, theme });
|
||||||
|
const res = await fetch(`/api/pdf?${params}`);
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({ error: `Ошибка ${res.status}` }));
|
||||||
|
setError(body.error ?? `Ошибка ${res.status}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const usesCount = res.headers.get("X-Uses-Count");
|
||||||
|
if (usesCount) setUsed(Number(usesCount));
|
||||||
|
|
||||||
|
const blob = await res.blob();
|
||||||
|
const disposition = res.headers.get("Content-Disposition") ?? "";
|
||||||
|
const match = disposition.match(/filename\*=UTF-8''([^;]+)/);
|
||||||
|
const filename = match ? decodeURIComponent(match[1]) : "document.pdf";
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = URL.createObjectURL(blob);
|
||||||
|
a.download = filename;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(a.href);
|
||||||
|
} catch {
|
||||||
|
setError("Сеть недоступна, попробуйте ещё раз");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
||||||
|
<label className="text-sm">Адрес статьи
|
||||||
|
<input className={field} style={fieldStyle} type="url" required placeholder="https://…"
|
||||||
|
value={url} onChange={(e) => setUrl(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<label className="text-sm">Формат
|
||||||
|
<select className={field} style={fieldStyle} value={format} onChange={(e) => setFormat(e.target.value as "A4" | "Letter")}>
|
||||||
|
<option value="A4">A4</option>
|
||||||
|
<option value="Letter">Letter</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">Тема
|
||||||
|
<select className={field} style={fieldStyle} value={theme} onChange={(e) => setTheme(e.target.value as "light" | "dark")}>
|
||||||
|
<option value="light">Светлая</option>
|
||||||
|
<option value="dark">Тёмная</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button type="submit" disabled={busy} className="btn-aubade px-4 py-2 text-sm font-bold disabled:opacity-50">
|
||||||
|
{busy ? "Генерируем… (до минуты)" : "Скачать PDF"}
|
||||||
|
</button>
|
||||||
|
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>Использовано {used} из {limit} в этом месяце</span>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm" style={{ color: "var(--destructive)" }}>{error}</p>}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"use client";
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { CodeOutput } from "@/components/tools/CodeOutput";
|
||||||
|
import { regeneratePdfApiKey } from "@/lib/actions/pdf-key-actions";
|
||||||
|
import { buildZoteroScript } from "@/lib/clean-pdf/zotero-script";
|
||||||
|
|
||||||
|
export function ZoteroSection({ apiKey, baseUrl }: { apiKey: string; baseUrl: string }) {
|
||||||
|
const [key, setKey] = useState(apiKey);
|
||||||
|
const [revealed, setRevealed] = useState(false);
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const shownKey = revealed ? key : key.slice(0, 8) + "…" + key.slice(-4);
|
||||||
|
const curlExample = `curl -H "Authorization: Bearer ${key}" \\\n "${baseUrl}/api/pdf?url=https://example.com/article&format=A4&theme=light" \\\n -o article.pdf`;
|
||||||
|
const script = buildZoteroScript({ apiKey: key, baseUrl });
|
||||||
|
|
||||||
|
function regenerate() {
|
||||||
|
if (!confirm("Старый ключ перестанет работать (в том числе в Zotero). Продолжить?")) return;
|
||||||
|
setError(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
const res = await regeneratePdfApiKey();
|
||||||
|
if (res.ok && res.key) {
|
||||||
|
setKey(res.key);
|
||||||
|
setRevealed(true);
|
||||||
|
} else {
|
||||||
|
setError("Не удалось перевыпустить ключ, попробуйте ещё раз");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError("Не удалось перевыпустить ключ, попробуйте ещё раз");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<h2 className="text-lg font-bold" style={{ color: "var(--foreground)" }}>Zotero и API</h2>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="text-xs font-bold tracking-wide" style={{ color: "var(--muted-foreground)" }}>ВАШ КЛЮЧ</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="p-2 text-sm" style={{ border: "1px solid var(--border)", borderRadius: "2px" }}>{shownKey}</code>
|
||||||
|
<button type="button" className="text-xs underline" onClick={() => setRevealed((v) => !v)}>
|
||||||
|
{revealed ? "скрыть" : "показать"}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="text-xs underline" onClick={() => navigator.clipboard.writeText(key)}>скопировать</button>
|
||||||
|
<button type="button" className="text-xs underline" disabled={pending} onClick={regenerate}>
|
||||||
|
{pending ? "меняем…" : "перевыпустить"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Ключ персональный — не публикуйте его. Если ключ утёк, перевыпустите: старый сразу отключится.
|
||||||
|
</p>
|
||||||
|
{error && <p className="text-sm" style={{ color: "var(--destructive)" }}>{error}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 text-sm" style={{ color: "var(--foreground)" }}>
|
||||||
|
<div className="text-xs font-bold tracking-wide" style={{ color: "var(--muted-foreground)" }}>НАСТРОЙКА ZOTERO</div>
|
||||||
|
<ol className="list-decimal pl-5 flex flex-col gap-1">
|
||||||
|
<li>Установите плагин <a className="underline" href="https://github.com/windingwind/zotero-actions-tags/releases" target="_blank" rel="noreferrer">Actions & Tags</a>.</li>
|
||||||
|
<li>В настройках плагина нажмите «+» и создайте действие: Name — <b>Чистый PDF</b>, Operation — <b>Script</b>.</li>
|
||||||
|
<li>В поле Data вставьте скрипт ниже (ключ уже подставлен).</li>
|
||||||
|
<li>Menu Label — <b>_чистый PDF</b>, поставьте галочку <b>In item Menu</b>, нажмите Save.</li>
|
||||||
|
<li>Готово: правый клик на записи с URL → «_чистый PDF» — файл прикрепится к записи.</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CodeOutput code={script} tool="clean-pdf" label="СКРИПТ ДЛЯ ACTIONS & TAGS" />
|
||||||
|
<CodeOutput code={curlExample} tool="clean-pdf" label="ПРИМЕР ДЛЯ API (CURL)" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { headers } from "next/headers";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { getMonthlyLimit, getMonthlyUsage, hasPaidAccess } from "@/lib/clean-pdf/access";
|
||||||
|
import { getOrCreatePdfKey } from "@/lib/clean-pdf/keys";
|
||||||
|
import { CleanPdfForm } from "./CleanPdfForm";
|
||||||
|
import { ZoteroSection } from "./ZoteroSection";
|
||||||
|
|
||||||
|
export const metadata = { title: "Чистый PDF — Obsidian Toolbox" };
|
||||||
|
|
||||||
|
export default async function CleanPdfToolPage() {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
const userId = session?.user.id;
|
||||||
|
const paid = userId ? await hasPaidAccess(userId) : false;
|
||||||
|
|
||||||
|
if (!userId || !paid) {
|
||||||
|
return (
|
||||||
|
<main className="mx-auto w-full max-w-5xl px-6 py-8">
|
||||||
|
<h1 className="text-2xl font-bold tracking-wide" style={{ color: "var(--foreground)" }}>Чистый PDF</h1>
|
||||||
|
<div className="mt-6 card-aubade p-4">
|
||||||
|
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Инструмент доступен студентам платных курсов школы. Если у вас есть купленный курс — проверьте, что вы вошли в нужный аккаунт.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [key, used] = await Promise.all([getOrCreatePdfKey(userId), getMonthlyUsage(userId)]);
|
||||||
|
const limit = getMonthlyLimit();
|
||||||
|
const baseUrl = process.env.NEXT_PUBLIC_APP_URL ?? "https://school.second-brain.ru";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto w-full max-w-5xl px-6 py-8">
|
||||||
|
<h1 className="text-2xl font-bold tracking-wide" style={{ color: "var(--foreground)" }}>Чистый PDF</h1>
|
||||||
|
<p className="mt-1 text-sm" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Превращает любую веб-страницу в аккуратный PDF: без рекламы, меню и мусора — только текст, картинки и оглавление.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-6 card-aubade p-4">
|
||||||
|
<CleanPdfForm initialUsed={used} limit={limit} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 card-aubade p-4">
|
||||||
|
<ZoteroSection apiKey={key} baseUrl={baseUrl} />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -94,7 +94,7 @@ export default async function AdminDashboard() {
|
|||||||
value={homeworkPending}
|
value={homeworkPending}
|
||||||
sub={`${homeworkTotal} всего сдано`}
|
sub={`${homeworkTotal} всего сдано`}
|
||||||
subAccent={homeworkPending > 0}
|
subAccent={homeworkPending > 0}
|
||||||
href="/curator/homework"
|
href="/curator/homework?status=pending"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -200,6 +200,7 @@ export async function applyImport(
|
|||||||
data: {
|
data: {
|
||||||
name: row.name,
|
name: row.name,
|
||||||
email: row.email,
|
email: row.email,
|
||||||
|
phone: row.phone || null,
|
||||||
emailVerified: options.autoVerifyEmail,
|
emailVerified: options.autoVerifyEmail,
|
||||||
role: "student",
|
role: "student",
|
||||||
},
|
},
|
||||||
@@ -231,10 +232,16 @@ export async function applyImport(
|
|||||||
|
|
||||||
created++;
|
created++;
|
||||||
} else if (row.status === "update" && row.existingId) {
|
} else if (row.status === "update" && row.existingId) {
|
||||||
|
const existing = await prisma.user.findUnique({
|
||||||
|
where: { id: row.existingId },
|
||||||
|
select: { phone: true },
|
||||||
|
});
|
||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
where: { id: row.existingId },
|
where: { id: row.existingId },
|
||||||
data: {
|
data: {
|
||||||
name: row.name || undefined,
|
name: row.name || undefined,
|
||||||
|
// don't overwrite an already-filled phone
|
||||||
|
phone: existing?.phone ? undefined : row.phone || undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export async function POST(request: NextRequest) {
|
|||||||
let body: {
|
let body: {
|
||||||
email?: string;
|
email?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
phone?: string;
|
||||||
course_slugs?: string[];
|
course_slugs?: string[];
|
||||||
order_id?: string;
|
order_id?: string;
|
||||||
};
|
};
|
||||||
@@ -30,6 +31,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const email = (body.email ?? "").trim().toLowerCase();
|
const email = (body.email ?? "").trim().toLowerCase();
|
||||||
const name = (body.name ?? "").trim();
|
const name = (body.name ?? "").trim();
|
||||||
|
const phone = (body.phone ?? "").trim().slice(0, 30);
|
||||||
const slugs = Array.isArray(body.course_slugs)
|
const slugs = Array.isArray(body.course_slugs)
|
||||||
? body.course_slugs.filter((s) => typeof s === "string" && s.trim())
|
? body.course_slugs.filter((s) => typeof s === "string" && s.trim())
|
||||||
: [];
|
: [];
|
||||||
@@ -66,7 +68,7 @@ export async function POST(request: NextRequest) {
|
|||||||
// Find or create user
|
// Find or create user
|
||||||
let user = await prisma.user.findFirst({
|
let user = await prisma.user.findFirst({
|
||||||
where: { email: { equals: email, mode: "insensitive" } },
|
where: { email: { equals: email, mode: "insensitive" } },
|
||||||
select: { id: true, name: true },
|
select: { id: true, name: true, email: true, phone: true },
|
||||||
});
|
});
|
||||||
let tempPassword: string | null = null;
|
let tempPassword: string | null = null;
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -76,14 +78,25 @@ export async function POST(request: NextRequest) {
|
|||||||
data: {
|
data: {
|
||||||
email,
|
email,
|
||||||
name: name || email,
|
name: name || email,
|
||||||
|
phone: phone || null,
|
||||||
emailVerified: true,
|
emailVerified: true,
|
||||||
mustChangePassword: true,
|
mustChangePassword: true,
|
||||||
accounts: {
|
accounts: {
|
||||||
create: { accountId: email, providerId: "credential", password: hash },
|
create: { accountId: email, providerId: "credential", password: hash },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
select: { id: true, name: true },
|
select: { id: true, name: true, email: true, phone: true },
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
// Enrich existing user from the order, never overwriting meaningful data:
|
||||||
|
// name only if the current one is empty or an email placeholder, phone only if empty.
|
||||||
|
const enrich: { name?: string; phone?: string } = {};
|
||||||
|
if (name && (!user.name || user.name === user.email)) enrich.name = name;
|
||||||
|
if (phone && !user.phone) enrich.phone = phone;
|
||||||
|
if (Object.keys(enrich).length > 0) {
|
||||||
|
await prisma.user.update({ where: { id: user.id }, data: enrich });
|
||||||
|
if (enrich.name) user.name = enrich.name;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enroll + audit log
|
// Enroll + audit log
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
// Streams a lesson material from the CDN with a forced attachment header and a
|
||||||
|
// human-readable filename. Direct CDN links open the PDF inline in a new tab
|
||||||
|
// (no Content-Disposition), which reads as "can't download" for users with a
|
||||||
|
// popup blocker or on mobile. This route makes the browser save the file.
|
||||||
|
|
||||||
|
const CONTENT_TYPES: Record<string, string> = {
|
||||||
|
pdf: "application/pdf",
|
||||||
|
zip: "application/zip",
|
||||||
|
doc: "application/msword",
|
||||||
|
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
ppt: "application/vnd.ms-powerpoint",
|
||||||
|
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||||
|
xls: "application/vnd.ms-excel",
|
||||||
|
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
png: "image/png",
|
||||||
|
jpg: "image/jpeg",
|
||||||
|
jpeg: "image/jpeg",
|
||||||
|
webp: "image/webp",
|
||||||
|
};
|
||||||
|
|
||||||
|
function extFromUrl(url: string): string {
|
||||||
|
const clean = url.split("?")[0];
|
||||||
|
const dot = clean.lastIndexOf(".");
|
||||||
|
return dot >= 0 ? clean.slice(dot + 1).toLowerCase() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Types a browser renders inline (so the direct link "opens in a tab" instead of
|
||||||
|
// downloading). Only these need proxying with a forced attachment header. ZIP,
|
||||||
|
// DOCX, etc. already download natively → redirect straight to the CDN.
|
||||||
|
const INLINE_EXT = new Set([
|
||||||
|
"pdf", "png", "jpg", "jpeg", "webp", "gif", "svg", "avif",
|
||||||
|
"txt", "htm", "html", "mp4", "mov", "webm", "mp3", "wav",
|
||||||
|
]);
|
||||||
|
// Above this, don't proxy through the app — redirect so the file streams from
|
||||||
|
// the CDN/Caddy with Range support (e.g. the 157 MB Second Brain Vault archive).
|
||||||
|
const PROXY_MAX_BYTES = 50 * 1024 * 1024;
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const file = await prisma.lessonFile.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { lesson: { include: { module: { select: { courseId: true } } } } },
|
||||||
|
});
|
||||||
|
if (!file) {
|
||||||
|
return NextResponse.json({ error: "not_found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Access: staff always; students must be enrolled in the file's course.
|
||||||
|
const role = session.user.role;
|
||||||
|
if (role !== "admin" && role !== "curator") {
|
||||||
|
const enrollment = await prisma.courseEnrollment.findFirst({
|
||||||
|
where: { userId: session.user.id, courseId: file.lesson.module.courseId },
|
||||||
|
select: { userId: true },
|
||||||
|
});
|
||||||
|
if (!enrollment) {
|
||||||
|
return NextResponse.json({ error: "forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = extFromUrl(file.url);
|
||||||
|
|
||||||
|
// Non-inline types download natively — send the browser straight to the CDN
|
||||||
|
// (native download, Range/resume, no app load). Auth is already enforced above.
|
||||||
|
if (!INLINE_EXT.has(ext)) {
|
||||||
|
return NextResponse.redirect(file.url, 302);
|
||||||
|
}
|
||||||
|
|
||||||
|
const upstream = await fetch(file.url);
|
||||||
|
if (!upstream.ok || !upstream.body) {
|
||||||
|
return NextResponse.json({ error: "file_unavailable" }, { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard: a very large inline file still shouldn't stream through the app.
|
||||||
|
const upstreamLen = Number(upstream.headers.get("content-length") ?? "0");
|
||||||
|
if (upstreamLen > PROXY_MAX_BYTES) {
|
||||||
|
await upstream.body.cancel();
|
||||||
|
return NextResponse.redirect(file.url, 302);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType =
|
||||||
|
upstream.headers.get("content-type") ??
|
||||||
|
CONTENT_TYPES[ext] ??
|
||||||
|
"application/octet-stream";
|
||||||
|
|
||||||
|
// Build a readable filename: "<name>.<ext>" (avoid doubling the extension).
|
||||||
|
let downloadName = file.name.trim();
|
||||||
|
if (ext && !downloadName.toLowerCase().endsWith(`.${ext}`)) {
|
||||||
|
downloadName += `.${ext}`;
|
||||||
|
}
|
||||||
|
const asciiName = downloadName.replace(/[^\x20-\x7E]+/g, "_").replace(/"/g, "");
|
||||||
|
const disposition =
|
||||||
|
`attachment; filename="${asciiName}"; ` +
|
||||||
|
`filename*=UTF-8''${encodeURIComponent(downloadName)}`;
|
||||||
|
|
||||||
|
const respHeaders: Record<string, string> = {
|
||||||
|
"Content-Type": contentType,
|
||||||
|
"Content-Disposition": disposition,
|
||||||
|
"Cache-Control": "private, no-store",
|
||||||
|
};
|
||||||
|
const len = upstream.headers.get("content-length");
|
||||||
|
if (len) respHeaders["Content-Length"] = len;
|
||||||
|
|
||||||
|
return new NextResponse(upstream.body, { status: 200, headers: respHeaders });
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import {
|
||||||
|
BURST_LIMIT, PDF_TOOL_ID, getBurstUsage, getMonthlyLimit, getMonthlyUsage, hasPaidAccess,
|
||||||
|
} from "@/lib/clean-pdf/access";
|
||||||
|
import { BlockedUrlError } from "@/lib/clean-pdf/ssrf";
|
||||||
|
import { EmptyContentError, RenderError, generateCleanPdf } from "@/lib/clean-pdf/generate";
|
||||||
|
import { safePdfFilename } from "@/lib/clean-pdf/template";
|
||||||
|
import { PDF_KEY_PREFIX } from "@/lib/clean-pdf/api-key";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const querySchema = z.object({
|
||||||
|
url: z.string().min(1).max(2000),
|
||||||
|
format: z.enum(["A4", "Letter"]).default("A4"),
|
||||||
|
theme: z.enum(["light", "dark"]).default("light"),
|
||||||
|
});
|
||||||
|
|
||||||
|
function jsonError(status: number, error: string, extra?: Record<string, string>) {
|
||||||
|
return NextResponse.json({ error }, { status, headers: extra });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveUserId(req: NextRequest): Promise<string | null> {
|
||||||
|
const authHeader = req.headers.get("authorization");
|
||||||
|
if (authHeader?.startsWith("Bearer ")) {
|
||||||
|
const key = authHeader.slice("Bearer ".length).trim();
|
||||||
|
if (!key.startsWith(PDF_KEY_PREFIX)) return null;
|
||||||
|
const record = await prisma.pdfApiKey.findUnique({ where: { key }, select: { userId: true } });
|
||||||
|
return record?.userId ?? null;
|
||||||
|
}
|
||||||
|
const session = await auth.api.getSession({ headers: req.headers });
|
||||||
|
return session?.user.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const userId = await resolveUserId(req);
|
||||||
|
if (!userId) return jsonError(401, "Нужен API-ключ или вход в аккаунт школы");
|
||||||
|
|
||||||
|
if (!(await hasPaidAccess(userId))) {
|
||||||
|
return jsonError(403, "Инструмент доступен студентам платных курсов школы");
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = querySchema.safeParse(Object.fromEntries(req.nextUrl.searchParams));
|
||||||
|
if (!parsed.success) return jsonError(422, "Проверьте параметры: url, format (A4|Letter), theme (light|dark)");
|
||||||
|
|
||||||
|
if ((await getBurstUsage(userId)) >= BURST_LIMIT) {
|
||||||
|
return jsonError(429, "Слишком часто: подождите минуту", { "X-Max-Uses": String(getMonthlyLimit()) });
|
||||||
|
}
|
||||||
|
const limit = getMonthlyLimit();
|
||||||
|
const used = await getMonthlyUsage(userId);
|
||||||
|
if (used >= limit) {
|
||||||
|
return jsonError(429, `Лимит ${limit} PDF в месяц исчерпан`, {
|
||||||
|
"X-Uses-Count": String(used), "X-Max-Uses": String(limit),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { pdf, title } = await generateCleanPdf(parsed.data);
|
||||||
|
await prisma.toolUsage.create({ data: { userId, tool: PDF_TOOL_ID } });
|
||||||
|
|
||||||
|
const filename = safePdfFilename(title);
|
||||||
|
return new NextResponse(new Uint8Array(pdf), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/pdf",
|
||||||
|
"Content-Disposition": `attachment; filename="document.pdf"; filename*=UTF-8''${encodeURIComponent(filename)}.pdf`,
|
||||||
|
"X-Uses-Count": String(used + 1),
|
||||||
|
"X-Max-Uses": String(limit),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof BlockedUrlError || e instanceof EmptyContentError) {
|
||||||
|
return jsonError(422, e.message);
|
||||||
|
}
|
||||||
|
if (e instanceof RenderError) {
|
||||||
|
console.error("[clean-pdf] render", e);
|
||||||
|
return jsonError(504, "Не получилось отрендерить страницу, попробуйте позже");
|
||||||
|
}
|
||||||
|
console.error("[clean-pdf]", e);
|
||||||
|
return jsonError(504, "Не получилось сгенерировать PDF, попробуйте позже");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { createHmac } from "crypto";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вход в конструктор планнеров (planner.second-brain.ru).
|
||||||
|
*
|
||||||
|
* Своей авторизации у конструктора нет: школа проверяет сессию и доступ,
|
||||||
|
* подписывает короткоживущий токен, конструктор его сверяет тем же секретом.
|
||||||
|
* Формат токена: base64url(payload).base64url(hmacSha256(payload)) —
|
||||||
|
* зеркало packages/web/src/auth.mjs в репозитории планнера. Менять оба конца.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PLANNER_URL = process.env.PLANNER_URL ?? "https://planner.second-brain.ru";
|
||||||
|
const COURSE_SLUG = "planner";
|
||||||
|
const TTL_SECONDS = 900;
|
||||||
|
|
||||||
|
const b64 = (input: Buffer | string) => Buffer.from(input).toString("base64url");
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const secret = process.env.PLANNER_TOKEN_SECRET;
|
||||||
|
if (!secret) {
|
||||||
|
// Лучше честная ошибка, чем редирект в конструктор с заведомо негодным
|
||||||
|
// токеном: там человек увидит невнятное «подпись не сходится».
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "PLANNER_TOKEN_SECRET не задан" },
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.redirect(new URL("/login", req.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
const enrolled = await prisma.courseEnrollment.findFirst({
|
||||||
|
where: { userId: session.user.id, course: { slug: COURSE_SLUG } },
|
||||||
|
select: { expiresAt: true },
|
||||||
|
});
|
||||||
|
if (!enrolled) {
|
||||||
|
return NextResponse.redirect(new URL("/dashboard", req.url));
|
||||||
|
}
|
||||||
|
if (enrolled.expiresAt && enrolled.expiresAt < new Date()) {
|
||||||
|
return NextResponse.redirect(new URL("/dashboard", req.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
userId: session.user.id,
|
||||||
|
email: session.user.email,
|
||||||
|
exp: Math.floor(Date.now() / 1000) + TTL_SECONDS,
|
||||||
|
};
|
||||||
|
const body = b64(JSON.stringify(payload));
|
||||||
|
const signature = b64(createHmac("sha256", secret).update(body).digest());
|
||||||
|
|
||||||
|
// Токен во фрагменте, а не в строке запроса: фрагмент не попадает ни в
|
||||||
|
// логи сервера, ни в заголовок Referer при переходах со страницы.
|
||||||
|
return NextResponse.redirect(`${PLANNER_URL}/build.html#token=${body}.${signature}`);
|
||||||
|
}
|
||||||
@@ -78,6 +78,8 @@ export async function POST(
|
|||||||
question.user.name,
|
question.user.name,
|
||||||
question.title,
|
question.title,
|
||||||
id,
|
id,
|
||||||
|
text.trim(),
|
||||||
|
Boolean(safeFiles?.length),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const staff = await prisma.user.findMany({
|
const staff = await prisma.user.findMany({
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { sendRegistrationConversion } from "@/lib/ads-conversion";
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
const disposableDomains = require("disposable-email-domains") as string[];
|
const disposableDomains = require("disposable-email-domains") as string[];
|
||||||
@@ -37,7 +38,7 @@ export async function POST(request: NextRequest) {
|
|||||||
return jsonError("Неверный формат запроса", 400);
|
return jsonError("Неверный формат запроса", 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { name, email, password, website, cfTurnstileResponse, archetype, utmSource, utmMedium, utmCampaign } = body;
|
const { name, email, password, website, cfTurnstileResponse, archetype, utmSource, utmMedium, utmCampaign, gclid } = body;
|
||||||
|
|
||||||
// Honeypot — bots fill this field, humans don't see it
|
// Honeypot — bots fill this field, humans don't see it
|
||||||
if (website) {
|
if (website) {
|
||||||
@@ -104,7 +105,7 @@ export async function POST(request: NextRequest) {
|
|||||||
]);
|
]);
|
||||||
// Тип второго мозга из квиза + UTM — сохраняем на юзере для персонализации
|
// Тип второго мозга из квиза + UTM — сохраняем на юзере для персонализации
|
||||||
const VALID_ARCHETYPES = ["architect", "gardener", "librarian", "pragmatist"];
|
const VALID_ARCHETYPES = ["architect", "gardener", "librarian", "pragmatist"];
|
||||||
if (user && (archetype || utmSource || utmMedium || utmCampaign)) {
|
if (user && (archetype || utmSource || utmMedium || utmCampaign || gclid)) {
|
||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
data: {
|
data: {
|
||||||
@@ -112,6 +113,7 @@ export async function POST(request: NextRequest) {
|
|||||||
utmSource: utmSource ? String(utmSource) : null,
|
utmSource: utmSource ? String(utmSource) : null,
|
||||||
utmMedium: utmMedium ? String(utmMedium) : null,
|
utmMedium: utmMedium ? String(utmMedium) : null,
|
||||||
utmCampaign: utmCampaign ? String(utmCampaign) : null,
|
utmCampaign: utmCampaign ? String(utmCampaign) : null,
|
||||||
|
gclid: gclid ? String(gclid) : null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -129,6 +131,10 @@ export async function POST(request: NextRequest) {
|
|||||||
method: "free-signup",
|
method: "free-signup",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
// Конверсия в Google Ads — фоном, регистрацию не задерживаем и не роняем.
|
||||||
|
if (gclid) {
|
||||||
|
void sendRegistrationConversion(String(gclid));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("free-enroll failed", e);
|
console.error("free-enroll failed", e);
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { uploadFile } from "@/lib/s3";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
|
||||||
|
// Images only: comments render them inline, and anything else would just be an
|
||||||
|
// unlabelled blob in the middle of a discussion.
|
||||||
|
const ALLOWED_TYPES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
|
||||||
|
const ALLOWED_EXTS = new Set(["jpg", "jpeg", "png", "gif", "webp"]);
|
||||||
|
const MAX_BYTES = 5 * 1024 * 1024; // 5 MB — enough for a screenshot
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const form = await req.formData();
|
||||||
|
const file = form.get("file") as File | null;
|
||||||
|
if (!file) return NextResponse.json({ error: "Missing file" }, { status: 400 });
|
||||||
|
|
||||||
|
if (file.size > MAX_BYTES) {
|
||||||
|
return NextResponse.json({ error: "Изображение слишком большое (макс. 5 МБ)" }, { status: 413 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ALLOWED_TYPES.has(file.type)) {
|
||||||
|
return NextResponse.json({ error: "Разрешены только изображения: jpg, png, gif, webp" }, { status: 415 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = file.name.split(".").pop()?.toLowerCase() ?? "bin";
|
||||||
|
if (!ALLOWED_EXTS.has(ext)) {
|
||||||
|
return NextResponse.json({ error: "Недопустимое расширение файла" }, { status: 415 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = `comments/${session.user.id}/${randomUUID()}.${ext}`;
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
const url = await uploadFile(key, buffer, file.type);
|
||||||
|
|
||||||
|
return NextResponse.json({ name: file.name, url, size: file.size });
|
||||||
|
}
|
||||||
@@ -4,10 +4,25 @@ import { auth } from "@/lib/auth";
|
|||||||
import { uploadFile } from "@/lib/s3";
|
import { uploadFile } from "@/lib/s3";
|
||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
|
|
||||||
|
// Расширение — основной критерий: Windows не знает MIME-тип для .md и отдаёт
|
||||||
|
// пустую строку или application/octet-stream, из-за чего заметки не прикреплялись.
|
||||||
|
const ALLOWED_EXTS = new Set(["jpg", "jpeg", "png", "gif", "webp", "pdf", "md", "txt"]);
|
||||||
|
|
||||||
|
// MIME проверяем дополнительно — но только когда браузер его вообще прислал
|
||||||
|
// и это не обобщённый «просто файл».
|
||||||
const ALLOWED_TYPES = new Set([
|
const ALLOWED_TYPES = new Set([
|
||||||
"image/jpeg", "image/png", "image/gif", "image/webp",
|
"image/jpeg", "image/png", "image/gif", "image/webp",
|
||||||
"application/pdf", "text/markdown", "text/x-markdown", "text/plain",
|
"application/pdf", "text/markdown", "text/x-markdown", "text/plain",
|
||||||
]);
|
]);
|
||||||
|
const GENERIC_TYPES = new Set(["", "application/octet-stream", "binary/octet-stream"]);
|
||||||
|
|
||||||
|
// Для отдачи файла браузеру подставляем корректный тип, если своего не было
|
||||||
|
const EXT_TO_TYPE: Record<string, string> = {
|
||||||
|
jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png",
|
||||||
|
gif: "image/gif", webp: "image/webp", pdf: "application/pdf",
|
||||||
|
md: "text/markdown", txt: "text/plain",
|
||||||
|
};
|
||||||
|
|
||||||
const MAX_BYTES = 10 * 1024 * 1024; // 10 MB
|
const MAX_BYTES = 10 * 1024 * 1024; // 10 MB
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
@@ -22,22 +37,29 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ error: "Файл слишком большой (макс. 10 МБ)" }, { status: 413 });
|
return NextResponse.json({ error: "Файл слишком большой (макс. 10 МБ)" }, { status: 413 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ALLOWED_TYPES.has(file.type)) {
|
const ext = file.name.split(".").pop()?.toLowerCase() ?? "";
|
||||||
|
if (!ALLOWED_EXTS.has(ext)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Разрешены только jpg, png, pdf, md" },
|
{ error: "Разрешены только jpg, png, gif, webp, pdf, md, txt" },
|
||||||
{ status: 415 }
|
{ status: 415 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ext = file.name.split(".").pop()?.toLowerCase() ?? "bin";
|
const declaredType = (file.type || "").toLowerCase();
|
||||||
const ALLOWED_EXTS = new Set(["jpg", "jpeg", "png", "gif", "webp", "pdf", "md", "txt"]);
|
if (!GENERIC_TYPES.has(declaredType) && !ALLOWED_TYPES.has(declaredType)) {
|
||||||
if (!ALLOWED_EXTS.has(ext)) {
|
return NextResponse.json(
|
||||||
return NextResponse.json({ error: "Недопустимое расширение файла" }, { status: 415 });
|
{ error: "Разрешены только jpg, png, gif, webp, pdf, md, txt" },
|
||||||
|
{ status: 415 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const contentType = GENERIC_TYPES.has(declaredType)
|
||||||
|
? (EXT_TO_TYPE[ext] ?? "application/octet-stream")
|
||||||
|
: declaredType;
|
||||||
|
|
||||||
const key = `questions/${session.user.id}/${randomUUID()}.${ext}`;
|
const key = `questions/${session.user.id}/${randomUUID()}.${ext}`;
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
const url = await uploadFile(key, buffer, file.type);
|
const url = await uploadFile(key, buffer, contentType);
|
||||||
|
|
||||||
return NextResponse.json({ name: file.name, url, size: file.size });
|
return NextResponse.json({ name: file.name, url, size: file.size });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export default async function CuratorDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{pending > 0 ? (
|
{pending > 0 ? (
|
||||||
<Link href="/curator/homework" className="btn-aubade btn-aubade-accent inline-flex items-center gap-2 px-5 py-2.5 text-sm">
|
<Link href="/curator/homework?status=pending" className="btn-aubade btn-aubade-accent inline-flex items-center gap-2 px-5 py-2.5 text-sm">
|
||||||
Перейти к проверке ({pending}) →
|
Перейти к проверке ({pending}) →
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -241,3 +241,60 @@
|
|||||||
border-left-color: #E8F0D8;
|
border-left-color: #E8F0D8;
|
||||||
background-color: var(--sidebar-surface);
|
background-color: var(--sidebar-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Markdown-разметка в комментариях под уроками (react-markdown) */
|
||||||
|
/* Таблицы GFM: широкая таблица прокручивается внутри себя, а не растягивает страницу */
|
||||||
|
.comment-md table {
|
||||||
|
display: block;
|
||||||
|
width: max-content;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
font-size: 0.95em;
|
||||||
|
}
|
||||||
|
.comment-md th,
|
||||||
|
.comment-md td {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 0.35rem 0.6rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.comment-md th { font-weight: 700; background: var(--color-surface); }
|
||||||
|
.comment-md del { opacity: 0.6; }
|
||||||
|
.comment-md img {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
}
|
||||||
|
.comment-md > :first-child { margin-top: 0; }
|
||||||
|
.comment-md > :last-child { margin-bottom: 0; }
|
||||||
|
.comment-md p { margin: 0 0 0.5rem; }
|
||||||
|
.comment-md strong { font-weight: 700; }
|
||||||
|
.comment-md em { font-style: italic; }
|
||||||
|
.comment-md a { color: var(--foreground); text-decoration: underline; text-underline-offset: 2px; }
|
||||||
|
.comment-md ul { list-style: disc; padding-left: 1.4rem; margin: 0 0 0.5rem; }
|
||||||
|
.comment-md ol { list-style: decimal; padding-left: 1.4rem; margin: 0 0 0.5rem; }
|
||||||
|
.comment-md li { margin: 0.15rem 0; }
|
||||||
|
.comment-md blockquote {
|
||||||
|
border-left: 2px solid var(--border);
|
||||||
|
padding-left: 0.75rem;
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
.comment-md code {
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
font-size: 0.9em;
|
||||||
|
background: var(--color-surface);
|
||||||
|
padding: 0.05rem 0.3rem;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
.comment-md pre {
|
||||||
|
background: var(--color-surface);
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
.comment-md pre code { background: none; padding: 0; }
|
||||||
|
|||||||
@@ -289,6 +289,11 @@ export function SettingsForm({ initial }: { initial: Settings }) {
|
|||||||
checked={bool("notifyStudentOnFeedback")}
|
checked={bool("notifyStudentOnFeedback")}
|
||||||
onChange={(v) => set("notifyStudentOnFeedback", v ? "true" : "false")}
|
onChange={(v) => set("notifyStudentOnFeedback", v ? "true" : "false")}
|
||||||
/>
|
/>
|
||||||
|
<Toggle
|
||||||
|
label="Уведомлять ученика об ответе на его комментарий"
|
||||||
|
checked={bool("notifyStudentOnCommentReply")}
|
||||||
|
onChange={(v) => set("notifyStudentOnCommentReply", v ? "true" : "false")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useTransition } from "react";
|
import { useRef, useState, useTransition, type ReactNode } from "react";
|
||||||
import { addComment, deleteComment } from "@/lib/actions/student-actions";
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
|
||||||
|
import { addComment, deleteComment, editComment } from "@/lib/actions/student-actions";
|
||||||
|
|
||||||
|
const MAX_LEN = 10000;
|
||||||
|
|
||||||
type Reply = {
|
type Reply = {
|
||||||
id: string;
|
id: string;
|
||||||
text: string;
|
text: string;
|
||||||
deleted: boolean;
|
deleted: boolean;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
editedAt: Date | null;
|
||||||
user: { id: string; name: string };
|
user: { id: string; name: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -16,6 +22,7 @@ type Comment = {
|
|||||||
text: string;
|
text: string;
|
||||||
deleted: boolean;
|
deleted: boolean;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
editedAt: Date | null;
|
||||||
user: { id: string; name: string };
|
user: { id: string; name: string };
|
||||||
replies: Reply[];
|
replies: Reply[];
|
||||||
};
|
};
|
||||||
@@ -26,6 +33,8 @@ interface Props {
|
|||||||
comments: Comment[];
|
comments: Comment[];
|
||||||
currentUserId: string;
|
currentUserId: string;
|
||||||
currentUserRole: string;
|
currentUserRole: string;
|
||||||
|
/** Public host of our own file storage (S3_CDN_URL) — see CommentBody. */
|
||||||
|
imageHost: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(date: Date) {
|
function formatDate(date: Date) {
|
||||||
@@ -38,10 +47,234 @@ function formatDate(date: Date) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LessonComments({ lessonId, slug, comments, currentUserId, currentUserRole }: Props) {
|
// The default schema drops `alt`, so uploaded images would lose their caption.
|
||||||
|
const sanitizeSchema = {
|
||||||
|
...defaultSchema,
|
||||||
|
attributes: {
|
||||||
|
...defaultSchema.attributes,
|
||||||
|
img: [...(defaultSchema.attributes?.img ?? []), "alt"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Renders comment text as sanitized Markdown (no raw HTML, safe links).
|
||||||
|
// `imageHost` is our own file storage: images from anywhere else are shown as a
|
||||||
|
// plain link instead of being loaded, since a third-party image would fetch —
|
||||||
|
// and thus expose the address — for every reader of the lesson.
|
||||||
|
function CommentBody({ text, imageHost }: { text: string; imageHost: string }) {
|
||||||
|
return (
|
||||||
|
<div className="comment-md text-sm leading-relaxed break-words">
|
||||||
|
<ReactMarkdown
|
||||||
|
// GFM: голый адрес сам становится ссылкой. Без него студент пишет
|
||||||
|
// https://… и получает мёртвый текст — разметку никто не проставляет.
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
rehypePlugins={[[rehypeSanitize, sanitizeSchema]]}
|
||||||
|
components={{
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- отбрасываем служебный node от react-markdown, чтобы он не протекал в DOM
|
||||||
|
a: ({ node, ...props }) => <a {...props} target="_blank" rel="noopener noreferrer" />,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
img: ({ node, src, alt, ...props }) => {
|
||||||
|
const url = typeof src === "string" ? src : "";
|
||||||
|
if (!imageHost || !url.startsWith(`${imageHost}/`)) {
|
||||||
|
return (
|
||||||
|
<a href={url} target="_blank" rel="noopener noreferrer">
|
||||||
|
{alt || url}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element -- пользовательская картинка произвольного размера, next/image здесь не подходит
|
||||||
|
<img {...props} src={url} alt={alt ?? ""} loading="lazy" />
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolbarBtnStyle = {
|
||||||
|
border: "2px solid var(--border)",
|
||||||
|
color: "var(--foreground)",
|
||||||
|
background: "var(--background)",
|
||||||
|
minWidth: "30px",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function ToolbarButton({
|
||||||
|
title,
|
||||||
|
onClick,
|
||||||
|
disabled,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
onClick: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={title}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onClick}
|
||||||
|
className="text-xs px-2 py-1 leading-none transition-colors"
|
||||||
|
style={toolbarBtnStyle}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.background = "var(--accent)")}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.background = "var(--background)")}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditorProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
placeholder: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
rows?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Textarea + Markdown formatting toolbar + character counter. Reused for the
|
||||||
|
// new comment, replies, and inline editing.
|
||||||
|
function CommentEditor({ value, onChange, placeholder, disabled, rows = 3 }: EditorProps) {
|
||||||
|
const ref = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Insert text at the caret (used for uploaded images).
|
||||||
|
function insertAtCursor(snippet: string) {
|
||||||
|
const el = ref.current;
|
||||||
|
const at = el ? el.selectionStart : value.length;
|
||||||
|
const next = value.slice(0, at) + snippet + value.slice(at);
|
||||||
|
onChange(next);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
el?.focus();
|
||||||
|
el?.setSelectionRange(at + snippet.length, at + snippet.length);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
e.target.value = ""; // allow picking the same file twice in a row
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
setUploadError(null);
|
||||||
|
try {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
const res = await fetch("/api/student/comment-upload", { method: "POST", body: fd });
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error ?? "Не удалось загрузить изображение");
|
||||||
|
// Square brackets in the filename would break the Markdown link syntax.
|
||||||
|
const caption = file.name.replace(/[[\]]/g, "");
|
||||||
|
insertAtCursor(`\n\n`);
|
||||||
|
} catch (err) {
|
||||||
|
setUploadError(err instanceof Error ? err.message : "Не удалось загрузить изображение");
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap the current selection with before/after markers.
|
||||||
|
function wrap(before: string, after: string, placeholderText: string) {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
const start = el.selectionStart;
|
||||||
|
const end = el.selectionEnd;
|
||||||
|
const selected = value.slice(start, end) || placeholderText;
|
||||||
|
const next = value.slice(0, start) + before + selected + after + value.slice(end);
|
||||||
|
onChange(next);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
el.focus();
|
||||||
|
el.setSelectionRange(start + before.length, start + before.length + selected.length);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefix each selected line (for lists and quotes).
|
||||||
|
function prefixLines(prefix: string) {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
const start = el.selectionStart;
|
||||||
|
const end = el.selectionEnd;
|
||||||
|
const lineStart = value.lastIndexOf("\n", start - 1) + 1;
|
||||||
|
const block = value.slice(lineStart, end);
|
||||||
|
const prefixed = block
|
||||||
|
.split("\n")
|
||||||
|
.map((l) => prefix + l)
|
||||||
|
.join("\n");
|
||||||
|
const next = value.slice(0, lineStart) + prefixed + value.slice(end);
|
||||||
|
onChange(next);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
el.focus();
|
||||||
|
el.setSelectionRange(lineStart, lineStart + prefixed.length);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap gap-1 mb-2">
|
||||||
|
<ToolbarButton title="Жирный" disabled={disabled} onClick={() => wrap("**", "**", "текст")}><b>Ж</b></ToolbarButton>
|
||||||
|
<ToolbarButton title="Курсив" disabled={disabled} onClick={() => wrap("*", "*", "текст")}><i>К</i></ToolbarButton>
|
||||||
|
<ToolbarButton title="Список" disabled={disabled} onClick={() => prefixLines("- ")}>•</ToolbarButton>
|
||||||
|
<ToolbarButton title="Цитата" disabled={disabled} onClick={() => prefixLines("> ")}>»</ToolbarButton>
|
||||||
|
<ToolbarButton title="Код" disabled={disabled} onClick={() => wrap("`", "`", "код")}>{"</>"}</ToolbarButton>
|
||||||
|
<ToolbarButton title="Ссылка" disabled={disabled} onClick={() => wrap("[", "](https://)", "текст")}>🔗</ToolbarButton>
|
||||||
|
<ToolbarButton
|
||||||
|
title="Изображение (jpg, png, gif, webp — до 5 МБ)"
|
||||||
|
disabled={disabled || uploading}
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
>
|
||||||
|
{uploading ? "…" : "🖼"}
|
||||||
|
</ToolbarButton>
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/gif,image/webp"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFile}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
ref={ref}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
rows={rows}
|
||||||
|
maxLength={MAX_LEN}
|
||||||
|
disabled={disabled}
|
||||||
|
className="w-full text-sm p-3 resize-y"
|
||||||
|
style={{
|
||||||
|
border: "2px solid var(--border)",
|
||||||
|
backgroundColor: "var(--background)",
|
||||||
|
color: "var(--foreground)",
|
||||||
|
outline: "none",
|
||||||
|
fontFamily: "inherit",
|
||||||
|
}}
|
||||||
|
onFocus={(e) => (e.target.style.borderColor = "var(--foreground)")}
|
||||||
|
onBlur={(e) => (e.target.style.borderColor = "var(--border)")}
|
||||||
|
/>
|
||||||
|
{uploadError && (
|
||||||
|
<div className="text-xs mt-1" style={{ color: "var(--destructive, #b00)" }}>
|
||||||
|
{uploadError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-xs mt-1" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Поддерживается форматирование Markdown{value.length > 0 ? ` · ${value.length}/${MAX_LEN}` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LessonComments({ lessonId, slug, comments, currentUserId, currentUserRole, imageHost }: Props) {
|
||||||
const [text, setText] = useState("");
|
const [text, setText] = useState("");
|
||||||
const [replyToId, setReplyToId] = useState<string | null>(null);
|
const [replyToId, setReplyToId] = useState<string | null>(null);
|
||||||
const [replyText, setReplyText] = useState("");
|
const [replyText, setReplyText] = useState("");
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [editText, setEditText] = useState("");
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -74,6 +307,19 @@ export function LessonComments({ lessonId, slug, comments, currentUserId, curren
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleEdit(commentId: string) {
|
||||||
|
if (!editText.trim()) return;
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
await editComment(commentId, lessonId, slug, editText.trim());
|
||||||
|
setEditingId(null);
|
||||||
|
setEditText("");
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function handleDelete(commentId: string) {
|
function handleDelete(commentId: string) {
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -84,6 +330,89 @@ export function LessonComments({ lessonId, slug, comments, currentUserId, curren
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderNode(node: Comment | Reply, isReply: boolean) {
|
||||||
|
const isOwn = node.user.id === currentUserId;
|
||||||
|
const editing = editingId === node.id;
|
||||||
|
return (
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||||
|
<span className="text-sm font-bold">{node.user.name}</span>
|
||||||
|
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
{formatDate(node.createdAt)}
|
||||||
|
{node.editedAt && !node.deleted ? " · изменено" : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{node.deleted ? (
|
||||||
|
<p className="text-sm italic" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
[Комментарий удалён]
|
||||||
|
</p>
|
||||||
|
) : editing ? (
|
||||||
|
<div>
|
||||||
|
<CommentEditor value={editText} onChange={setEditText} placeholder="Измените комментарий..." disabled={isPending} rows={3} />
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleEdit(node.id)}
|
||||||
|
disabled={isPending || !editText.trim()}
|
||||||
|
className="btn-aubade btn-aubade-accent text-sm"
|
||||||
|
>
|
||||||
|
{isPending ? "Сохранение..." : "Сохранить"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setEditingId(null); setEditText(""); }}
|
||||||
|
disabled={isPending}
|
||||||
|
className="text-xs underline"
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<CommentBody text={node.text} imageHost={imageHost} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!node.deleted && !editing && (
|
||||||
|
<div className="flex gap-3 mt-1">
|
||||||
|
{isOwn && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setEditingId(node.id); setEditText(node.text); setReplyToId(null); }}
|
||||||
|
disabled={isPending}
|
||||||
|
className="text-xs underline"
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
Изменить
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{(isOwn || canModerate) && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(node.id)}
|
||||||
|
disabled={isPending}
|
||||||
|
className="text-xs underline"
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!isReply && canModerate && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setReplyToId(replyToId === node.id ? null : node.id);
|
||||||
|
setReplyText("");
|
||||||
|
}}
|
||||||
|
disabled={isPending}
|
||||||
|
className="text-xs underline"
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
{replyToId === node.id ? "Отмена" : "Ответить"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="space-y-5 mb-6">
|
<div className="space-y-5 mb-6">
|
||||||
@@ -107,86 +436,25 @@ export function LessonComments({ lessonId, slug, comments, currentUserId, curren
|
|||||||
>
|
>
|
||||||
{comment.user.name[0]?.toUpperCase() ?? "?"}
|
{comment.user.name[0]?.toUpperCase() ?? "?"}
|
||||||
</div>
|
</div>
|
||||||
|
{renderNode(comment, false)}
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
|
||||||
<span className="text-sm font-bold">{comment.user.name}</span>
|
|
||||||
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
|
||||||
{formatDate(comment.createdAt)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{comment.deleted ? (
|
|
||||||
<p className="text-sm italic" style={{ color: "var(--muted-foreground)" }}>
|
|
||||||
[Комментарий удалён]
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm whitespace-pre-wrap break-words leading-relaxed">
|
|
||||||
{comment.text}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex gap-3 mt-1">
|
|
||||||
{!comment.deleted && (comment.user.id === currentUserId || canModerate) && (
|
|
||||||
<button
|
|
||||||
onClick={() => handleDelete(comment.id)}
|
|
||||||
disabled={isPending}
|
|
||||||
className="text-xs underline"
|
|
||||||
style={{ color: "var(--muted-foreground)" }}
|
|
||||||
>
|
|
||||||
Удалить
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{!comment.deleted && canModerate && (
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setReplyToId(replyToId === comment.id ? null : comment.id);
|
|
||||||
setReplyText("");
|
|
||||||
}}
|
|
||||||
disabled={isPending}
|
|
||||||
className="text-xs underline"
|
|
||||||
style={{ color: "var(--muted-foreground)" }}
|
|
||||||
>
|
|
||||||
{replyToId === comment.id ? "Отмена" : "Ответить"}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Inline reply form */}
|
|
||||||
{replyToId === comment.id && (
|
|
||||||
<div className="mt-3">
|
|
||||||
<textarea
|
|
||||||
value={replyText}
|
|
||||||
onChange={(e) => setReplyText(e.target.value)}
|
|
||||||
placeholder="Напишите ответ..."
|
|
||||||
rows={2}
|
|
||||||
maxLength={2000}
|
|
||||||
disabled={isPending}
|
|
||||||
className="w-full text-sm p-3 resize-y"
|
|
||||||
style={{
|
|
||||||
border: "2px solid var(--border)",
|
|
||||||
backgroundColor: "var(--background)",
|
|
||||||
color: "var(--foreground)",
|
|
||||||
outline: "none",
|
|
||||||
fontFamily: "inherit",
|
|
||||||
}}
|
|
||||||
onFocus={(e) => (e.target.style.borderColor = "var(--foreground)")}
|
|
||||||
onBlur={(e) => (e.target.style.borderColor = "var(--border)")}
|
|
||||||
/>
|
|
||||||
<div className="flex justify-end mt-2">
|
|
||||||
<button
|
|
||||||
onClick={() => handleReply(comment.id)}
|
|
||||||
disabled={isPending || !replyText.trim()}
|
|
||||||
className="btn-aubade btn-aubade-accent text-sm"
|
|
||||||
>
|
|
||||||
{isPending ? "Отправка..." : "Отправить ответ"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Inline reply form (under root) */}
|
||||||
|
{replyToId === comment.id && (
|
||||||
|
<div className="mt-3 ml-11">
|
||||||
|
<CommentEditor value={replyText} onChange={setReplyText} placeholder="Напишите ответ..." disabled={isPending} rows={2} />
|
||||||
|
<div className="flex justify-end mt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleReply(comment.id)}
|
||||||
|
disabled={isPending || !replyText.trim()}
|
||||||
|
className="btn-aubade btn-aubade-accent text-sm"
|
||||||
|
>
|
||||||
|
{isPending ? "Отправка..." : "Отправить ответ"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Replies */}
|
{/* Replies */}
|
||||||
{comment.replies.length > 0 && (
|
{comment.replies.length > 0 && (
|
||||||
<div
|
<div
|
||||||
@@ -205,33 +473,7 @@ export function LessonComments({ lessonId, slug, comments, currentUserId, curren
|
|||||||
>
|
>
|
||||||
{reply.user.name[0]?.toUpperCase() ?? "?"}
|
{reply.user.name[0]?.toUpperCase() ?? "?"}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
{renderNode(reply, true)}
|
||||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
|
||||||
<span className="text-sm font-bold">{reply.user.name}</span>
|
|
||||||
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
|
||||||
{formatDate(reply.createdAt)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{reply.deleted ? (
|
|
||||||
<p className="text-sm italic" style={{ color: "var(--muted-foreground)" }}>
|
|
||||||
[Комментарий удалён]
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm whitespace-pre-wrap break-words leading-relaxed">
|
|
||||||
{reply.text}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{!reply.deleted && (reply.user.id === currentUserId || canModerate) && (
|
|
||||||
<button
|
|
||||||
onClick={() => handleDelete(reply.id)}
|
|
||||||
disabled={isPending}
|
|
||||||
className="text-xs mt-1 underline"
|
|
||||||
style={{ color: "var(--muted-foreground)" }}
|
|
||||||
>
|
|
||||||
Удалить
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -242,33 +484,13 @@ export function LessonComments({ lessonId, slug, comments, currentUserId, curren
|
|||||||
|
|
||||||
{/* New comment form */}
|
{/* New comment form */}
|
||||||
<form onSubmit={handleAdd}>
|
<form onSubmit={handleAdd}>
|
||||||
<textarea
|
<CommentEditor value={text} onChange={setText} placeholder="Напишите комментарий..." disabled={isPending} rows={3} />
|
||||||
value={text}
|
|
||||||
onChange={(e) => setText(e.target.value)}
|
|
||||||
placeholder="Напишите комментарий..."
|
|
||||||
rows={3}
|
|
||||||
maxLength={2000}
|
|
||||||
disabled={isPending}
|
|
||||||
className="w-full text-sm p-3 resize-y"
|
|
||||||
style={{
|
|
||||||
border: "2px solid var(--border)",
|
|
||||||
backgroundColor: "var(--background)",
|
|
||||||
color: "var(--foreground)",
|
|
||||||
outline: "none",
|
|
||||||
fontFamily: "inherit",
|
|
||||||
}}
|
|
||||||
onFocus={(e) => (e.target.style.borderColor = "var(--foreground)")}
|
|
||||||
onBlur={(e) => (e.target.style.borderColor = "var(--border)")}
|
|
||||||
/>
|
|
||||||
{error && (
|
{error && (
|
||||||
<p className="text-xs mt-1" style={{ color: "oklch(0.577 0.245 27.325)" }}>
|
<p className="text-xs mt-1" style={{ color: "oklch(0.577 0.245 27.325)" }}>
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center justify-between mt-2">
|
<div className="flex items-center justify-end mt-2">
|
||||||
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
|
||||||
{text.length > 0 ? `${text.length}/2000` : ""}
|
|
||||||
</span>
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isPending || !text.trim()}
|
disabled={isPending || !text.trim()}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { MessageSquareQuote, FileCode2, Palette, SlidersHorizontal, Table2, Database, Wrench, type LucideIcon } from "lucide-react";
|
import { MessageSquareQuote, FileCode2, Palette, SlidersHorizontal, Table2, Database, FileText, Wrench, type LucideIcon } from "lucide-react";
|
||||||
import type { ToolMeta } from "@/lib/tools/_shared/types";
|
import type { ToolMeta } from "@/lib/tools/_shared/types";
|
||||||
|
|
||||||
// Явная карта (не `import * as Icons`) — иначе весь набор lucide попадает в бандл.
|
// Явная карта (не `import * as Icons`) — иначе весь набор lucide попадает в бандл.
|
||||||
@@ -10,6 +10,7 @@ const ICONS: Record<string, LucideIcon> = {
|
|||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
Table2,
|
Table2,
|
||||||
Database,
|
Database,
|
||||||
|
FileText,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ToolCard({ tool }: { tool: ToolMeta }) {
|
export function ToolCard({ tool }: { tool: ToolMeta }) {
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { commentReplyRecipient } from "../comment-notify";
|
||||||
|
|
||||||
|
const parent = {
|
||||||
|
userId: "student-1",
|
||||||
|
deleted: false,
|
||||||
|
user: { email: "student@example.com", name: "Владимир" },
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("commentReplyRecipient", () => {
|
||||||
|
it("отдаёт автора родительского комментария, когда админ ответил ученику", () => {
|
||||||
|
expect(
|
||||||
|
commentReplyRecipient({
|
||||||
|
notifyEnabled: true,
|
||||||
|
replierId: "admin-1",
|
||||||
|
replierRole: "admin",
|
||||||
|
parent,
|
||||||
|
})
|
||||||
|
).toEqual({ email: "student@example.com", name: "Владимир" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("работает и для куратора", () => {
|
||||||
|
expect(
|
||||||
|
commentReplyRecipient({
|
||||||
|
notifyEnabled: true,
|
||||||
|
replierId: "curator-1",
|
||||||
|
replierRole: "curator",
|
||||||
|
parent,
|
||||||
|
})
|
||||||
|
).toEqual({ email: "student@example.com", name: "Владимир" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("молчит при выключенной настройке", () => {
|
||||||
|
expect(
|
||||||
|
commentReplyRecipient({
|
||||||
|
notifyEnabled: false,
|
||||||
|
replierId: "admin-1",
|
||||||
|
replierRole: "admin",
|
||||||
|
parent,
|
||||||
|
})
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("молчит, когда отвечает не staff", () => {
|
||||||
|
expect(
|
||||||
|
commentReplyRecipient({
|
||||||
|
notifyEnabled: true,
|
||||||
|
replierId: "student-2",
|
||||||
|
replierRole: "student",
|
||||||
|
parent,
|
||||||
|
})
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("молчит без родительского комментария — это корневая запись, а не ответ", () => {
|
||||||
|
expect(
|
||||||
|
commentReplyRecipient({
|
||||||
|
notifyEnabled: true,
|
||||||
|
replierId: "admin-1",
|
||||||
|
replierRole: "admin",
|
||||||
|
parent: null,
|
||||||
|
})
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("молчит, если родительский комментарий удалён", () => {
|
||||||
|
expect(
|
||||||
|
commentReplyRecipient({
|
||||||
|
notifyEnabled: true,
|
||||||
|
replierId: "admin-1",
|
||||||
|
replierRole: "admin",
|
||||||
|
parent: { ...parent, deleted: true },
|
||||||
|
})
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("не пишет письмо самому себе", () => {
|
||||||
|
expect(
|
||||||
|
commentReplyRecipient({
|
||||||
|
notifyEnabled: true,
|
||||||
|
replierId: "student-1",
|
||||||
|
replierRole: "admin",
|
||||||
|
parent,
|
||||||
|
})
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("молчит, когда у автора нет почты", () => {
|
||||||
|
expect(
|
||||||
|
commentReplyRecipient({
|
||||||
|
notifyEnabled: true,
|
||||||
|
replierId: "admin-1",
|
||||||
|
replierRole: "admin",
|
||||||
|
parent: { ...parent, user: { email: " ", name: "Без почты" } },
|
||||||
|
})
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("отдаёт пустое имя, если в карточке его нет", () => {
|
||||||
|
expect(
|
||||||
|
commentReplyRecipient({
|
||||||
|
notifyEnabled: true,
|
||||||
|
replierId: "admin-1",
|
||||||
|
replierRole: "admin",
|
||||||
|
parent: { ...parent, user: { email: "student@example.com", name: "" } },
|
||||||
|
})
|
||||||
|
).toEqual({ email: "student@example.com", name: "" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
const sent: Array<Record<string, string>> = [];
|
||||||
|
|
||||||
|
vi.mock("resend", () => ({
|
||||||
|
Resend: class {
|
||||||
|
emails = {
|
||||||
|
send: async (args: Record<string, string>) => {
|
||||||
|
sent.push(args);
|
||||||
|
return { data: { id: "test" }, error: null };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../settings", () => ({
|
||||||
|
getSetting: async () => "Second Brain",
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { sendCommentReplyEmail } = await import("../email");
|
||||||
|
|
||||||
|
const LESSON_URL = "https://school.second-brain.ru/courses/obsidian-full/lessons/lesson-1";
|
||||||
|
|
||||||
|
describe("sendCommentReplyEmail", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sent.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("кладёт текст ответа в письмо и ведёт кнопкой на урок", async () => {
|
||||||
|
await sendCommentReplyEmail(
|
||||||
|
"student@example.com",
|
||||||
|
"Владимир",
|
||||||
|
"6.3. Холст",
|
||||||
|
"Прямого импорта нет, но есть два пути.",
|
||||||
|
LESSON_URL,
|
||||||
|
"Дмитрий"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(sent).toHaveLength(1);
|
||||||
|
expect(sent[0].to).toBe("student@example.com");
|
||||||
|
expect(sent[0].subject).toBe("Ответ на ваш комментарий к уроку «6.3. Холст»");
|
||||||
|
expect(sent[0].html).toContain("Прямого импорта нет, но есть два пути.");
|
||||||
|
expect(sent[0].html).toContain("Привет, Владимир!");
|
||||||
|
expect(sent[0].html).toContain("Отвечает <strong>Дмитрий</strong>:");
|
||||||
|
expect(sent[0].html).toContain(`href="${LESSON_URL}"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("экранирует разметку в тексте ответа и в названии урока", async () => {
|
||||||
|
await sendCommentReplyEmail(
|
||||||
|
"student@example.com",
|
||||||
|
"Владимир",
|
||||||
|
"Урок <b>про холст</b>",
|
||||||
|
'Смотрите тег <script>alert("x")</script>',
|
||||||
|
LESSON_URL
|
||||||
|
);
|
||||||
|
|
||||||
|
const html = sent[0].html;
|
||||||
|
expect(html).not.toContain("<script>");
|
||||||
|
expect(html).toContain("<script>");
|
||||||
|
expect(html).toContain("Урок <b>про холст</b>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("обрезает длинный ответ и говорит, что целиком он под уроком", async () => {
|
||||||
|
await sendCommentReplyEmail(
|
||||||
|
"student@example.com",
|
||||||
|
"Владимир",
|
||||||
|
"6.3. Холст",
|
||||||
|
"я".repeat(4500),
|
||||||
|
LESSON_URL
|
||||||
|
);
|
||||||
|
|
||||||
|
const html = sent[0].html;
|
||||||
|
expect(html).toContain("Ответ длинный — целиком он под уроком.");
|
||||||
|
expect(html).toContain("…");
|
||||||
|
expect(html).not.toContain("я".repeat(4100));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("без имени отвечающего пишет просто «Школа ответила»", async () => {
|
||||||
|
await sendCommentReplyEmail("student@example.com", "", "6.3. Холст", "Коротко", LESSON_URL);
|
||||||
|
|
||||||
|
const html = sent[0].html;
|
||||||
|
expect(html).toContain("Школа ответила на ваш комментарий");
|
||||||
|
expect(html).not.toContain("Отвечает <strong>");
|
||||||
|
expect(html).toContain("Привет!");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
"use server";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { hasPaidAccess } from "@/lib/clean-pdf/access";
|
||||||
|
import { regenerateKey } from "@/lib/clean-pdf/keys";
|
||||||
|
|
||||||
|
export async function regeneratePdfApiKey(): Promise<{ ok: boolean; key?: string }> {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session) return { ok: false };
|
||||||
|
if (!(await hasPaidAccess(session.user.id))) return { ok: false };
|
||||||
|
const key = await regenerateKey(session.user.id);
|
||||||
|
return { ok: true, key };
|
||||||
|
}
|
||||||
@@ -4,7 +4,12 @@ import { prisma } from "@/lib/prisma";
|
|||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { sendHomeworkSubmittedEmail, sendCommentNotificationEmail } from "@/lib/email";
|
import {
|
||||||
|
sendHomeworkSubmittedEmail,
|
||||||
|
sendCommentNotificationEmail,
|
||||||
|
sendCommentReplyEmail,
|
||||||
|
} from "@/lib/email";
|
||||||
|
import { commentReplyRecipient } from "@/lib/comment-notify";
|
||||||
import { getSettings, parseNotificationEmails, asBool } from "@/lib/settings";
|
import { getSettings, parseNotificationEmails, asBool } from "@/lib/settings";
|
||||||
|
|
||||||
// ── Lesson Progress ───────────────────────────────────────────────────────────
|
// ── Lesson Progress ───────────────────────────────────────────────────────────
|
||||||
@@ -152,7 +157,7 @@ export async function addComment(lessonId: string, slug: string, text: string, p
|
|||||||
if (!session) throw new Error("Unauthorized");
|
if (!session) throw new Error("Unauthorized");
|
||||||
|
|
||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
if (!trimmed || trimmed.length > 2000) throw new Error("Invalid text");
|
if (!trimmed || trimmed.length > 10000) throw new Error("Invalid text");
|
||||||
|
|
||||||
const lesson = await prisma.lesson.findUnique({
|
const lesson = await prisma.lesson.findUnique({
|
||||||
where: { id: lessonId },
|
where: { id: lessonId },
|
||||||
@@ -175,9 +180,20 @@ export async function addComment(lessonId: string, slug: string, text: string, p
|
|||||||
if (enrollment.expiresAt && enrollment.expiresAt < new Date()) throw new Error("Access expired");
|
if (enrollment.expiresAt && enrollment.expiresAt < new Date()) throw new Error("Access expired");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Родителя читаем один раз: он нужен и для проверки, и для письма автору ветки.
|
||||||
|
const parent = parentId
|
||||||
|
? await prisma.lessonComment.findUnique({
|
||||||
|
where: { id: parentId },
|
||||||
|
select: {
|
||||||
|
lessonId: true,
|
||||||
|
userId: true,
|
||||||
|
deleted: true,
|
||||||
|
user: { select: { email: true, name: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: null;
|
||||||
if (parentId) {
|
if (parentId) {
|
||||||
if (!isAdmin && !isCurator) throw new Error("Forbidden");
|
if (!isAdmin && !isCurator) throw new Error("Forbidden");
|
||||||
const parent = await prisma.lessonComment.findUnique({ where: { id: parentId } });
|
|
||||||
if (!parent || parent.lessonId !== lessonId) throw new Error("Invalid parent");
|
if (!parent || parent.lessonId !== lessonId) throw new Error("Invalid parent");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,6 +201,8 @@ export async function addComment(lessonId: string, slug: string, text: string, p
|
|||||||
data: { lessonId, userId: session.user.id, text: trimmed, ...(parentId ? { parentId } : {}) },
|
data: { lessonId, userId: session.user.id, text: trimmed, ...(parentId ? { parentId } : {}) },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const lessonUrl = `${process.env.BETTER_AUTH_URL ?? "https://school.second-brain.ru"}/courses/${slug}/lessons/${lessonId}`;
|
||||||
|
|
||||||
// Уведомление админам/кураторам о новом комментарии студента (не о своих ответах)
|
// Уведомление админам/кураторам о новом комментарии студента (не о своих ответах)
|
||||||
if (!isAdmin && !isCurator) {
|
if (!isAdmin && !isCurator) {
|
||||||
const settings = await getSettings();
|
const settings = await getSettings();
|
||||||
@@ -196,7 +214,6 @@ export async function addComment(lessonId: string, slug: string, text: string, p
|
|||||||
where: { role: { in: ["admin", "curator"] } },
|
where: { role: { in: ["admin", "curator"] } },
|
||||||
select: { email: true, name: true },
|
select: { email: true, name: true },
|
||||||
});
|
});
|
||||||
const lessonUrl = `${process.env.BETTER_AUTH_URL ?? "https://school.second-brain.ru"}/courses/${slug}/lessons/${lessonId}`;
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
recipients.map((r) =>
|
recipients.map((r) =>
|
||||||
sendCommentNotificationEmail(r.email, r.name, session.user.name, lesson.title, trimmed, lessonUrl)
|
sendCommentNotificationEmail(r.email, r.name, session.user.name, lesson.title, trimmed, lessonUrl)
|
||||||
@@ -205,6 +222,28 @@ export async function addComment(lessonId: string, slug: string, text: string, p
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Обратное направление: автор ветки узнаёт письмом, что школа ответила. Без него
|
||||||
|
// ответ виден только тому, кто сам вернётся в урок.
|
||||||
|
if (parentId) {
|
||||||
|
const settings = await getSettings();
|
||||||
|
const recipient = commentReplyRecipient({
|
||||||
|
notifyEnabled: asBool(settings.notifyStudentOnCommentReply),
|
||||||
|
replierId: session.user.id,
|
||||||
|
replierRole: session.user.role,
|
||||||
|
parent,
|
||||||
|
});
|
||||||
|
if (recipient) {
|
||||||
|
await sendCommentReplyEmail(
|
||||||
|
recipient.email,
|
||||||
|
recipient.name,
|
||||||
|
lesson.title,
|
||||||
|
trimmed,
|
||||||
|
lessonUrl,
|
||||||
|
session.user.name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
revalidatePath(`/courses/${slug}/lessons/${lessonId}`);
|
revalidatePath(`/courses/${slug}/lessons/${lessonId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,3 +267,24 @@ export async function deleteComment(commentId: string, lessonId: string, slug: s
|
|||||||
|
|
||||||
revalidatePath(`/courses/${slug}/lessons/${lessonId}`);
|
revalidatePath(`/courses/${slug}/lessons/${lessonId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function editComment(commentId: string, lessonId: string, slug: string, text: string) {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session) throw new Error("Unauthorized");
|
||||||
|
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed || trimmed.length > 10000) throw new Error("Invalid text");
|
||||||
|
|
||||||
|
const comment = await prisma.lessonComment.findUnique({ where: { id: commentId } });
|
||||||
|
if (!comment) throw new Error("Not found");
|
||||||
|
if (comment.deleted) throw new Error("Comment deleted");
|
||||||
|
// Только автор может редактировать текст своего комментария (модераторы — только удаление).
|
||||||
|
if (comment.userId !== session.user.id) throw new Error("Forbidden");
|
||||||
|
|
||||||
|
await prisma.lessonComment.update({
|
||||||
|
where: { id: commentId },
|
||||||
|
data: { text: trimmed, editedAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(`/courses/${slug}/lessons/${lessonId}`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* Серверная отправка конверсии «регистрация» в Google Ads.
|
||||||
|
*
|
||||||
|
* Зачем серверно, а не тегом на странице: рекламный трафик по обучающим
|
||||||
|
* запросам ведём на бесплатный вход в школу, и Google должен видеть, какие
|
||||||
|
* клики превращаются в студентов. Тег на странице «спасибо» here не годится —
|
||||||
|
* после регистрации человека сразу редиректит в кабинет, и событие теряется
|
||||||
|
* (та же грабля, что была с покупками в payment-router, разбор 20260810).
|
||||||
|
*
|
||||||
|
* Всё gated по env: нет кредов — тихо ничего не делаем. Ошибки наружу не
|
||||||
|
* пробрасываются: аналитика не должна ронять регистрацию.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
|
||||||
|
const GOOGLE_ADS_API = "https://googleads.googleapis.com/v24";
|
||||||
|
|
||||||
|
async function accessToken(): Promise<string | null> {
|
||||||
|
const clientId = process.env.GADS_CLIENT_ID;
|
||||||
|
const clientSecret = process.env.GADS_CLIENT_SECRET;
|
||||||
|
const refreshToken = process.env.GADS_REFRESH_TOKEN;
|
||||||
|
if (!clientId || !clientSecret || !refreshToken) return null;
|
||||||
|
|
||||||
|
const res = await fetch(GOOGLE_TOKEN_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = (await res.json()) as { access_token?: string };
|
||||||
|
return data.access_token ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Формат Google Ads: "yyyy-mm-dd hh:mm:ss+hh:mm". Отдаём в Алматы (+05:00). */
|
||||||
|
function conversionDateTime(when: Date): string {
|
||||||
|
const almaty = new Date(when.getTime() + 5 * 3600 * 1000);
|
||||||
|
const p = (n: number) => String(n).padStart(2, "0");
|
||||||
|
return (
|
||||||
|
`${almaty.getUTCFullYear()}-${p(almaty.getUTCMonth() + 1)}-${p(almaty.getUTCDate())} ` +
|
||||||
|
`${p(almaty.getUTCHours())}:${p(almaty.getUTCMinutes())}:${p(almaty.getUTCSeconds())}+05:00`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendRegistrationConversion(gclid: string): Promise<boolean> {
|
||||||
|
const developerToken = process.env.GADS_DEVELOPER_TOKEN;
|
||||||
|
const customerId = process.env.GADS_CUSTOMER_ID;
|
||||||
|
const loginCustomerId = process.env.GADS_LOGIN_CUSTOMER_ID;
|
||||||
|
const actionId = process.env.GADS_SIGNUP_CONVERSION_ACTION_ID;
|
||||||
|
if (!gclid || !developerToken || !customerId || !actionId) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = await accessToken();
|
||||||
|
if (!token) return false;
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${GOOGLE_ADS_API}/customers/${customerId}:uploadClickConversions`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"developer-token": developerToken,
|
||||||
|
...(loginCustomerId ? { "login-customer-id": loginCustomerId } : {}),
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
conversions: [
|
||||||
|
{
|
||||||
|
gclid,
|
||||||
|
conversionAction: `customers/${customerId}/conversionActions/${actionId}`,
|
||||||
|
conversionDateTime: conversionDateTime(new Date()),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
partialFailure: true,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error("ads signup conversion failed", res.status, (await res.text()).slice(0, 300));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const body = (await res.json()) as { partialFailureError?: unknown };
|
||||||
|
if (body.partialFailureError) {
|
||||||
|
console.error("ads signup conversion partial failure", JSON.stringify(body.partialFailureError).slice(0, 300));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("ads signup conversion error", e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { decidePaidAccess, monthStartUtc } from "@/lib/clean-pdf/access";
|
||||||
|
|
||||||
|
const now = new Date("2026-07-06T10:00:00Z");
|
||||||
|
const freeSlug = "obsidian-start";
|
||||||
|
|
||||||
|
describe("decidePaidAccess", () => {
|
||||||
|
it("платный enrollment без срока — да", () => {
|
||||||
|
expect(decidePaidAccess({ role: "student", banned: false, freeSlug, now,
|
||||||
|
enrollments: [{ courseSlug: "zotero", expiresAt: null }] })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("только бесплатный курс — нет", () => {
|
||||||
|
expect(decidePaidAccess({ role: "student", banned: false, freeSlug, now,
|
||||||
|
enrollments: [{ courseSlug: "obsidian-start", expiresAt: null }] })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("без enrollments — нет", () => {
|
||||||
|
expect(decidePaidAccess({ role: "student", banned: false, freeSlug, now, enrollments: [] })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("истёкший платный — нет, живой срок — да", () => {
|
||||||
|
expect(decidePaidAccess({ role: "student", banned: false, freeSlug, now,
|
||||||
|
enrollments: [{ courseSlug: "zotero", expiresAt: new Date("2026-01-01") }] })).toBe(false);
|
||||||
|
expect(decidePaidAccess({ role: "student", banned: false, freeSlug, now,
|
||||||
|
enrollments: [{ courseSlug: "zotero", expiresAt: new Date("2027-01-01") }] })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("admin и curator — да даже без курсов", () => {
|
||||||
|
expect(decidePaidAccess({ role: "admin", banned: false, freeSlug, now, enrollments: [] })).toBe(true);
|
||||||
|
expect(decidePaidAccess({ role: "curator", banned: false, freeSlug, now, enrollments: [] })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("бан всё перекрывает", () => {
|
||||||
|
expect(decidePaidAccess({ role: "admin", banned: true, freeSlug, now, enrollments: [] })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("monthStartUtc", () => {
|
||||||
|
it("возвращает первое число месяца 00:00 UTC", () => {
|
||||||
|
expect(monthStartUtc(new Date("2026-07-06T23:59:59Z")).toISOString()).toBe("2026-07-01T00:00:00.000Z");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { PDF_KEY_PREFIX, generatePdfKey } from "@/lib/clean-pdf/api-key";
|
||||||
|
|
||||||
|
describe("generatePdfKey", () => {
|
||||||
|
it("формат sbpdf_<base64url>, достаточная длина", () => {
|
||||||
|
const key = generatePdfKey();
|
||||||
|
expect(key.startsWith(PDF_KEY_PREFIX)).toBe(true);
|
||||||
|
expect(key.length).toBeGreaterThanOrEqual(PDF_KEY_PREFIX.length + 32);
|
||||||
|
expect(key.slice(PDF_KEY_PREFIX.length)).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("две генерации различаются", () => {
|
||||||
|
expect(generatePdfKey()).not.toBe(generatePdfKey());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { generateCleanPdf } from "@/lib/clean-pdf/generate";
|
||||||
|
|
||||||
|
const enabled = process.env.RUN_PDF_INTEGRATION === "1";
|
||||||
|
|
||||||
|
describe.runIf(enabled)("generateCleanPdf (integration, нужен browserless)", () => {
|
||||||
|
it("генерирует PDF реальной статьи", async () => {
|
||||||
|
const { pdf, title } = await generateCleanPdf({
|
||||||
|
url: "https://habr.com/ru/articles/942236/",
|
||||||
|
format: "A4",
|
||||||
|
theme: "light",
|
||||||
|
});
|
||||||
|
expect(pdf.length).toBeGreaterThan(20_000);
|
||||||
|
expect(pdf.subarray(0, 5).toString()).toBe("%PDF-");
|
||||||
|
expect(title.length).toBeGreaterThan(3);
|
||||||
|
}, 120_000);
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { BlockedUrlError, assertPublicUrl, isPrivateAddress } from "@/lib/clean-pdf/ssrf";
|
||||||
|
|
||||||
|
describe("isPrivateAddress", () => {
|
||||||
|
const privateIps = [
|
||||||
|
"127.0.0.1", "0.0.0.0", "10.1.2.3", "100.64.0.1", "169.254.169.254",
|
||||||
|
"172.16.0.1", "172.31.255.255", "192.168.1.1", "192.0.0.8", "198.18.0.1",
|
||||||
|
"224.0.0.1", "255.255.255.255",
|
||||||
|
"::1", "::", "fc00::1", "fd12:3456::1", "fe80::1", "::ffff:10.0.0.1", "::ffff:127.0.0.1",
|
||||||
|
"::ffff:7f00:1", "::ffff:a00:1", "::ffff:c0a8:101", "::ffff:a9fe:a9fe",
|
||||||
|
];
|
||||||
|
const publicIps = ["93.184.216.34", "8.8.8.8", "172.32.0.1", "2606:2800:220:1::1", "::ffff:8.8.8.8", "::ffff:808:808"];
|
||||||
|
|
||||||
|
it.each(privateIps)("блокирует %s", (ip) => expect(isPrivateAddress(ip)).toBe(true));
|
||||||
|
it.each(publicIps)("пропускает %s", (ip) => expect(isPrivateAddress(ip)).toBe(false));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("assertPublicUrl", () => {
|
||||||
|
const publicResolve = async () => [{ address: "93.184.216.34", family: 4 }];
|
||||||
|
const privateResolve = async () => [{ address: "172.18.0.2", family: 4 }];
|
||||||
|
const mixedResolve = async () => [
|
||||||
|
{ address: "93.184.216.34", family: 4 },
|
||||||
|
{ address: "10.0.0.5", family: 4 },
|
||||||
|
];
|
||||||
|
|
||||||
|
it("пропускает публичный https-URL", async () => {
|
||||||
|
const url = await assertPublicUrl("https://example.com/article", publicResolve);
|
||||||
|
expect(url.hostname).toBe("example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"file:///etc/passwd",
|
||||||
|
"ftp://example.com/x",
|
||||||
|
"chrome://settings",
|
||||||
|
"not a url",
|
||||||
|
])("блокирует %s", async (raw) => {
|
||||||
|
await expect(assertPublicUrl(raw, publicResolve)).rejects.toThrow(BlockedUrlError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("блокирует localhost и .local без резолва", async () => {
|
||||||
|
await expect(assertPublicUrl("http://localhost:3000/x", publicResolve)).rejects.toThrow(BlockedUrlError);
|
||||||
|
await expect(assertPublicUrl("http://printer.local/x", publicResolve)).rejects.toThrow(BlockedUrlError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("блокирует литеральный приватный IP", async () => {
|
||||||
|
await expect(assertPublicUrl("http://192.168.1.1/admin", publicResolve)).rejects.toThrow(BlockedUrlError);
|
||||||
|
await expect(assertPublicUrl("http://[::1]:8080/", publicResolve)).rejects.toThrow(BlockedUrlError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("блокирует хост, резолвящийся в приватный IP (включая частично)", async () => {
|
||||||
|
await expect(assertPublicUrl("https://evil.example/x", privateResolve)).rejects.toThrow(BlockedUrlError);
|
||||||
|
await expect(assertPublicUrl("https://evil.example/x", mixedResolve)).rejects.toThrow(BlockedUrlError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("блокирует хост, который не резолвится", async () => {
|
||||||
|
const failResolve = async () => { throw new Error("ENOTFOUND"); };
|
||||||
|
await expect(assertPublicUrl("https://nope.example/x", failResolve)).rejects.toThrow(BlockedUrlError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("блокирует v4-mapped IPv6 в bracket-нотации (SSRF-обход)", async () => {
|
||||||
|
for (const raw of [
|
||||||
|
"http://[::ffff:127.0.0.1]/",
|
||||||
|
"http://[::ffff:10.0.0.1]/",
|
||||||
|
"http://[::ffff:169.254.169.254]/",
|
||||||
|
"http://[::ffff:192.168.1.1]/",
|
||||||
|
]) {
|
||||||
|
await expect(assertPublicUrl(raw, publicResolve)).rejects.toThrow(BlockedUrlError);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { buildCleanHtml, safePdfFilename } from "@/lib/clean-pdf/template";
|
||||||
|
|
||||||
|
const base = {
|
||||||
|
title: "Заголовок статьи",
|
||||||
|
url: "https://example.com/article",
|
||||||
|
theme: "light" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("buildCleanHtml", () => {
|
||||||
|
it("экранирует HTML в метаполях", () => {
|
||||||
|
const html = buildCleanHtml({ ...base, title: `<script>alert(1)</script>`, contentHtml: "<p>ок</p>" });
|
||||||
|
expect(html).not.toContain("<script>alert(1)</script>");
|
||||||
|
expect(html).toContain("<script>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("вставляет контент и шапку", () => {
|
||||||
|
const html = buildCleanHtml({ ...base, author: "Автор", site: "Example", contentHtml: "<p>Текст статьи</p>" });
|
||||||
|
expect(html).toContain("<p>Текст статьи</p>");
|
||||||
|
expect(html).toContain("Заголовок статьи");
|
||||||
|
expect(html).toContain("Автор");
|
||||||
|
expect(html).toContain("https://example.com/article");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("строит оглавление при 3+ заголовках и проставляет якоря", () => {
|
||||||
|
const content = "<h2>Один</h2><p>a</p><h2>Два</h2><p>b</p><h3>Три</h3><p>c</p>";
|
||||||
|
const html = buildCleanHtml({ ...base, contentHtml: content });
|
||||||
|
expect(html).toContain("Содержание");
|
||||||
|
expect(html).toMatch(/<h2 id="[^"]+">Один<\/h2>/);
|
||||||
|
expect((html.match(/class="toc-item/g) ?? []).length).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("не строит оглавление при <3 заголовках", () => {
|
||||||
|
const html = buildCleanHtml({ ...base, contentHtml: "<h2>Один</h2><p>a</p>" });
|
||||||
|
expect(html).not.toContain("Содержание");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("уникализирует одинаковые якоря", () => {
|
||||||
|
const content = "<h2>Раздел</h2><h2>Раздел</h2><h2>Раздел</h2>";
|
||||||
|
const html = buildCleanHtml({ ...base, contentHtml: content });
|
||||||
|
const ids = [...html.matchAll(/<h2 id="([^"]+)"/g)].map((m) => m[1]);
|
||||||
|
expect(new Set(ids).size).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("вырезает script и inline-обработчики из контента", () => {
|
||||||
|
const html = buildCleanHtml({ ...base, contentHtml: `<p onclick="alert(1)">t</p><script>alert(2)</script><a href="javascript:alert(3)">x</a>` });
|
||||||
|
expect(html).not.toContain("<script>alert(2)");
|
||||||
|
expect(html).not.toContain("onclick");
|
||||||
|
expect(html).not.toContain("javascript:alert(3)");
|
||||||
|
expect(html).toContain(">t<");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("переключает тёмную тему", () => {
|
||||||
|
const light = buildCleanHtml({ ...base, contentHtml: "<p>x</p>" });
|
||||||
|
const dark = buildCleanHtml({ ...base, theme: "dark", contentHtml: "<p>x</p>" });
|
||||||
|
expect(light).toContain('data-theme="light"');
|
||||||
|
expect(dark).toContain('data-theme="dark"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("safePdfFilename", () => {
|
||||||
|
it("убирает опасные символы и ограничивает длину", () => {
|
||||||
|
expect(safePdfFilename('Статья: как/не\\надо "делать"?')).toBe("Статья_ как_не_надо _делать_");
|
||||||
|
expect(safePdfFilename("")).toBe("document");
|
||||||
|
expect(safePdfFilename("a".repeat(300)).length).toBeLessThanOrEqual(140);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { buildZoteroScript } from "@/lib/clean-pdf/zotero-script";
|
||||||
|
|
||||||
|
describe("buildZoteroScript", () => {
|
||||||
|
it("подставляет ключ и адрес школы", () => {
|
||||||
|
const s = buildZoteroScript({ apiKey: "sbpdf_test123", baseUrl: "https://school.second-brain.ru" });
|
||||||
|
expect(s).toContain("'sbpdf_test123'");
|
||||||
|
expect(s).toContain("'https://school.second-brain.ru'");
|
||||||
|
expect(s).toContain("/api/pdf?url=");
|
||||||
|
expect(s).not.toContain("YOUR_KEY");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("генерирует синтаксически валидный JS", () => {
|
||||||
|
const s = buildZoteroScript({ apiKey: "sbpdf_test123", baseUrl: "https://school.second-brain.ru" });
|
||||||
|
expect(() => new Function(s)).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
// НЕ "clean-pdf": так CopyButton логирует копирования — они не должны тратить лимит
|
||||||
|
export const PDF_TOOL_ID = "clean-pdf-generate";
|
||||||
|
export const BURST_LIMIT = 5; // успешных генераций в минуту
|
||||||
|
const BURST_WINDOW_MS = 60_000;
|
||||||
|
|
||||||
|
export function decidePaidAccess(input: {
|
||||||
|
role: string;
|
||||||
|
banned: boolean;
|
||||||
|
enrollments: { courseSlug: string; expiresAt: Date | null }[];
|
||||||
|
freeSlug: string;
|
||||||
|
now?: Date;
|
||||||
|
}): boolean {
|
||||||
|
const now = input.now ?? new Date();
|
||||||
|
if (input.banned) return false;
|
||||||
|
if (input.role === "admin" || input.role === "curator") return true;
|
||||||
|
return input.enrollments.some(
|
||||||
|
(e) => e.courseSlug !== input.freeSlug && (e.expiresAt === null || e.expiresAt > now),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hasPaidAccess(userId: string): Promise<boolean> {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: {
|
||||||
|
role: true,
|
||||||
|
banned: true,
|
||||||
|
enrollments: { select: { expiresAt: true, course: { select: { slug: true } } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!user) return false;
|
||||||
|
return decidePaidAccess({
|
||||||
|
role: user.role,
|
||||||
|
banned: user.banned ?? false,
|
||||||
|
freeSlug: process.env.FREE_COURSE_SLUG ?? "obsidian-start",
|
||||||
|
enrollments: user.enrollments.map((e) => ({ courseSlug: e.course.slug, expiresAt: e.expiresAt })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function monthStartUtc(now: Date): Date {
|
||||||
|
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMonthlyLimit(): number {
|
||||||
|
const n = Number(process.env.PDF_MONTHLY_LIMIT);
|
||||||
|
return Number.isFinite(n) && n > 0 ? n : 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMonthlyUsage(userId: string): Promise<number> {
|
||||||
|
return prisma.toolUsage.count({
|
||||||
|
where: { userId, tool: PDF_TOOL_ID, createdAt: { gte: monthStartUtc(new Date()) } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBurstUsage(userId: string): Promise<number> {
|
||||||
|
return prisma.toolUsage.count({
|
||||||
|
where: { userId, tool: PDF_TOOL_ID, createdAt: { gte: new Date(Date.now() - BURST_WINDOW_MS) } },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
|
||||||
|
export const PDF_KEY_PREFIX = "sbpdf_";
|
||||||
|
|
||||||
|
export function generatePdfKey(): string {
|
||||||
|
return PDF_KEY_PREFIX + randomBytes(24).toString("base64url");
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { isIP } from "node:net";
|
||||||
|
import { chromium } from "playwright-core";
|
||||||
|
import { JSDOM } from "jsdom";
|
||||||
|
import { Defuddle } from "defuddle/node";
|
||||||
|
import { assertPublicUrl, isPrivateAddress } from "@/lib/clean-pdf/ssrf";
|
||||||
|
import { buildCleanHtml } from "@/lib/clean-pdf/template";
|
||||||
|
|
||||||
|
export class EmptyContentError extends Error {}
|
||||||
|
export class RenderError extends Error {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Оборачивает промис в app-level таймаут: если `p` не завершится за `ms`,
|
||||||
|
* гонка завершится отказом с RenderError, и внешний `finally` (browser.close())
|
||||||
|
* освободит слот browserless вместо того, чтобы держать его до зависшего вызова.
|
||||||
|
*/
|
||||||
|
function withTimeout<T>(p: Promise<T>, ms: number, message: string): Promise<T> {
|
||||||
|
let timer: ReturnType<typeof setTimeout>;
|
||||||
|
const guard = new Promise<never>((_, reject) => {
|
||||||
|
timer = setTimeout(() => reject(new RenderError(message)), ms);
|
||||||
|
timer.unref?.();
|
||||||
|
});
|
||||||
|
return Promise.race([p, guard]).finally(() => clearTimeout(timer)) as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GOTO_TIMEOUT_MS = 30_000;
|
||||||
|
const SETTLE_TIMEOUT_MS = 10_000;
|
||||||
|
const PDF_TIMEOUT_MS = 30_000;
|
||||||
|
const UA =
|
||||||
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36";
|
||||||
|
|
||||||
|
function browserWsUrl(): string {
|
||||||
|
const base = process.env.BROWSER_WS_URL;
|
||||||
|
if (!base) throw new RenderError("BROWSER_WS_URL не задан");
|
||||||
|
const token = process.env.BROWSERLESS_TOKEN;
|
||||||
|
return token ? `${base}${base.includes("?") ? "&" : "?"}token=${token}` : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateCleanPdf(opts: {
|
||||||
|
url: string;
|
||||||
|
format: "A4" | "Letter";
|
||||||
|
theme: "light" | "dark";
|
||||||
|
}): Promise<{ pdf: Buffer; title: string }> {
|
||||||
|
const target = await assertPublicUrl(opts.url);
|
||||||
|
|
||||||
|
const browser = await chromium.connectOverCDP(browserWsUrl()).catch((e) => {
|
||||||
|
throw new RenderError(`Браузер недоступен: ${e.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const context = await browser.newContext({ userAgent: UA, viewport: { width: 1280, height: 800 } });
|
||||||
|
// Вторая линия SSRF-обороны: страница не может дёргать приватные адреса
|
||||||
|
// (литеральные IP и localhost; hostnames уже проверены до goto).
|
||||||
|
await context.route("**/*", (route) => {
|
||||||
|
try {
|
||||||
|
const u = new URL(route.request().url());
|
||||||
|
const host = u.hostname.replace(/^\[|\]$/g, "").replace(/\.$/, "");
|
||||||
|
if (
|
||||||
|
(u.protocol !== "http:" && u.protocol !== "https:") ||
|
||||||
|
host === "localhost" || host.endsWith(".local") || host.endsWith(".internal") ||
|
||||||
|
(isIP(host) !== 0 && isPrivateAddress(host))
|
||||||
|
) {
|
||||||
|
return route.abort();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return route.abort();
|
||||||
|
}
|
||||||
|
return route.continue();
|
||||||
|
});
|
||||||
|
|
||||||
|
// context.route НЕ перехватывает WebSocket — отдельная защита от SSRF через ws://
|
||||||
|
await context.routeWebSocket("**/*", (ws) => {
|
||||||
|
try {
|
||||||
|
const u = new URL(ws.url());
|
||||||
|
const host = u.hostname.replace(/^\[|\]$/g, "").replace(/\.$/, "");
|
||||||
|
if (
|
||||||
|
(u.protocol !== "ws:" && u.protocol !== "wss:") ||
|
||||||
|
host === "localhost" || host.endsWith(".local") || host.endsWith(".internal") ||
|
||||||
|
(isIP(host) !== 0 && isPrivateAddress(host))
|
||||||
|
) {
|
||||||
|
ws.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
ws.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ws.connectToServer();
|
||||||
|
});
|
||||||
|
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto(target.href, { waitUntil: "domcontentloaded", timeout: GOTO_TIMEOUT_MS }).catch((e) => {
|
||||||
|
throw new RenderError(`Страница не открылась: ${e.message}`);
|
||||||
|
});
|
||||||
|
await page.waitForLoadState("networkidle", { timeout: SETTLE_TIMEOUT_MS }).catch(() => {});
|
||||||
|
|
||||||
|
const rawHtml = await page.content();
|
||||||
|
const pageTitle = await page.title().catch(() => "");
|
||||||
|
|
||||||
|
const dom = new JSDOM(rawHtml, { url: target.href });
|
||||||
|
const article = await Defuddle(dom.window.document, target.href);
|
||||||
|
// DefuddleResponse (node_modules/defuddle/dist/types.d.ts) типизирует
|
||||||
|
// content/title/author/site/domain/wordCount как обязательные (не optional) поля —
|
||||||
|
// сам объект тоже не nullable (Defuddle() не возвращает null/undefined).
|
||||||
|
// Отсутствие контента выражается пустой строкой/нулевым wordCount, не отсутствием поля.
|
||||||
|
if (!article.content || article.wordCount < 10) {
|
||||||
|
throw new EmptyContentError("Не удалось выделить содержимое страницы");
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = article.title || pageTitle || target.hostname;
|
||||||
|
const cleanHtml = buildCleanHtml({
|
||||||
|
title,
|
||||||
|
author: article.author || undefined,
|
||||||
|
site: article.site || article.domain || undefined,
|
||||||
|
url: target.href,
|
||||||
|
contentHtml: article.content,
|
||||||
|
theme: opts.theme,
|
||||||
|
});
|
||||||
|
|
||||||
|
const pdfPage = await context.newPage();
|
||||||
|
await pdfPage.setContent(cleanHtml, { waitUntil: "networkidle", timeout: SETTLE_TIMEOUT_MS }).catch(() => {});
|
||||||
|
// page.pdf() в playwright-core не принимает опцию timeout (проверено по
|
||||||
|
// node_modules/playwright-core/types/types.d.ts) и не имеет implicit-бага —
|
||||||
|
// оборачиваем в app-level withTimeout, чтобы зависание всё же дошло до
|
||||||
|
// finally (browser.close()) и не держало CONCURRENT-слот browserless.
|
||||||
|
const pdf = await withTimeout(
|
||||||
|
pdfPage.pdf({
|
||||||
|
format: opts.format,
|
||||||
|
printBackground: true,
|
||||||
|
margin: { top: "15mm", bottom: "18mm", left: "15mm", right: "15mm" },
|
||||||
|
}),
|
||||||
|
PDF_TIMEOUT_MS,
|
||||||
|
"Печать PDF не завершилась вовремя",
|
||||||
|
);
|
||||||
|
|
||||||
|
return { pdf, title };
|
||||||
|
} finally {
|
||||||
|
await browser.close().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { generatePdfKey } from "@/lib/clean-pdf/api-key";
|
||||||
|
|
||||||
|
export async function getOrCreatePdfKey(userId: string): Promise<string> {
|
||||||
|
const existing = await prisma.pdfApiKey.findUnique({ where: { userId } });
|
||||||
|
if (existing) return existing.key;
|
||||||
|
const created = await prisma.pdfApiKey.upsert({
|
||||||
|
where: { userId },
|
||||||
|
create: { userId, key: generatePdfKey() },
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
return created.key;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function regenerateKey(userId: string): Promise<string> {
|
||||||
|
const key = generatePdfKey();
|
||||||
|
await prisma.pdfApiKey.upsert({
|
||||||
|
where: { userId },
|
||||||
|
create: { userId, key },
|
||||||
|
update: { key },
|
||||||
|
});
|
||||||
|
return key;
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { lookup } from "node:dns/promises";
|
||||||
|
import { isIP } from "node:net";
|
||||||
|
|
||||||
|
export class BlockedUrlError extends Error {}
|
||||||
|
|
||||||
|
export type LookupFn = (
|
||||||
|
hostname: string,
|
||||||
|
opts: { all: true },
|
||||||
|
) => Promise<{ address: string; family: number }[]>;
|
||||||
|
|
||||||
|
function v4ToInt(ip: string): number {
|
||||||
|
return ip.split(".").reduce((acc, o) => acc * 256 + Number(o), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// [начало включительно, конец включительно] в виде 32-битных чисел
|
||||||
|
const V4_PRIVATE: Array<[number, number]> = [
|
||||||
|
["0.0.0.0", "0.255.255.255"], // "this network"
|
||||||
|
["10.0.0.0", "10.255.255.255"], // RFC1918
|
||||||
|
["100.64.0.0", "100.127.255.255"], // CGNAT
|
||||||
|
["127.0.0.0", "127.255.255.255"], // loopback
|
||||||
|
["169.254.0.0", "169.254.255.255"], // link-local / cloud metadata
|
||||||
|
["172.16.0.0", "172.31.255.255"], // RFC1918 (docker-сети попадают сюда)
|
||||||
|
["192.0.0.0", "192.0.0.255"], // IETF protocol assignments
|
||||||
|
["192.168.0.0", "192.168.255.255"], // RFC1918
|
||||||
|
["198.18.0.0", "198.19.255.255"], // benchmarking
|
||||||
|
["224.0.0.0", "255.255.255.255"], // multicast + reserved + broadcast
|
||||||
|
].map(([a, b]) => [v4ToInt(a), v4ToInt(b)] as [number, number]);
|
||||||
|
|
||||||
|
function isPrivateV4(ip: string): boolean {
|
||||||
|
const n = v4ToInt(ip);
|
||||||
|
return V4_PRIVATE.some(([lo, hi]) => n >= lo && n <= hi);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Разворачивает IPv6 (в т.ч. сжатый `::` и v4-mapped/embedded dotted) в 8 групп по 16 бит. */
|
||||||
|
function expandV6(ip: string): number[] | null {
|
||||||
|
let s = ip.toLowerCase();
|
||||||
|
|
||||||
|
// Встроенный dotted-хвост (::ffff:127.0.0.1, ::127.0.0.1) → в hex-группы
|
||||||
|
const dotted = s.match(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
|
||||||
|
if (dotted) {
|
||||||
|
if (isIP(dotted[1]) !== 4) return null;
|
||||||
|
const n = v4ToInt(dotted[1]);
|
||||||
|
s = s.slice(0, dotted.index) + ((n >>> 16) & 0xffff).toString(16) + ":" + (n & 0xffff).toString(16);
|
||||||
|
}
|
||||||
|
|
||||||
|
const halves = s.split("::");
|
||||||
|
if (halves.length > 2) return null;
|
||||||
|
const head = halves[0] ? halves[0].split(":") : [];
|
||||||
|
const tail = halves.length === 2 ? (halves[1] ? halves[1].split(":") : []) : [];
|
||||||
|
|
||||||
|
let groups: string[];
|
||||||
|
if (halves.length === 2) {
|
||||||
|
const missing = 8 - head.length - tail.length;
|
||||||
|
if (missing < 0) return null;
|
||||||
|
groups = [...head, ...Array(missing).fill("0"), ...tail];
|
||||||
|
} else {
|
||||||
|
groups = head;
|
||||||
|
}
|
||||||
|
if (groups.length !== 8) return null;
|
||||||
|
|
||||||
|
const nums = groups.map((g) => (g === "" ? 0 : parseInt(g, 16)));
|
||||||
|
if (nums.some((x) => Number.isNaN(x) || x < 0 || x > 0xffff)) return null;
|
||||||
|
return nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPrivateAddress(ip: string): boolean {
|
||||||
|
if (isIP(ip) === 4) return isPrivateV4(ip);
|
||||||
|
if (isIP(ip) !== 6) return true; // не IP — не пропускаем
|
||||||
|
|
||||||
|
const groups = expandV6(ip);
|
||||||
|
if (!groups) return true; // не смогли разобрать v6 — не пропускаем
|
||||||
|
|
||||||
|
// ::/96 (v4-compatible, вкл. :: и ::1) и ::ffff:0:0/96 (v4-mapped):
|
||||||
|
// младшие 32 бита содержат IPv4 — проверяем его напрямую.
|
||||||
|
const firstFiveZero = groups.slice(0, 5).every((g) => g === 0);
|
||||||
|
if (firstFiveZero && (groups[5] === 0 || groups[5] === 0xffff)) {
|
||||||
|
const v4num = groups[6] * 0x10000 + groups[7];
|
||||||
|
const dottedV4 = `${(v4num >>> 24) & 255}.${(v4num >>> 16) & 255}.${(v4num >>> 8) & 255}.${v4num & 255}`;
|
||||||
|
return isPrivateV4(dottedV4);
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = groups[0];
|
||||||
|
if (first >= 0xfc00 && first <= 0xfdff) return true; // fc00::/7 (ULA)
|
||||||
|
if (first >= 0xfe80 && first <= 0xfebf) return true; // fe80::/10 (link-local)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assertPublicUrl(raw: string, resolve: LookupFn = lookup): Promise<URL> {
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(raw);
|
||||||
|
} catch {
|
||||||
|
throw new BlockedUrlError("Некорректный URL");
|
||||||
|
}
|
||||||
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||||
|
throw new BlockedUrlError("Поддерживаются только http и https");
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostname = url.hostname.replace(/^\[|\]$/g, "").replace(/\.$/, ""); // [::1] → ::1
|
||||||
|
if (hostname === "localhost" || hostname.endsWith(".local") || hostname.endsWith(".internal")) {
|
||||||
|
throw new BlockedUrlError("Адрес недоступен");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isIP(hostname)) {
|
||||||
|
if (isPrivateAddress(hostname)) throw new BlockedUrlError("Адрес недоступен");
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
let addresses: { address: string; family: number }[];
|
||||||
|
try {
|
||||||
|
addresses = await resolve(hostname, { all: true });
|
||||||
|
} catch {
|
||||||
|
throw new BlockedUrlError("Не удалось определить адрес сайта");
|
||||||
|
}
|
||||||
|
if (addresses.length === 0 || addresses.some((a) => isPrivateAddress(a.address))) {
|
||||||
|
throw new BlockedUrlError("Адрес недоступен");
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { JSDOM } from "jsdom";
|
||||||
|
|
||||||
|
export interface CleanArticle {
|
||||||
|
title: string;
|
||||||
|
author?: string;
|
||||||
|
site?: string;
|
||||||
|
url: string;
|
||||||
|
contentHtml: string;
|
||||||
|
theme: "light" | "dark";
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function safePdfFilename(title: string): string {
|
||||||
|
const cleaned = title.replace(/[\\/:"*?<>|\n\r]+/g, "_").trim().slice(0, 140);
|
||||||
|
return cleaned || "document";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TocEntry { id: string; text: string; level: 2 | 3 }
|
||||||
|
|
||||||
|
/** Проставляет id заголовкам h2/h3 и возвращает контент + записи оглавления. */
|
||||||
|
function prepareContent(contentHtml: string): { html: string; toc: TocEntry[] } {
|
||||||
|
const dom = new JSDOM(`<body>${contentHtml}</body>`);
|
||||||
|
const doc = dom.window.document;
|
||||||
|
const used = new Set<string>();
|
||||||
|
const toc: TocEntry[] = [];
|
||||||
|
|
||||||
|
doc.querySelectorAll("h2, h3").forEach((h) => {
|
||||||
|
const text = (h.textContent ?? "").trim();
|
||||||
|
if (!text) return;
|
||||||
|
let id = text.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "") || "section";
|
||||||
|
let i = 2;
|
||||||
|
while (used.has(id)) id = `${id}-${i++}`;
|
||||||
|
used.add(id);
|
||||||
|
h.id = id;
|
||||||
|
toc.push({ id, text, level: h.tagName === "H2" ? 2 : 3 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Defuddle отдаёт очищенный, но не полностью доверенный HTML исходной
|
||||||
|
// страницы — вырезаем активное содержимое перед печатью в реальном браузере.
|
||||||
|
doc.querySelectorAll("script, style, iframe, object, embed").forEach((el) => el.remove());
|
||||||
|
doc.querySelectorAll("*").forEach((el) => {
|
||||||
|
for (const attr of [...el.attributes]) {
|
||||||
|
if (attr.name.startsWith("on") || (attr.name === "href" && attr.value.trim().toLowerCase().startsWith("javascript:"))) {
|
||||||
|
el.removeAttribute(attr.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { html: doc.body.innerHTML, toc };
|
||||||
|
}
|
||||||
|
|
||||||
|
const CSS = `
|
||||||
|
:root[data-theme="light"] { --bg: #faf7f0; --fg: #1f1d1a; --muted: #6b6459; --line: #d8d2c4; --accent: #7a2e2e; }
|
||||||
|
:root[data-theme="dark"] { --bg: #201e1b; --fg: #e8e4dc; --muted: #a39b8d; --line: #3d3a34; --accent: #d4a0a0; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { background: var(--bg); color: var(--fg); font-family: Georgia, "Times New Roman", serif;
|
||||||
|
font-size: 12.5pt; line-height: 1.65; margin: 0; }
|
||||||
|
main { max-width: 100%; }
|
||||||
|
h1 { font-size: 22pt; line-height: 1.25; margin: 0 0 6pt; }
|
||||||
|
h2 { font-size: 16pt; margin: 20pt 0 8pt; }
|
||||||
|
h3 { font-size: 13.5pt; margin: 16pt 0 6pt; }
|
||||||
|
p { margin: 0 0 9pt; }
|
||||||
|
a { color: var(--accent); text-decoration: none; }
|
||||||
|
img, video, iframe { max-width: 100%; height: auto; }
|
||||||
|
pre { background: rgba(127,127,127,.08); border: 1px solid var(--line); padding: 8pt;
|
||||||
|
overflow-x: hidden; white-space: pre-wrap; word-wrap: break-word;
|
||||||
|
font-family: "SF Mono", Menlo, Consolas, monospace; font-size: 9.5pt; }
|
||||||
|
code { font-family: "SF Mono", Menlo, Consolas, monospace; font-size: 0.9em; }
|
||||||
|
blockquote { border-left: 3px solid var(--line); margin: 0 0 9pt; padding: 2pt 0 2pt 12pt; color: var(--muted); }
|
||||||
|
table { border-collapse: collapse; width: 100%; font-size: 10.5pt; }
|
||||||
|
th, td { border: 1px solid var(--line); padding: 4pt 6pt; text-align: left; }
|
||||||
|
figure { margin: 0 0 9pt; } figcaption { color: var(--muted); font-size: 9.5pt; }
|
||||||
|
.meta { color: var(--muted); font-size: 10pt; margin-bottom: 4pt; }
|
||||||
|
.meta a { color: var(--muted); }
|
||||||
|
.head-rule { border: 0; border-top: 1px solid var(--line); margin: 12pt 0 16pt; }
|
||||||
|
.toc { border: 1px solid var(--line); padding: 10pt 14pt; margin: 0 0 16pt; }
|
||||||
|
.toc-title { font-weight: bold; margin-bottom: 6pt; }
|
||||||
|
.toc-item { display: block; margin: 2pt 0; }
|
||||||
|
.toc-item.lvl3 { padding-left: 14pt; font-size: 0.92em; }
|
||||||
|
h2, h3 { break-after: avoid; } pre, blockquote, figure, table { break-inside: avoid; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
export function buildCleanHtml(article: CleanArticle): string {
|
||||||
|
const { html, toc } = prepareContent(article.contentHtml);
|
||||||
|
const metaParts = [article.site, article.author].filter(Boolean).map((s) => escapeHtml(s!));
|
||||||
|
|
||||||
|
const tocHtml = toc.length >= 3
|
||||||
|
? `<nav class="toc"><div class="toc-title">Содержание</div>${toc
|
||||||
|
.map((t) => `<a class="toc-item lvl${t.level}" href="#${t.id}">${escapeHtml(t.text)}</a>`)
|
||||||
|
.join("")}</nav>`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="ru" data-theme="${article.theme}">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>${escapeHtml(article.title)}</title>
|
||||||
|
<style>${CSS}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>${escapeHtml(article.title)}</h1>
|
||||||
|
${metaParts.length ? `<div class="meta">${metaParts.join(" · ")}</div>` : ""}
|
||||||
|
<div class="meta"><a href="${escapeHtml(article.url)}">${escapeHtml(article.url)}</a></div>
|
||||||
|
<hr class="head-rule">
|
||||||
|
${tocHtml}
|
||||||
|
${html}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
// Адаптация скрипта Clean PDF (pdf.brainysnipe.ru/zotero-script.js) под школу.
|
||||||
|
export function buildZoteroScript({ apiKey, baseUrl }: { apiKey: string; baseUrl: string }): string {
|
||||||
|
return `// ── Настройки ──────────────────────────────────────────────────────────────
|
||||||
|
const API_KEY = '${apiKey}'; // персональный ключ из ${baseUrl}/tools/clean-pdf
|
||||||
|
const SERVICE_URL = '${baseUrl}';
|
||||||
|
const FORMAT = 'A4'; // 'A4' или 'Letter'
|
||||||
|
const THEME = 'light'; // 'light' или 'dark'
|
||||||
|
// ───────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (typeof item === "undefined" || !item) return;
|
||||||
|
(async function () {
|
||||||
|
let tempFilePath = null;
|
||||||
|
try {
|
||||||
|
function joinPath(dir, filename) {
|
||||||
|
if (typeof OS !== "undefined" && OS.Path && OS.Path.join) return OS.Path.join(dir, filename);
|
||||||
|
const separator = dir.includes("\\\\") ? "\\\\" : "/";
|
||||||
|
return dir.replace(/[\\\\/]+$/, "") + separator + filename;
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetItem = item;
|
||||||
|
if (!targetItem.isRegularItem()) {
|
||||||
|
if (targetItem.isAttachment() || targetItem.isNote())
|
||||||
|
targetItem = Zotero.Items.get(targetItem.parentID);
|
||||||
|
}
|
||||||
|
if (!targetItem?.isRegularItem()) {
|
||||||
|
Zotero.alert(null, "Чистый PDF", "Выберите обычную запись Zotero.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = targetItem.getField("url");
|
||||||
|
if (!url) { Zotero.alert(null, "Чистый PDF", "У записи нет URL."); return; }
|
||||||
|
|
||||||
|
const pw = new Zotero.ProgressWindow();
|
||||||
|
pw.changeHeadline("Чистый PDF");
|
||||||
|
const progress = new pw.ItemProgress(targetItem.getImageSrc(), targetItem.getField("title"));
|
||||||
|
pw.show();
|
||||||
|
pw.addDescription("Генерируем PDF…");
|
||||||
|
|
||||||
|
const response = await Zotero.getMainWindow().fetch(
|
||||||
|
SERVICE_URL + "/api/pdf?url=" + encodeURIComponent(url) + "&format=" + FORMAT + "&theme=" + THEME,
|
||||||
|
{ headers: { 'Authorization': 'Bearer ' + API_KEY } }
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.json().catch(() => ({}));
|
||||||
|
progress.setError();
|
||||||
|
pw.addDescription("Ошибка: " + (body.error || ("HTTP " + response.status)));
|
||||||
|
pw.startCloseTimer(8000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uint8Array = new Uint8Array(await (await response.blob()).arrayBuffer());
|
||||||
|
if (uint8Array.length < 1000) {
|
||||||
|
progress.setError(); pw.addDescription("Пришёл пустой PDF."); pw.startCloseTimer(8000); return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeTitle = (targetItem.getField("title") || "Document").replace(/[\\\\/:"*?<>|]+/g, "_").slice(0, 140);
|
||||||
|
tempFilePath = joinPath(Zotero.getTempDirectory().path, safeTitle + ".pdf");
|
||||||
|
await Zotero.getMainWindow().IOUtils.write(tempFilePath, uint8Array);
|
||||||
|
await Zotero.Attachments.importFromFile({ file: tempFilePath, parentItemID: targetItem.id, contentType: "application/pdf" });
|
||||||
|
|
||||||
|
for (const attId of targetItem.getAttachments()) {
|
||||||
|
const att = Zotero.Items.get(attId);
|
||||||
|
const ct = att?.attachmentContentType || "";
|
||||||
|
if (ct === "text/html" || ct === "application/zip") await att.eraseTx();
|
||||||
|
}
|
||||||
|
|
||||||
|
progress.setProgress(100);
|
||||||
|
const usesCount = response.headers.get('X-Uses-Count');
|
||||||
|
const maxUses = response.headers.get('X-Max-Uses');
|
||||||
|
const usageInfo = (usesCount && maxUses) ? (" (" + usesCount + "/" + maxUses + " за месяц)") : "";
|
||||||
|
pw.addDescription("PDF прикреплён." + usageInfo);
|
||||||
|
pw.startCloseTimer(4000);
|
||||||
|
} catch (e) {
|
||||||
|
Zotero.alert(null, "Ошибка", e.toString());
|
||||||
|
} finally {
|
||||||
|
if (tempFilePath) {
|
||||||
|
await Zotero.getMainWindow().IOUtils.remove(tempFilePath, { ignoreAbsent: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* Кому уходит письмо об ответе на комментарий под уроком.
|
||||||
|
*
|
||||||
|
* Решение вынесено из серверного действия отдельной чистой функцией: у действия
|
||||||
|
* на входе сессия и Prisma, и покрыть тестами сами правила «шлём / не шлём» там
|
||||||
|
* нельзя. Ответы в ветке умеют писать только админ и куратор (см. addComment),
|
||||||
|
* поэтому адресат всегда один — автор родительского комментария.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface CommentReplyParent {
|
||||||
|
userId: string;
|
||||||
|
deleted: boolean;
|
||||||
|
user: { email: string; name: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommentReplyRecipient {
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function commentReplyRecipient(params: {
|
||||||
|
notifyEnabled: boolean;
|
||||||
|
replierId: string;
|
||||||
|
replierRole: string | null | undefined;
|
||||||
|
parent: CommentReplyParent | null;
|
||||||
|
}): CommentReplyRecipient | null {
|
||||||
|
const { notifyEnabled, replierId, replierRole, parent } = params;
|
||||||
|
|
||||||
|
if (!notifyEnabled) return null;
|
||||||
|
if (replierRole !== "admin" && replierRole !== "curator") return null;
|
||||||
|
if (!parent) return null;
|
||||||
|
// Ветку удалённого комментария ученик в уроке не увидит — письмо вело бы в пустоту.
|
||||||
|
if (parent.deleted) return null;
|
||||||
|
// Свой же комментарий: staff отвечает сам себе или уточняет собственную мысль.
|
||||||
|
if (parent.userId === replierId) return null;
|
||||||
|
|
||||||
|
const email = parent.user?.email?.trim();
|
||||||
|
if (!email) return null;
|
||||||
|
|
||||||
|
return { email, name: parent.user?.name?.trim() ?? "" };
|
||||||
|
}
|
||||||
+66
-5
@@ -89,11 +89,19 @@ function btn(href: string, label: string) {
|
|||||||
</table>`;
|
</table>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text: string) {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
function quote(text: string) {
|
function quote(text: string) {
|
||||||
return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="margin:16px 0;">
|
return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="margin:16px 0;">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="border-left:3px solid #323232;padding:10px 14px;background-color:#E8F0D8;">
|
<td style="border-left:3px solid #323232;padding:10px 14px;background-color:#E8F0D8;">
|
||||||
<p style="margin:0;font-family:'Courier New',Courier,monospace;font-size:13px;line-height:1.6;color:#323232;">${text.replace(/\n/g, "<br/>")}</p>
|
<p style="margin:0;font-family:'Courier New',Courier,monospace;font-size:13px;line-height:1.6;color:#323232;">${escapeHtml(text).replace(/\n/g, "<br/>")}</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>`;
|
</table>`;
|
||||||
@@ -335,27 +343,80 @@ export async function sendQuestionCreatedEmail(
|
|||||||
}).catch((e) => console.error("[email] sendQuestionCreatedEmail:", e));
|
}).catch((e) => console.error("[email] sendQuestionCreatedEmail:", e));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Текст ответа кладём прямо в письмо: раньше письмо звало в LMS «прочитать ответ»,
|
||||||
|
// и ради двух строк человеку надо было авторизоваться — часть студентов так и не
|
||||||
|
// открывала тред (isRead=false при доставленном письме).
|
||||||
|
const REPLY_PREVIEW_LIMIT = 2000;
|
||||||
|
|
||||||
export async function sendQuestionReplyEmail(
|
export async function sendQuestionReplyEmail(
|
||||||
to: string,
|
to: string,
|
||||||
studentName: string,
|
studentName: string,
|
||||||
questionTitle: string,
|
questionTitle: string,
|
||||||
questionId: string,
|
questionId: string,
|
||||||
|
replyText: string,
|
||||||
|
hasFiles = false,
|
||||||
) {
|
) {
|
||||||
const school = await getSchoolName();
|
const school = await getSchoolName();
|
||||||
|
const trimmed = replyText.trim();
|
||||||
|
const preview =
|
||||||
|
trimmed.length > REPLY_PREVIEW_LIMIT
|
||||||
|
? `${trimmed.slice(0, REPLY_PREVIEW_LIMIT)}…`
|
||||||
|
: trimmed;
|
||||||
|
const truncated = trimmed.length > REPLY_PREVIEW_LIMIT;
|
||||||
|
|
||||||
await getResend().emails.send({
|
await getResend().emails.send({
|
||||||
from: FROM,
|
from: FROM,
|
||||||
to,
|
to,
|
||||||
subject: `Ответ на ваш вопрос`,
|
subject: `Ответ на ваш вопрос`,
|
||||||
html: base(`
|
html: base(`
|
||||||
<p ${p}>Привет, ${studentName}!</p>
|
<p ${p}>Привет, ${studentName}!</p>
|
||||||
<p ${p}>Школа ответила на ваш вопрос:</p>
|
<p ${p}>Школа ответила на ваш вопрос «${escapeHtml(questionTitle)}»:</p>
|
||||||
${quote(questionTitle)}
|
${quote(preview)}
|
||||||
<p ${pLast}>Откройте тред чтобы прочитать ответ:</p>
|
${truncated ? `<p ${p}>Ответ длинный — целиком он в треде.</p>` : ""}
|
||||||
${btn(`${BASE_URL}/questions/${questionId}`, "Читать ответ")}
|
${hasFiles ? `<p ${p}>К ответу приложены файлы — открыть их можно в треде.</p>` : ""}
|
||||||
|
<p ${pLast}>Ответить или посмотреть переписку целиком:</p>
|
||||||
|
${btn(`${BASE_URL}/questions/${questionId}`, "Открыть тред")}
|
||||||
`, school),
|
`, school),
|
||||||
}).catch((e) => console.error("[email] sendQuestionReplyEmail:", e));
|
}).catch((e) => console.error("[email] sendQuestionReplyEmail:", e));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ответ под уроком — это развёрнутый разбор, а не реплика в чате: ввод разрешает
|
||||||
|
// 10 000 символов, и порог вопросов (2000) резал бы каждый второй такой ответ.
|
||||||
|
// Письму нужен только предохранитель от мегабайтов, отсюда 4000.
|
||||||
|
const COMMENT_REPLY_PREVIEW_LIMIT = 4000;
|
||||||
|
|
||||||
|
export async function sendCommentReplyEmail(
|
||||||
|
to: string,
|
||||||
|
studentName: string,
|
||||||
|
lessonTitle: string,
|
||||||
|
replyText: string,
|
||||||
|
lessonUrl: string,
|
||||||
|
replierName?: string,
|
||||||
|
) {
|
||||||
|
const school = await getSchoolName();
|
||||||
|
const trimmed = replyText.trim();
|
||||||
|
const preview =
|
||||||
|
trimmed.length > COMMENT_REPLY_PREVIEW_LIMIT
|
||||||
|
? `${trimmed.slice(0, COMMENT_REPLY_PREVIEW_LIMIT)}…`
|
||||||
|
: trimmed;
|
||||||
|
const truncated = trimmed.length > COMMENT_REPLY_PREVIEW_LIMIT;
|
||||||
|
const title = escapeHtml(lessonTitle);
|
||||||
|
|
||||||
|
await getResend().emails.send({
|
||||||
|
from: FROM,
|
||||||
|
to,
|
||||||
|
subject: `Ответ на ваш комментарий к уроку «${lessonTitle}»`,
|
||||||
|
html: base(`
|
||||||
|
<p ${p}>Привет${studentName ? `, ${escapeHtml(studentName)}` : ""}!</p>
|
||||||
|
<p ${p}>Школа ответила на ваш комментарий под уроком <strong>«${title}»</strong>.${replierName ? ` Отвечает <strong>${escapeHtml(replierName)}</strong>:` : ""}</p>
|
||||||
|
${quote(preview)}
|
||||||
|
${truncated ? `<p ${p}>Ответ длинный — целиком он под уроком.</p>` : ""}
|
||||||
|
<p ${pLast}>Посмотреть ветку и ответить:</p>
|
||||||
|
${btn(lessonUrl, "Открыть урок")}
|
||||||
|
`, school),
|
||||||
|
}).catch((e) => console.error("[email] sendCommentReplyEmail:", e));
|
||||||
|
}
|
||||||
|
|
||||||
export async function sendQuestionFollowUpEmail(
|
export async function sendQuestionFollowUpEmail(
|
||||||
to: string,
|
to: string,
|
||||||
recipientName: string,
|
recipientName: string,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export const SETTINGS_DEFAULTS = {
|
|||||||
notifyOnComment: "true",
|
notifyOnComment: "true",
|
||||||
notifyOnRegistration: "true",
|
notifyOnRegistration: "true",
|
||||||
notifyStudentOnFeedback: "true",
|
notifyStudentOnFeedback: "true",
|
||||||
|
notifyStudentOnCommentReply: "true",
|
||||||
|
|
||||||
// Student profile
|
// Student profile
|
||||||
requireEmailVerification: "true",
|
requireEmailVerification: "true",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export type ToolId = "callout" | "frontmatter" | "theme" | "style-settings" | "dataview" | "bases";
|
export type ToolId = "callout" | "frontmatter" | "theme" | "style-settings" | "dataview" | "bases" | "clean-pdf";
|
||||||
|
|
||||||
export const TOOL_IDS: ToolId[] = [
|
export const TOOL_IDS: ToolId[] = [
|
||||||
"callout",
|
"callout",
|
||||||
@@ -7,6 +7,7 @@ export const TOOL_IDS: ToolId[] = [
|
|||||||
"style-settings",
|
"style-settings",
|
||||||
"dataview",
|
"dataview",
|
||||||
"bases",
|
"bases",
|
||||||
|
"clean-pdf",
|
||||||
];
|
];
|
||||||
|
|
||||||
export interface ToolMeta {
|
export interface ToolMeta {
|
||||||
@@ -24,4 +25,5 @@ export const TOOLS: ToolMeta[] = [
|
|||||||
{ id: "style-settings", title: "Генератор Style Settings", description: "Комментарии-настройки для плагина Style Settings.", icon: "SlidersHorizontal" },
|
{ id: "style-settings", title: "Генератор Style Settings", description: "Комментарии-настройки для плагина Style Settings.", icon: "SlidersHorizontal" },
|
||||||
{ id: "dataview", title: "Генератор Dataview", description: "Запросы DQL: таблицы, списки и задачи по заметкам.", icon: "Table2" },
|
{ id: "dataview", title: "Генератор Dataview", description: "Запросы DQL: таблицы, списки и задачи по заметкам.", icon: "Table2" },
|
||||||
{ id: "bases", title: "Генератор Bases", description: "Базы Obsidian (.base): представления с фильтрами.", icon: "Database" },
|
{ id: "bases", title: "Генератор Bases", description: "Базы Obsidian (.base): представления с фильтрами.", icon: "Database" },
|
||||||
|
{ id: "clean-pdf", title: "Чистый PDF", description: "Любая веб-страница → аккуратный PDF без рекламы и мусора. Интеграция с Zotero.", icon: "FileText" },
|
||||||
];
|
];
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getSessionCookie } from "better-auth/cookies";
|
import { getSessionCookie } from "better-auth/cookies";
|
||||||
|
|
||||||
const PUBLIC_ROUTES = ["/login", "/register", "/verify-email", "/forgot-password", "/reset-password", "/api/auth", "/api/register", "/api/internal", "/maintenance", "/share/wrapped"];
|
const PUBLIC_ROUTES = ["/login", "/register", "/verify-email", "/forgot-password", "/reset-password", "/api/auth", "/api/register", "/api/internal", "/api/pdf", "/maintenance", "/share/wrapped"];
|
||||||
|
|
||||||
export function middleware(request: NextRequest) {
|
export function middleware(request: NextRequest) {
|
||||||
const { pathname } = request.nextUrl;
|
const { pathname } = request.nextUrl;
|
||||||
|
|||||||
+1
-1
@@ -7,6 +7,6 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
environment: "node",
|
environment: "node",
|
||||||
include: ["src/lib/tools/**/__tests__/**/*.test.ts"],
|
include: ["src/lib/**/__tests__/**/*.test.ts"],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user