Author SHA1 Message Date
adminsandClaude Opus 5 8223cbb963 feat(комментарии): письмо ученику об ответе под уроком
Ответы персонала в ветке комментариев никуда не уведомляли: ученик видел
ответ, только если сам возвращался в урок. У тредов вопросов письмо есть,
у комментариев не было — из-за этого ответ под уроком 6.3 «Холст» висел
непрочитанным, пока его не продублировали руками.

- sendCommentReplyEmail: тот же шаблон ДС-2, что у ответов в тредах, с текстом
  ответа внутри письма и кнопкой на урок. Свой лимит превью 4000 символов:
  ввод разрешает 10 000, а порог вопросов (2000) резал бы развёрнутые разборы.
- commentReplyRecipient: правила «шлём / не шлём» вынесены чистой функцией и
  покрыты тестами — не шлём при выключенной настройке, не-staff, удалённом
  родителе, ответе самому себе и пустой почте.
- notifyStudentOnCommentReply: тумблер в админке рядом с уведомлением о фидбеке,
  по умолчанию включён.
- Удалён неподключённый дубль comment-actions.ts: компонент комментариев ходит
  в lib/actions/student-actions.ts, а в дубле жил свой лимит 2000 — правка в нём
  не давала эффекта.

Проверено: type-check и lint чистые (3 ошибки линтера — существующий долг в
quick-enroll-modal и kinescope-player), vitest 13/13, письмо отправлено боем
и доставлено (Resend, last_event=delivered).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 11:49:45 +05:00
adminsandClaude Opus 5 273cb2d1b4 feat(planner): вход в конструктор планнеров по подписанному токену
GET /api/planner/start проверяет сессию и доступ к курсу planner,
подписывает короткоживущий токен (HMAC-SHA256, 15 минут) и отправляет
в planner.second-brain.ru. Токен уходит во фрагменте, а не в строке
запроса: так он не попадает ни в логи, ни в Referer.

Алгоритм подписи — зеркало packages/web/src/auth.mjs в репозитории
планнера; совместимость проверена.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1UnMjwLLs22j7rymn9vUq
2026-09-14 10:19:52 +05:00
adminsandClaude Opus 5 5ad0bd98f1 Document clean-pdf state, correct SSRF limits and prod gate
Audit of the shipped Chistyy PDF feature against the actual code found
three documentation defects and one misplaced gate:

- The SSRF write-up understated the hole. assertPublicUrl resolves DNS
  exactly once, for the initial URL; the in-browser filter never resolves
  hostnames at all. Any new hostname after the first navigation (redirect,
  subresource, fetch, ws://) goes unchecked - a DNS rebind is not even
  required. Corrected in TECHNICAL.md and the design spec.
- The prod gate was tied to TOOLBOX_VISIBLE, but /api/pdf sits in
  PUBLIC_ROUTES and authenticates itself, so the feature goes live the
  moment browserless and BROWSER_WS_URL appear on prod - before the flag.
  Gate is now tied to the renderer.
- TECHNICAL.md claimed the browserless port is published on neither
  staging nor prod. It is published on dev/staging (127.0.0.1:3333) and
  the SSH tunnel depends on it.
- AGENTS.md described a src/proxy.ts that does not exist; route protection
  lives in src/middleware.ts.

Also adds a state snapshot (docs/plans) and a "grabli uklada" section to
CLAUDE.md covering the non-obvious conventions already enforced in code:
the two ToolUsage ids, the vitest include pattern, page.pdf() without a
timeout option, context.route not seeing WebSockets, and NEXT_PUBLIC_*
being inlined at build time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sy7vY7WQ1A3q1MkgsDd8VB
2026-09-12 12:55:46 +05:00
adminsandClaude Fable 5 03aaec76c2 Put the reply text inside question notification emails
The notification only said that the school had answered and linked into
the LMS, so a student had to log in to read two lines. Delivered mail
plus an unread thread was the common outcome — one such thread was a
pre-sale question that never converted.

The mail now carries the answer itself (capped at 2000 chars, with a
pointer to the thread when longer or when files are attached). Quoted
blocks are HTML-escaped, which they were not before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 13:41:33 +05:00
admins a92592ec63 Autolink bare URLs in lesson comments
Comments render CommonMark, which leaves a pasted https://… as dead text —
students share links constantly and nobody writes [text](url) by hand. Add
remark-gfm so plain URLs, www hosts and emails become links (and tables and
strikethrough come along), plus table styling so a wide table scrolls inside
itself instead of stretching the page.

Sanitizing is unchanged: javascript: is neither linked nor autolinked, raw
HTML is still dropped, and third-party images are still rendered as plain
links by the img component.
2026-08-16 11:55:48 +05:00
admins 8fc9708233 feat(ads): конверсия «регистрация» в Google Ads по gclid
Рекламу по обучающим запросам (obsidian как пользоваться, obsidian заметки)
ведём на бесплатный вход в школу, а не на продажу: порог входа нулевой,
а из 19 бесплатных регистраций 6 дошли до покупки (32%).

Чтобы Google видел, какие клики дают студентов:
- User.gclid — идентификатор клика рядом с уже собираемыми UTM;
- gclid прокидывается из URL через page → форму → API (той же цепочкой);
- src/lib/ads-conversion.ts — серверная отправка конверсии 7719084914
  (UPLOAD_CLICKS). Серверно, а не тегом: после регистрации сразу редирект
  в кабинет, тег на странице «спасибо» не сработает — грабля из разбора
  платежей 20260810.

Всё gated по env (GADS_*): нет кредов — тихо не отправляем, регистрацию
не роняем. Миграция идемпотентна: колонка заведена на проде вручную.
2026-08-13 11:25:26 +05:00
adminsandClaude Opus 5 20a25695bc Fix .md attachment upload in student questions
Windows reports an empty or generic MIME type for .md files, so the
type-first check rejected them with "Разрешены только jpg, png, pdf, md"
even though the extension was allowed — a client hit this while sending
us a vault note.

Extension is now the primary gate; MIME is validated only when the
browser actually sent a meaningful one. Generic types fall back to a
type derived from the extension so the file is served correctly later.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 10:27:14 +05:00
admins 005cd329b2 Allow image uploads in lesson comments
Add an image button to the comment toolbar backed by a new upload route
(images only, 5 MB, stored under comments/<userId>/), inserted as Markdown.

Only images served from our own storage are rendered: a third-party src would
be fetched by every reader of the lesson, exposing their address. Such links
are shown as plain links instead — this also defuses the external images a few
existing comments already carry.
2026-08-06 11:41:19 +05:00
admins 917253990b Drop react-markdown node prop from rendered comment links
The custom `a` renderer spread react-markdown's internal `node` prop onto
the anchor, leaking node="[object Object]" into the DOM on every comment link.
Destructure it out.
2026-07-31 19:48:31 +05:00
admins 2498d0f8da Enrich lesson comments: Markdown formatting, edit, higher limit
Add a formatting toolbar + safe Markdown rendering (react-markdown +
rehype-sanitize), raise the length limit 2000 -> 10000, and let authors
edit their own comments (new editComment action + editedAt column shown
as an 'изменено' badge).
2026-07-31 19:35:58 +05:00
adminsandClaude Fable 5 08e212365d Link homework review entry points to pending filter
Dashboard tiles and the curator CTA pointed to /curator/homework without
a status filter, so the default all-submissions list (newest first)
buried old PENDING items pages deep — 19 June submissions sat unseen
for 6 weeks while the top of the list showed freshly approved work.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 10:27:03 +05:00
admins 92fd88964e Accept buyer phone in grant API and enrich existing users
payment-router now forwards name/phone from the order. Store phone on
create; for existing users fill name (when it is an email placeholder)
and phone (when empty) without overwriting meaningful data. Also stop
dropping the parsed phone column in CSV import.
2026-07-11 19:41:49 +05:00
admins 33c0d3cafd compose: db на PG18 (тег + раскладка тома)
Прод LMS на Hoster.kz переведён с PostgreSQL 16 на 18 (09.07.2026,
dump/restore по регламенту major-upgrade-docker-бд). У PG18 другая
раскладка: том монтируется в /var/lib/postgresql, кластер лежит
в подкаталоге 18/docker.

Этот файл исполняется на сборочном сервере Hetzner, где том
lms-sb_postgres_data — hot-standby, уже переведённый на PG18 18.06.
То есть db-секция с postgres:16 + mount .../data была взведённой миной:
`docker compose -f docker-compose.prod.yml up -d` пересоздал бы standby
как PG16 поверх данных PG18 → initdb на непустом томе → рестарт-луп.
Ровно так легли lms-sb-db (01.07) и crm-prod (09.07).

Прод-compose Hoster (/root/lms-sb/docker-compose.yml) в git не живёт
и выровнен на месте; бэкап — /root/backups/major-upgrade-20260709/.
2026-07-09 17:28:27 +05:00
admins 5d94949810 Redirect large/non-inline lesson files to CDN instead of proxying
ZIP/DOCX etc. download natively — no need to force attachment; and the
157 MB Second Brain Vault archive must stream from Caddy with Range,
not through the app. Proxy attachment stays for small inline types (PDF/images).
2026-07-09 11:35:45 +05:00
admins 98cc023709 Force lesson materials to download as attachment with readable filename
Direct CDN links opened PDFs inline (no Content-Disposition), which reads
as 'can't download' behind popup blockers / on mobile. New proxy route
streams the file with attachment + human name; lesson link points to it.
2026-07-08 19:28:26 +05:00
admins 53caf54b03 Merge feature/clean-pdf: Чистый PDF tool (URL→PDF, Zotero, API) 2026-07-06 14:23:06 +05:00
admins 4734b99bea Document DNS-rebind residual and prod-enablement egress hardening
The security docs claimed the in-browser SSRF filter (context.route/
routeWebSocket) re-applies "the same filtering" as the pre-fetch DNS
check. That's inaccurate for hostnames: the browser-level filter only
blocks literal private IPs and localhost/.local/.internal suffixes —
it never re-resolves hostnames, so a same-hostname DNS-rebind (public
IP on first resolve, private IP on a later request from inside
browserless) is not closed at that layer. Correct the wording in the
design spec and TECHNICAL.md, and add a prominent note to both the
spec's deploy section and the plan's deploy notes: before flipping
TOOLBOX_VISIBLE on prod, harden the browserless container's network
egress (block 169.254.0.0/16 and RFC1918 ranges via host firewall or
a dedicated internal docker network) to close the residual at the
network layer. Also note that per-user limits currently count only
successful generations — failed renders are uncapped, a bounded
self-DoS risk worth a follow-up.
2026-07-06 13:47:00 +05:00
admins 118fa3961f Surface regenerate errors in clean-pdf Zotero section
regenerate() silently did nothing on {ok:false} or a rejected server
action, risking an unhandled promise rejection and leaving the student
staring at a stuck "меняем…" button with no feedback. Wrap the action
call in try/catch and show an inline error message on failure.
2026-07-06 13:45:12 +05:00
admins 1ccf994112 Strip active content from extracted HTML before PDF print
Defuddle-extracted article content is injected raw into the PDF HTML
and rendered by a real browser (browserless). As defense-in-depth
against a compromised or malicious source page, remove script/style/
iframe/object/embed elements and on* event-handler / javascript: href
attributes from the parsed DOM before serializing it into the template.
2026-07-06 13:44:41 +05:00
admins 02b16e311b Make Zotero script comment dynamic and assert valid JS
The generated script's setup comment hardcoded school.second-brain.ru
even though baseUrl is already interpolated elsewhere in the template,
so the comment would lie on any other host. Also add a regression test
that the generated script parses as valid JavaScript, to catch escaping
mistakes in the template literal.
2026-07-06 13:44:13 +05:00
admins f143f58cea Add browserless service to prod compose and document clean-pdf 2026-07-06 13:30:24 +05:00
admins fd40b1e394 Recompute Zotero script reactively on key regenerate 2026-07-06 13:13:19 +05:00
admins 0cbea10e1a Add clean-pdf tool page with web form and Zotero section 2026-07-06 12:49:06 +05:00
admins 668dd29397 Add regenerate action for clean-pdf API key 2026-07-06 12:44:29 +05:00
47 changed files with 2453 additions and 245 deletions
+2
View File
@@ -48,4 +48,6 @@ TOOLBOX_VISIBLE=""
# Чистый PDF (browserless на staging/prod; локально — SSH-туннель на staging) # Чистый PDF (browserless на staging/prod; локально — SSH-туннель на staging)
BROWSER_WS_URL="ws://localhost:3333" BROWSER_WS_URL="ws://localhost:3333"
# Секрет browserless (TOKEN в его env) — на прод/staging генерировать: openssl rand -hex 24
BROWSERLESS_TOKEN=""
PDF_MONTHLY_LIMIT="100" PDF_MONTHLY_LIMIT="100"
+3 -5
View File
@@ -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.
--- ---
+67 -5
View File
@@ -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
+40
View File
@@ -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
View File
@@ -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:
@@ -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`).
+6 -1
View File
@@ -82,11 +82,12 @@ model PdfApiKey {
- Только `http://` и `https://`. - Только `http://` и `https://`.
- Перед загрузкой — резолв DNS и блокировка приватных/зарезервированных диапазонов: localhost/127.x, 10.x, 172.1631.x, 192.168.x, 169.254.x (метаданные облаков), ::1, fc00::/7, плюс docker-хостнеймы стенда (`db`, `app`, `browserless`). - Перед загрузкой — резолв DNS и блокировка приватных/зарезервированных диапазонов: localhost/127.x, 10.x, 172.1631.x, 192.168.x, 169.254.x (метаданные облаков), ::1, fc00::/7, плюс docker-хостнеймы стенда (`db`, `app`, `browserless`).
- Внутри browserless — перехват сетевых запросов страницы с той же фильтрацией (защита от редиректов и подгрузок на внутренние адреса). - Внутри browserless — перехват сетевых запросов страницы (`context.route`/`context.routeWebSocket`): блокирует буквальные приватные/зарезервированные IP и хосты `localhost`/`*.local`/`*.internal`. **Честно про предел этой защиты:** `assertPublicUrl` резолвит DNS **ровно один раз — для исходного URL**, а browser-level фильтр хостнеймы не резолвит вовсе. Значит без проверки уходит **любой новый хостнейм после первого перехода**: цель редиректа, субресурс, `fetch` из JS, `ws://`. DNS-rebind для обхода даже не нужен — достаточно редиректа на внутреннее имя. Остаточный риск закрывается только на сетевом уровне — см. «Деплой». *(уточнено 20260912 аудитом кода)*
- Порт browserless наружу не публикуется, доступен только приложению по внутренней сети compose. - Порт browserless наружу не публикуется, доступен только приложению по внутренней сети compose.
- Куки/учётные данные пользователя на целевую страницу не передаются. - Куки/учётные данные пользователя на целевую страницу не передаются.
- Санитайз имени файла в `Content-Disposition`. - Санитайз имени файла в `Content-Disposition`.
- Лимиты: 100/мес (env) + burst 5/мин на пользователя; сверху — очередь browserless (2 конкурентных рендера). - Лимиты: 100/мес (env) + burst 5/мин на пользователя; сверху — очередь browserless (2 конкурентных рендера).
- ⚠️ Follow-up (не блокирует релиз): оба счётчика лимита считают только **успешные** генерации (`ToolUsage` пишется после успешного рендера) — неудачные попытки (таймаут, 5xx с целевого сайта, зависший рендер) лимит не расходуют. Потенциальный ограниченный self-DoS повторными запросами к тяжёлым/неотвечающим URL. Рассмотреть подсчёт попыток, а не только успехов, отдельной задачей.
## Деплой ## Деплой
@@ -96,6 +97,10 @@ model PdfApiKey {
- Стандартная схема деплоя LMS: сборка на Hetzner → `docker save | ssh | docker load` на Hoster.kz; browserless на Hoster.kz — обычный `docker pull`. - Стандартная схема деплоя LMS: сборка на Hetzner → `docker save | ssh | docker load` на Hoster.kz; browserless на Hoster.kz — обычный `docker pull`.
- Hot-standby на Hetzner получает тот же compose (репликация БД уже покрывает `PdfApiKey` и `ToolUsage`). - 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-шаблон на фикстурах. - Юнит: SSRF-валидатор (таблица адресов → допуск/блок), пайплайн Defuddle → HTML-шаблон на фикстурах.
@@ -1654,9 +1654,13 @@ git commit -m "Add browserless service to prod compose and document clean-pdf"
1. В `.env` на Hoster.kz добавить `BROWSERLESS_TOKEN=<random>` (например `openssl rand -hex 24`) и при желании `PDF_MONTHLY_LIMIT`. 1. В `.env` на Hoster.kz добавить `BROWSERLESS_TOKEN=<random>` (например `openssl rand -hex 24`) и при желании `PDF_MONTHLY_LIMIT`.
2. Миграция на проде: выполняется как обычно при деплое (`prisma migrate deploy` в entrypoint — проверить, что `add_pdf_api_key` применилась: `docker exec -i lms-sb-db-1 psql -U lms_user -d lms_db -c '\d "PdfApiKey"'`). 2. Миграция на проде: выполняется как обычно при деплое (`prisma migrate deploy` в entrypoint — проверить, что `add_pdf_api_key` применилась: `docker exec -i lms-sb-db-1 psql -U lms_user -d lms_db -c '\d "PdfApiKey"'`).
3. `docker pull ghcr.io/browserless/chromium` на Hoster.kz (интернет с сервера есть) + обновлённый compose. 3. `docker pull ghcr.io/browserless/chromium` на Hoster.kz (интернет с сервера есть) + обновлённый compose.
> ⚠️ **Перед шагом 4 (включением `TOOLBOX_VISIBLE=true`) обязательно захардить сетевой egress контейнера `browserless` на проде** — заблокировать `169.254.0.0/16` (cloud-metadata) и RFC1918-диапазоны (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`) на уровне хост-файрвола или выделенной internal-only docker-сети. Browser-level SSRF-фильтр (`context.route`/`context.routeWebSocket`) не резолвит DNS заново и не закрывает same-hostname DNS-rebind (публичный IP на первом резолве, приватный — на повторном запросе изнутри browserless); сетевой egress-блок — единственный слой, который закрывает этот остаточный риск. Подробности — «Безопасность»/«Деплой» в `docs/specs/20260706-clean-pdf-design.md`.
4. Инструмент останется невидимым, пока на проде не выставлен `TOOLBOX_VISIBLE=true` — это отдельное продуктовое решение. `/api/pdf` при этом уже будет работать — это ок (доступ гейтится платным enrollment), но анонсировать до включения тулбокса не нужно. 4. Инструмент останется невидимым, пока на проде не выставлен `TOOLBOX_VISIBLE=true` — это отдельное продуктовое решение. `/api/pdf` при этом уже будет работать — это ок (доступ гейтится платным enrollment), но анонсировать до включения тулбокса не нужно.
5. Hot-standby Hetzner: подтянуть тот же compose (`/root/digital-household/lms-sb/docker-compose.prod.yml` обновится через git pull). 5. Hot-standby Hetzner: подтянуть тот же compose (`/root/digital-household/lms-sb/docker-compose.prod.yml` обновится через git pull).
6. После релиза: обновить SBT-карточку `SBT/00-Стек/Сервисы/lms.md` (раздел про тулбокс + новые env) через capture-knowledge. 6. После релиза: обновить SBT-карточку `SBT/00-Стек/Сервисы/lms.md` (раздел про тулбокс + новые env) через capture-knowledge.
7. Follow-up (не блокирует релиз): месячный/burst-лимит сейчас считает только **успешные** генерации — неудачные попытки (таймаут, 5xx, зависший рендер) лимит не расходуют, потенциальный ограниченный self-DoS повторными запросами. Рассмотреть подсчёт попыток, а не только успехов, отдельной задачей.
## Самопроверка при завершении ## Самопроверка при завершении
+831 -1
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -47,6 +47,9 @@
"playwright-core": "^1.61.1", "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",
@@ -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;
+8 -3
View File
@@ -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
@@ -353,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)
+1
View File
@@ -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>
+3 -1
View File
@@ -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 &amp; 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>
);
}
+1 -1
View File
@@ -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,
}, },
}); });
+15 -2
View File
@@ -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 });
}
+60
View File
@@ -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({
+8 -2
View File
@@ -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 });
}
+29 -7
View File
@@ -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 });
} }
+1 -1
View File
@@ -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>
) : ( ) : (
+57
View File
@@ -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; }
+5
View File
@@ -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>
+352 -130
View File
@@ -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![${caption}](${data.url})\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()}
+2 -1
View File
@@ -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 }) {
+109
View File
@@ -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("&lt;script&gt;");
expect(html).toContain("Урок &lt;b&gt;про холст&lt;/b&gt;");
});
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("Привет!");
});
});
+13
View File
@@ -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 };
}
+64 -4
View File
@@ -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}`);
}
+95
View File
@@ -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;
}
}
@@ -42,6 +42,14 @@ describe("buildCleanHtml", () => {
expect(new Set(ids).size).toBe(3); 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("переключает тёмную тему", () => { it("переключает тёмную тему", () => {
const light = buildCleanHtml({ ...base, contentHtml: "<p>x</p>" }); const light = buildCleanHtml({ ...base, contentHtml: "<p>x</p>" });
const dark = buildCleanHtml({ ...base, theme: "dark", contentHtml: "<p>x</p>" }); const dark = buildCleanHtml({ ...base, theme: "dark", contentHtml: "<p>x</p>" });
@@ -9,4 +9,9 @@ describe("buildZoteroScript", () => {
expect(s).toContain("/api/pdf?url="); expect(s).toContain("/api/pdf?url=");
expect(s).not.toContain("YOUR_KEY"); 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();
});
}); });
+11
View File
@@ -42,6 +42,17 @@ function prepareContent(contentHtml: string): { html: string; toc: TocEntry[] }
toc.push({ id, text, level: h.tagName === "H2" ? 2 : 3 }); 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 }; return { html: doc.body.innerHTML, toc };
} }
+1 -1
View File
@@ -1,7 +1,7 @@
// Адаптация скрипта Clean PDF (pdf.brainysnipe.ru/zotero-script.js) под школу. // Адаптация скрипта Clean PDF (pdf.brainysnipe.ru/zotero-script.js) под школу.
export function buildZoteroScript({ apiKey, baseUrl }: { apiKey: string; baseUrl: string }): string { export function buildZoteroScript({ apiKey, baseUrl }: { apiKey: string; baseUrl: string }): string {
return `// ── Настройки ────────────────────────────────────────────────────────────── return `// ── Настройки ──────────────────────────────────────────────────────────────
const API_KEY = '${apiKey}'; // персональный ключ из school.second-brain.ru/tools/clean-pdf const API_KEY = '${apiKey}'; // персональный ключ из ${baseUrl}/tools/clean-pdf
const SERVICE_URL = '${baseUrl}'; const SERVICE_URL = '${baseUrl}';
const FORMAT = 'A4'; // 'A4' или 'Letter' const FORMAT = 'A4'; // 'A4' или 'Letter'
const THEME = 'light'; // 'light' или 'dark' const THEME = 'light'; // 'light' или 'dark'
+41
View File
@@ -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
View File
@@ -89,11 +89,19 @@ function btn(href: string, label: string) {
</table>`; </table>`;
} }
function escapeHtml(text: string) {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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,
+1
View File
@@ -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",
+3 -1
View File
@@ -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" },
]; ];