Commit Graph
235 Commits
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
adminsandClaude Sonnet 5 9ad4d762d7 Wrap /api/pdf handler in try/catch for JSON error contract
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-06 12:39:01 +05:00
admins c50a295290 Add /api/pdf route with key/session auth and limits
Adds GET /api/pdf: resolves the caller via a Bearer sbpdf_ API key
(PdfApiKey table) or a Better Auth session cookie, then gates on paid
course access, burst/monthly usage limits, generates the PDF via
generateCleanPdf, records a ToolUsage row, and streams the file with
X-Uses-Count/X-Max-Uses headers. Errors map to 401/403/422/429/504
JSON responses. Whitelists /api/pdf in middleware PUBLIC_ROUTES so the
route can perform its own auth instead of being redirected to /login.
2026-07-06 12:24:23 +05:00
admins 0c56b0b809 Bound PDF print timeout and block WebSocket SSRF in clean-pdf 2026-07-06 12:21:24 +05:00
admins 89383fab1d Add PDF generation pipeline via browserless and Defuddle
Connects to browserless over CDP (playwright-core), extracts article
content with Defuddle/JSDOM, renders it through buildCleanHtml, and
prints a PDF on a second page. Adds an in-browser request filter as a
second line of SSRF defense against redirects to private/localhost
hosts, on top of assertPublicUrl's DNS check.

Integration test is gated by RUN_PDF_INTEGRATION=1 (describe.runIf) so
it is skipped in a normal npm run test and only runs against a real
browserless instance.
2026-07-06 12:09:28 +05:00
admins ef46cdbe29 Add paid-access and usage limit helpers for clean-pdf 2026-07-06 12:00:07 +05:00
admins b49680c118 Add PdfApiKey model and key storage helpers
Adds a Prisma model for per-student Clean PDF API keys plus a
hand-written migration (no local Postgres to run `migrate dev`
against). getOrCreatePdfKey/regenerateKey wrap the model with
lazy-creation and rotation logic on top of generatePdfKey().
2026-07-06 11:53:31 +05:00
admins 1d5b4f9251 Add API key generator and Zotero script builder 2026-07-06 11:46:31 +05:00
admins 6316c09993 Add PDF HTML template with typography, themes and TOC 2026-07-06 11:40:24 +05:00
admins 9a74339087 Block v4-mapped IPv6 literals in SSRF validator 2026-07-06 11:37:05 +05:00
admins 2c619be043 Add SSRF URL validator for clean-pdf 2026-07-06 11:28:11 +05:00
admins 52073fd914 Document clean-pdf env vars in .env.example 2026-07-06 11:25:31 +05:00
admins f5768331cf Ignore .superpowers SDD scratch dir 2026-07-06 11:21:01 +05:00
admins 541853bb1d Add clean-pdf dependencies and browserless dev service 2026-07-06 11:19:51 +05:00
adminsandClaude Fable 5 e795c4d655 Adapt clean-pdf plan to staging-based verification (no local Docker)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 11:13:22 +05:00
adminsandClaude Fable 5 aaf84ea931 Add clean-pdf implementation plan
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 10:10:11 +05:00
adminsandClaude Fable 5 16b17685f6 Ignore lms-auth.json auth state
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 09:57:08 +05:00
adminsandClaude Fable 5 5b81a7b594 Add Clean PDF tool design spec
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 09:55:46 +05:00
adminsandClaude Fable 5 980c4d621e Use bullet marker in welcome scheme cards
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 09:43:38 +05:00
adminsandClaude Fable 5 8c2bd42be3 Let welcome header wrap on narrow screens
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 09:37:56 +05:00
adminsandClaude Fable 5 fa7c25465c Add public DS-2 welcome page on root for unauthenticated visitors
Second-brain scheme (logo + six cards with rays) reworked from the old
platform's infographic. Root stays role-redirecting for signed-in users;
middleware opens "/" as an exact match only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 09:32:49 +05:00
adminsandClaude Fable 5 1133557a62 Sync dev compose with Hetzner hot-standby: postgres 18, loopback port, versioned volume
Change was made live on the build server on 20260701 and is now captured
in git per the no-drift rule.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 16:37:39 +05:00
adminsandClaude Fable 5 a6835567f1 Open checkout modal from locked course cards on dashboard
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 16:35:30 +05:00
adminsandClaude Fable 5 c2f84079ac Add DS-2 checkout modal for locked courses
Form POSTs directly to payment-router /pay/order with the student's
email prefilled, so the purchase auto-attaches to the existing account.
Payment methods are fetched from /pay/methods with a robokassa fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 16:35:30 +05:00
adminsandClaude Fable 5 f8f246ca9b Add store catalog config for dashboard upsell checkout
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 16:35:30 +05:00
adminsandClaude Opus 4.8 d2094c9541 Make URLs in homework answers and feedback clickable
Apply linkify() to student submission text (curator review page + student
cabinet: reviewed/approved/pending) and to curator feedback text, so pasted
links (e.g. Yandex.Disk) render as clickable anchors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 19:29:21 +05:00
adminsandClaude Opus 4.8 7c8e72cd94 Add email notification on new lesson comment
Notify admins/curators (or configured notificationEmails) when a student
leaves a comment under a lesson. New setting notifyOnComment (default on) +
toggle in admin settings. Fires only for student comments, not admin/curator
replies; comment text is HTML-escaped in the email.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 10:02:07 +05:00