Compare commits

54 Commits

Author SHA1 Message Date
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
admins 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
admins 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
admins aaf84ea931 Add clean-pdf implementation plan
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 10:10:11 +05:00
admins 16b17685f6 Ignore lms-auth.json auth state
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 09:57:08 +05:00
admins 5b81a7b594 Add Clean PDF tool design spec
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 09:55:46 +05:00
admins 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
admins 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
admins 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
admins 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
admins 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
admins 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
admins 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
admins 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
admins 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
admins d20e277535 WS6 fix: SQL пометить справочным — курсы создавать через Admin UI (CUID невалидны для psql -f)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 10:08:30 +05:00
admins 47e301a5b8 feat(ws6): SQL-скрипт создания курсов-носителей biz-brain + verify-скрипт
- scripts/create_biz_brain_courses.sql: идемпотентное создание 5 курсов
  (biz-brain-base, biz-brain-premium, module-caldav, module-ai-pdf, module-legacy)
- scripts/verify_biz_brain_grant.sh: проверка /api/internal/grant (Нужен Дмитрий для запуска на проде)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 19:27:22 +05:00
admins 39e2102a18 Gate Obsidian Toolbox behind TOOLBOX_VISIBLE flag
Toolbox is still being refined; hide it from clients on prod while keeping it
usable on staging. Entry points (header link, dashboard card) render only when
TOOLBOX_VISIBLE=true; a tools/layout redirects /tools/* to /dashboard otherwise.
Routes and ToolUsage table are untouched (no destructive migration).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 12:15:42 +05:00
admins 3e2c70dd51 Fix global CSS wipe: inject head/body code via DOM, not <head dangerouslySetInnerHTML>
Rendering <head dangerouslySetInnerHTML={headCode}> in the App Router root
layout let the manual <head> overwrite the React-hoisted <link rel=stylesheet>
during hydration — all Tailwind styles dropped for real users once JS ran
(headless snapshots taken before hydration looked fine). headCode held the
Yandex.Metrika snippet. Replace with HtmlInjector client component that appends
the code through the DOM post-hydration, recreating <script> nodes so they run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 12:13:58 +05:00
admins a239607360 Show homework badge by review status, not just feedback presence
Curator list labeled APPROVED-without-feedback submissions as 'Без ответа'
(badge keyed on feedbacks only). Key it on status: Принято / С отзывом /
На рассмотрении / Отклонено / Без ответа. Dashboards already count by status.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 10:23:35 +05:00
admins 6317dade9e Add Dataview and Bases generators (Toolbox Phase 2)
Two more inside-LMS generators under /tools, completing the 6-tool set:
- Dataview: DQL builder (TABLE/LIST/TASK/CALENDAR, FROM/WHERE/SORT/LIMIT),
  output wrapped in a dataview code block
- Bases: .base YAML builder (views, filters and:, order, sort, groupBy, limit);
  filter expressions emitted as single-quoted YAML scalars for safety

Syntax verified against official Obsidian docs (research workflow). Audit fix:
Bases sort entries use 'column:' (not 'property:') per documented working .base
files; 'property:' is kept only for groupBy. 14 new tests (47 total).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:04:09 +05:00
admins 1aa99b9def Add Toolbox promo card to student dashboard (Мои курсы)
Entry point into /tools alongside courses, per gate #3 decision.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 16:00:58 +05:00
admins b6d555ab3b Add Obsidian Toolbox — 4 syntax generators under /tools
Inside-LMS toolbox for registered students: callout-CSS, YAML-frontmatter,
theme CSS-variables, Style Settings generators. Auth inherited from (student)
route group; no public surface, no email gate.

- Pure generators in src/lib/tools/* with Vitest (33 tests)
- Shared YAML-safe scalar quoter (_shared/yaml.ts) used by frontmatter +
  style-settings: unquoted user input was silently breaking YAML (tags '#x'
  -> null, color default '#7C3AED' -> null, 'a: b' -> parse error)
- hex validation in callout (no NaN), date input constrained, clipboard +
  analytics calls guarded
- Prisma ToolUsage model + migration; logToolUsage Server Action (auth-first,
  10-min dedup window per user/tool)
- Tool index /tools + ToolCard (explicit lucide map, no import *) + header link

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 15:45:52 +05:00
admins f8aa27533c Add vault graph poster to Obsidian Wrapped
- parse.worker collects edges, builds top-150-by-degree graph (links capped 600)
- graphPoster.ts renders force-graph on off-screen canvas -> PNG (DS-2 colors),
  node names never leave the browser
- WrappedClient: Карточка/Граф toggle, optional node labels, lazy-load force-graph
- THIRD-PARTY-NOTICES for force-graph (MIT) and next/og satori/resvg (MPL-2.0)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 14:57:36 +05:00
admins 0a1425301d Add Wrapped link to student nav
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 14:25:48 +05:00
admins 06b8d9f64b Add Obsidian Wrapped feature (inside-LMS, client-side vault parsing)
- WrappedRun Prisma model + migration (aggregates only, no note names/text)
- /wrapped authed route: FSA + webkitdirectory + drag-drop reader, Web Worker parser
- archetype + score heuristics, ДС-2 result card
- POST /api/wrapped/run (userId from session), public /share/wrapped/[token]
- next/og OG image (Satori+resvg-wasm, alpine-safe) + Fira Mono ttf
- middleware: /share/wrapped public

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 14:19:59 +05:00
admins 1cde567dbe Add NEXT_PUBLIC_METRIKA_COUNTER_ID build-arg to Dockerfile
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 12:31:11 +05:00
admins 0e67a53122 Add Яндекс.Метрика funnel goals: signup_free + gate_view (env-driven counter)
- window.ym reachGoal 'signup_free' on registration success
- GateViewTracker client component fires 'gate_view' on tripwire gate mount
- counter id via NEXT_PUBLIC_METRIKA_COUNTER_ID (build-time)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 12:13:32 +05:00
admins 7bf3935c81 Add freemium variant C: archetype persistence, dashboard banner, lesson gate
- User.archetype/utmSource/utmMedium/utmCampaign fields + migration
- register flow reads ?archetype/?utm_* and saves them on the User row
- dashboard ArchetypeBanner: per-archetype 'твой путь' block
- lesson gate after wow-moment lesson (WOW_MOMENT_LESSON_ID=obs-start-l2-006) → tripwire + full-course offer

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:26:34 +05:00
admins bb6c806cdd Add quiz-lead internal endpoint for Typebot → Listmonk funnel
POST /api/internal/quiz-lead accepts archetype quiz results and
idempotently upserts subscriber in Listmonk (create on 201, PUT on 409).
Documents QUIZ_LEAD_SECRET and LISTMONK_* vars in .env.example.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 14:34:39 +05:00
admins 9fbb7aea6c Accept CDN URLs for question attachments
Attachments uploaded via question-upload get a CDN URL
(files.second-brain.ru via S3_CDN_URL), but the validator only accepted
the direct S3 endpoint prefix — so every attachment was silently dropped
(message text saved, file lost). Add isAllowedPublicUrl (CDN + direct S3)
in lib/s3 and use it in both question routes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 15:11:36 +05:00
admins 2436f77de5 Add 'approve without feedback' button for homework review
Curators/admins can mark a submission APPROVED (and complete the lesson)
without writing feedback. The student sees 'ДЗ принято ✓' and can no
longer resubmit. No email is sent on silent approval.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 16:10:29 +05:00
admins a9869bfac8 Backlog: markdown rendering for homework descriptions
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 10:15:27 +05:00
admins 4b300f3466 Merge branch 'feat/freemium-auto-enroll' into main
Auto-enroll new signups into free course obsidian-start (freemium)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 16:18:03 +05:00
119 changed files with 9226 additions and 158 deletions
+27
View File
@@ -16,6 +16,11 @@ S3_REGION="eu-central"
NEXT_PUBLIC_TURNSTILE_SITE_KEY="" NEXT_PUBLIC_TURNSTILE_SITE_KEY=""
TURNSTILE_SECRET_KEY="" TURNSTILE_SECRET_KEY=""
# Яндекс.Метрика — ID счётчика school.second-brain.ru (цели signup_free/gate_view).
# Build-time (NEXT_PUBLIC_*) — передавать через --build-arg при сборке. Сам счётчик
# (ym init snippet) ставится в admin → Settings → headCode, отдельно от этого ID.
NEXT_PUBLIC_METRIKA_COUNTER_ID=""
# Kinescope API # Kinescope API
KINESCOPE_API_KEY="" KINESCOPE_API_KEY=""
@@ -24,3 +29,25 @@ INTERNAL_GRANT_SECRET=""
# Freemium: slug бесплатного курса, в который авто-записываем новых юзеров при регистрации # Freemium: slug бесплатного курса, в который авто-записываем новых юзеров при регистрации
FREE_COURSE_SLUG=obsidian-start FREE_COURSE_SLUG=obsidian-start
# Freemium quiz funnel (Typebot → /api/internal/quiz-lead)
QUIZ_LEAD_SECRET=""
# Listmonk API base — публичный домен (Listmonk живёт на Hetzner, наружу через newsletter.)
LISTMONK_URL="https://newsletter.second-brain.ru"
LISTMONK_USER="apiuser"
LISTMONK_TOKEN=""
LISTMONK_QUIZ_LIST_ID=""
# Freemium gate: ID wow-урока (2.6 «ежедневные заметки») — после его завершения
# показываем лестницу-оффер (трипвайр + полный курс)
WOW_MOMENT_LESSON_ID="obs-start-l2-006"
# Obsidian Toolbox (/tools): "true" — показывать точки входа и роуты (staging);
# не задано/иное — скрыто, /tools редиректит на /dashboard (prod, пока дорабатываем)
TOOLBOX_VISIBLE=""
# Чистый PDF (browserless на staging/prod; локально — SSH-туннель на staging)
BROWSER_WS_URL="ws://localhost:3333"
# Секрет browserless (TOKEN в его env) — на прод/staging генерировать: openssl rand -hex 24
BROWSERLESS_TOKEN=""
PDF_MONTHLY_LIMIT="100"
+6
View File
@@ -48,3 +48,9 @@ next-env.d.ts
# Claude Code local plugins (external git repos, не коммитим) # Claude Code local plugins (external git repos, не коммитим)
.claude/plugins/ .claude/plugins/
# agent-browser auth state
lms-auth.json
# SDD scratch
.superpowers/
+62
View File
@@ -0,0 +1,62 @@
# Quiz-Lead Task 3 — Implementation Report
Date: 20260622
## STATUS: DONE
## Files created/modified
| File | Action |
|------|--------|
| `src/app/api/internal/quiz-lead/route.ts` | CREATED |
| `.env.example` | MODIFIED — added QUIZ_LEAD_SECRET, LISTMONK_URL, LISTMONK_USER, LISTMONK_TOKEN, LISTMONK_QUIZ_LIST_ID |
| `src/middleware.ts` | NOT MODIFIED — `/api/internal` already in PUBLIC_ROUTES with startsWith match; covers /api/internal/quiz-lead without changes |
## Test infra
`grep -E 'vitest|jest' package.json` → no output (empty).
No test framework present. Verification done via `npx tsc --noEmit` + manual reasoning.
## Commands and output
### `npm run type-check`
```
> lms-sb@0.1.0 type-check
> tsc --noEmit
(no output = clean)
```
Exit code: 0
### `npm run lint` (new file only)
```
grep 'quiz-lead' in lint output → no results (no errors in new file)
```
Pre-existing lint errors in repo (36 total, 20 errors) are NOT related to this task:
- `.claude/plugins/superpowers/` scripts using CommonJS require
- `quick-enroll-modal.tsx` — react-hooks/immutability (pre-existing)
- `kinescope-player.tsx` — react-hooks/set-state-in-effect (pre-existing)
## Commit
Base: `9fbb7ae`
New: `5e65ad6`
```
5e65ad6 Add quiz-lead internal endpoint for Typebot → Listmonk funnel
```
## Implementation notes
- Auth: `x-quiz-secret` header vs `QUIZ_LEAD_SECRET` env var; 401 on mismatch/missing
- Validation: email regex, archetype against const tuple, Number.isFinite(score); 400 on failure
- Upsert logic: POST create → 409 → GET by email SQL query → PUT update; 502 on Listmonk errors
- Middleware: `/api/internal` already in PUBLIC_ROUTES with `startsWith` — no changes needed
- TypeScript strict mode: no `any` used; typed interfaces for Listmonk response shapes
- No production Listmonk list created; LISTMONK_QUIZ_LIST_ID left as empty placeholder in .env.example
- No git push; no deploy
## Concerns
None. The pre-existing lint errors in the repo are unrelated to this task and were already present before this commit.
+2
View File
@@ -12,6 +12,8 @@ COPY . .
# NEXT_PUBLIC_* vars are inlined at build time # NEXT_PUBLIC_* vars are inlined at build time
ARG NEXT_PUBLIC_TURNSTILE_SITE_KEY ARG NEXT_PUBLIC_TURNSTILE_SITE_KEY
ENV NEXT_PUBLIC_TURNSTILE_SITE_KEY=$NEXT_PUBLIC_TURNSTILE_SITE_KEY ENV NEXT_PUBLIC_TURNSTILE_SITE_KEY=$NEXT_PUBLIC_TURNSTILE_SITE_KEY
ARG NEXT_PUBLIC_METRIKA_COUNTER_ID
ENV NEXT_PUBLIC_METRIKA_COUNTER_ID=$NEXT_PUBLIC_METRIKA_COUNTER_ID
# Generate Prisma client before build # Generate Prisma client before build
RUN npx prisma generate RUN npx prisma generate
RUN npm run build RUN npm run build
+3
View File
@@ -340,3 +340,6 @@
- Записи открытых встреч и вебинаров - Записи открытых встреч и вебинаров
- Календарь: предстоящие открытые уроки, запуски курсов, события - Календарь: предстоящие открытые уроки, запуски курсов, события
- Возможно: публичный Obsidian-like граф знаний - Возможно: публичный Obsidian-like граф знаний
- **Markdown-рендер описаний ДЗ** — сейчас `Homework.description` выводится как plain text (`whitespace-pre-wrap` в `src/components/student/homework-section.tsx`), поэтому markdown-разметка (списки, `**жирный**`, ссылки) показывается сырой. Перевести на markdown-рендер (react-markdown или общий рендерер контента урока), чтобы задания оформлялись аккуратно.
- Контекст: при миграции ДЗ из Emdesell были артефакты форматирования (оторванные маркеры списков `• \n`, пачки пустых строк). 17.06.2026 13 ДЗ (очаг — obsidian-ai) причёсаны regex'ом под текущий plain-text рендер (бэкап `Homework_bak_20260617`). Полноценное решение — markdown-рендер.
+33
View File
@@ -171,6 +171,39 @@ 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`), admin/curator — без ограничений. Видимость страницы гейтится флагом `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`), без пересборки |
Контейнер `browserless` (`ghcr.io/browserless/chromium`) — внутренний, порт наружу не публикуется ни на staging, ни на проде.
**SSRF-защита — честно про пределы:** до рендера URL проверяется резолвом DNS (блок приватных/зарезервированных диапазонов), плюс внутри browserless страница перехватывается фильтром буквальных приватных IP и `localhost`/`*.local`/`*.internal`. Этот browser-level фильтр **не** переразрешает хостнеймы — same-hostname DNS-rebind (публичный IP на первом резолве, приватный на повторном запросе изнутри browserless) он не закрывает. Полный разбор и требование захардить сетевой egress `browserless` перед включением `TOOLBOX_VISIBLE` на проде — см. «Безопасность» и «Деплой» в [`docs/specs/20260706-clean-pdf-design.md`](docs/specs/20260706-clean-pdf-design.md).
--- ---
+51
View File
@@ -0,0 +1,51 @@
# Third-Party Notices
This product bundles third-party software. Their licenses and copyright
notices are reproduced below as required.
## force-graph
Used client-side in the "Obsidian Wrapped" feature to render the vault graph
poster on an off-screen canvas. The library and its node names never leave the
browser.
- Version: 1.51.4
- License: MIT
- Copyright (c) 2018 Vasco Asturiano
- https://github.com/vasturiano/force-graph
```
MIT License
Copyright (c) 2018 Vasco Asturiano
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
force-graph depends on the d3-force family (ISC) by Mike Bostock for its
force simulation, bundled transitively.
## next/og (Satori + resvg)
Server-side share-card PNG rendering (`ImageResponse`) uses Vercel's `next/og`,
which bundles `satori` and `@resvg/resvg-wasm` (both MPL-2.0). These are part of
the `next` dependency and distributed under Next.js's own license terms.
- satori — MPL-2.0 — https://github.com/vercel/satori
- resvg-wasm — MPL-2.0 — https://github.com/yisibl/resvg-js
+15
View File
@@ -13,9 +13,14 @@ 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 image: postgres:16-alpine
@@ -32,5 +37,15 @@ services:
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:
+13 -3
View File
@@ -1,15 +1,25 @@
services: services:
db: db:
image: postgres:16-alpine image: postgres:18-alpine
restart: unless-stopped restart: unless-stopped
environment: environment:
POSTGRES_USER: lms_user POSTGRES_USER: lms_user
POSTGRES_PASSWORD: lms_password POSTGRES_PASSWORD: lms_password
POSTGRES_DB: lms_db POSTGRES_DB: lms_db
ports: ports:
- "5432:5432" - "127.0.0.1:5432:5432"
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql
browserless:
image: ghcr.io/browserless/chromium
restart: unless-stopped
ports:
- "127.0.0.1:3333:3000"
environment:
CONCURRENT: "2"
QUEUED: "10"
TIMEOUT: "120000"
volumes: volumes:
postgres_data: postgres_data:
+114
View File
@@ -0,0 +1,114 @@
# Чистый PDF — инструмент «URL → PDF» в LMS
Дата: 20260706
Статус: дизайн утверждается
Референс: Clean PDF (`pdf.brainysnipe.ru`) из поста https://t.me/flowing_abyss/94, инструкция https://flowing-abyss.com/Clean-PDF-for-Zotero
## Что делаем
Инструмент для студентов школы: превращает любую веб-страницу в чистый PDF — без рекламы, меню и мусора, с нормальной типографикой, оглавлением, форматами A4/Letter и светлой/тёмной темой. Два сценария плюс открытый API:
1. **Веб-форма** в кабинете (`/tools/clean-pdf`): вставил URL — скачал PDF.
2. **Zotero**: персональный API-ключ + скрипт для плагина Actions & Tags — правый клик на записи → PDF генерируется и прикрепляется к ней.
3. **Открытый API** для продвинутых: `curl` с Bearer-ключом, документация на странице инструмента.
## Принятые решения
| Вопрос | Решение |
|---|---|
| Архитектура | Всё внутри LMS (подход B): страница и API в Next.js, рендер — отдельный контейнер browserless в том же compose |
| Кому доступ | Любой платный студент: есть `CourseEnrollment` на курс со slug ≠ `FREE_COURSE_SLUG`; admin/curator — без ограничений |
| Видимость | Часть Obsidian Toolbox, живёт под существующим флагом `TOOLBOX_VISIBLE`; включение на проде — отдельное решение позже |
| Лимит | 100 генераций/месяц на студента, env `PDF_MONTHLY_LIMIT` (без пересборки), плюс burst-лимит 5/мин |
## Пайплайн генерации
```
URL студента
→ Chromium (browserless) загружает страницу (JS отрабатывает)
→ Next.js забирает итоговый HTML
→ Defuddle (npm, через JSDOM) выделяет главный контент
→ чистый HTML-шаблон: типографика, A4/Letter, light/dark, оглавление из заголовков
→ Playwright page.pdf() в том же browserless
→ файл application/pdf в ответ
```
- **Defuddle** — библиотека kepano (автор Obsidian), та же, что у референса: https://github.com/kepano/defuddle
- **browserless/chromium** — отдельный контейнер в docker-compose: `MAX_CONCURRENT_SESSIONS=2`, встроенная очередь, `mem_limit` ~1 ГБ, порт наружу не публикуется.
- Next.js подключается по WebSocket через `playwright-core` (только клиент, Chromium в образ LMS не попадает — образ не растёт).
- Таймаут всего пайплайна ~90 сек; понятные JSON-ошибки: URL недоступен, лимит исчерпан, нет платного доступа.
## Данные (Prisma)
Новая модель:
```prisma
model PdfApiKey {
id String @id @default(cuid())
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
key String @unique // формат sbpdf_<random>, показывается студенту
createdAt DateTime @default(now())
}
```
- Ключ создаётся лениво при первом заходе на страницу инструмента.
- Перегенерация заменяет ключ (старый перестаёт работать сразу).
- Учёт использований — существующая `ToolUsage` с `tool = "clean-pdf-generate"`; месячный счётчик = COUNT за календарный месяц. Именно отдельный идентификатор: `CopyButton` на странице логирует копирования как `tool = "clean-pdf"`, и они не должны тратить лимит генераций.
- Проверка платного доступа выполняется на каждый запрос → рефанд или блокировка аккаунта автоматически отключает и веб, и ключ. Источник истины один — БД LMS.
## API
`GET /api/pdf?url=<...>&format=A4|Letter&theme=light|dark`
- **Авторизация, два пути**: заголовок `Authorization: Bearer sbpdf_...` (Zotero, curl) **или** активная сессия Better Auth (веб-форма — студенту ключ для веба не нужен).
- Роут добавляется в `PUBLIC_ROUTES` в `middleware.ts` (иначе 307 → /login — известные грабли).
- Ответ: `application/pdf` + заголовки `X-Uses-Count` / `X-Max-Uses` (их же показывает Zotero-скрипт).
- Ошибки: JSON `{ error }` c честными статусами (401 нет ключа/сессии, 403 нет платного доступа, 429 лимит, 422 плохой URL, 504 не отрендерилось).
## Страница /tools/clean-pdf
По образцу существующих инструментов тулбокса (реестр `TOOLS`, `ToolCard`, страница + клиентская форма):
1. **Форма**: поле URL, выбор формата и темы, кнопка «Скачать PDF», счётчик «использовано N из 100 в этом месяце».
2. **Блок «Zotero»**: персональный ключ (показать/скопировать/перегенерировать), пошаговая инструкция по Actions & Tags, готовый скрипт с уже подставленными адресом школы и ключом студента (адаптация скрипта референса: `SERVICE_URL = https://school.second-brain.ru`, endpoint `/api/pdf`).
3. **Блок «API»**: пример curl.
Дизайн — строго ДС-2 (перед вёрсткой перечитать `SBT/02-Стандарты/Дизайн-LMS/DESIGN.md`).
## Безопасность
Сервис скачивает произвольные URL изнутри прод-стенда — главный риск SSRF:
- Только `http://` и `https://`.
- Перед загрузкой — резолв DNS и блокировка приватных/зарезервированных диапазонов: localhost/127.x, 10.x, 172.1631.x, 192.168.x, 169.254.x (метаданные облаков), ::1, fc00::/7, плюс docker-хостнеймы стенда (`db`, `app`, `browserless`).
- Внутри browserless — перехват сетевых запросов страницы (`context.route`/`context.routeWebSocket`): блокирует буквальные приватные/зарезервированные IP и хосты `localhost`/`*.local`/`*.internal`. **Честно про предел этой защиты:** она не резолвит DNS заново — обычный хостнейм (не IP-литерал) проходит проверку без разрешения адреса. Значит, DNS-rebind на тот же хостнейм (первый резолв на этапе `assertPublicUrl` — публичный IP; повторный запрос со страницы внутри browserless — уже приватный IP того же имени) **не блокируется** этим browser-level фильтром. Остаточный риск закрывается на сетевом уровне — см. «Деплой».
- Порт browserless наружу не публикуется, доступен только приложению по внутренней сети compose.
- Куки/учётные данные пользователя на целевую страницу не передаются.
- Санитайз имени файла в `Content-Disposition`.
- Лимиты: 100/мес (env) + burst 5/мин на пользователя; сверху — очередь browserless (2 конкурентных рендера).
- ⚠️ Follow-up (не блокирует релиз): оба счётчика лимита считают только **успешные** генерации (`ToolUsage` пишется после успешного рендера) — неудачные попытки (таймаут, 5xx с целевого сайта, зависший рендер) лимит не расходуют. Потенциальный ограниченный self-DoS повторными запросами к тяжёлым/неотвечающим URL. Рассмотреть подсчёт попыток, а не только успехов, отдельной задачей.
## Деплой
- `docker-compose.yml` (dev) и `docker-compose.prod.yml`: сервис `browserless` (образ `ghcr.io/browserless/chromium`), внутренняя сеть, лимиты памяти.
- env: `BROWSER_WS_URL`, `PDF_MONTHLY_LIMIT=100`, существующий `TOOLBOX_VISIBLE`.
- Prisma-миграция (`PdfApiKey`).
- Стандартная схема деплоя LMS: сборка на Hetzner → `docker save | ssh | docker load` на Hoster.kz; browserless на Hoster.kz — обычный `docker pull`.
- Hot-standby на Hetzner получает тот же compose (репликация БД уже покрывает `PdfApiKey` и `ToolUsage`).
> ⚠️ **Перед включением `TOOLBOX_VISIBLE` на проде обязательно захардить сетевой 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-сети. Именно это закрывает DNS-rebind остаточный риск, описанный выше в «Безопасность» — browser-level фильтр (`context.route`) его не закрывает, потому что не переразрешает хостнеймы.
## Тестирование
- Юнит: SSRF-валидатор (таблица адресов → допуск/блок), пайплайн Defuddle → HTML-шаблон на фикстурах.
- Интеграция на staging: рендер 3–5 реальных статей (лонгрид, статья с картинками, JS-тяжёлая страница), проверка лимитов и обоих путей авторизации.
- Zotero-скрипт — ручная проверка на живой библиотеке.
- Веб-форма — e2e через agent-browser в `--headed` (headless режет антиспам — известные грабли).
## Вне рамок (YAGNI)
- Отдельный домен `pdf.second-brain.ru` — не нужен, всё на `school.second-brain.ru`.
- Хранение сгенерированных PDF на сервере — файл отдаётся сразу и не сохраняется.
- Пер-курсовый гейтинг, тарифные лимиты, платные квоты — не сейчас.
- Освещение в рассылке/анонсы — после включения `TOOLBOX_VISIBLE` на проде, отдельной задачей.
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -3,7 +3,7 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: "standalone", output: "standalone",
transpilePackages: ["unified", "remark-parse"], transpilePackages: ["unified", "remark-parse"],
serverExternalPackages: ["@prisma/client", "@prisma/adapter-pg", "pg"], serverExternalPackages: ["@prisma/client", "@prisma/adapter-pg", "pg", "jsdom", "playwright-core", "defuddle"],
}; };
export default nextConfig; export default nextConfig;
+1945 -45
View File
File diff suppressed because it is too large Load Diff
+9 -2
View File
@@ -7,7 +7,8 @@
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint", "lint": "eslint",
"type-check": "tsc --noEmit" "type-check": "tsc --noEmit",
"test": "vitest run"
}, },
"prisma": { "prisma": {
"seed": "ts-node --project tsconfig.json prisma/seed.ts" "seed": "ts-node --project tsconfig.json prisma/seed.ts"
@@ -33,13 +34,17 @@
"better-auth": "^1.6.0", "better-auth": "^1.6.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"defuddle": "^0.19.1",
"disposable-email-domains": "^1.0.62", "disposable-email-domains": "^1.0.62",
"force-graph": "^1.51.4",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"iconv-lite": "^0.7.2", "iconv-lite": "^0.7.2",
"jsdom": "^29.1.1",
"lucide-react": "^1.7.0", "lucide-react": "^1.7.0",
"next": "16.2.2", "next": "16.2.2",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"pg": "^8.20.0", "pg": "^8.20.0",
"playwright-core": "^1.61.1",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"remark-parse": "^11.0.0", "remark-parse": "^11.0.0",
@@ -52,6 +57,7 @@
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/jsdom": "^28.0.3",
"@types/node": "^20", "@types/node": "^20",
"@types/pg": "^8.20.0", "@types/pg": "^8.20.0",
"@types/react": "^19", "@types/react": "^19",
@@ -62,6 +68,7 @@
"prisma": "^7.6.0", "prisma": "^7.6.0",
"tailwindcss": "^4", "tailwindcss": "^4",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "^5" "typescript": "^5",
"vitest": "^4.1.9"
} }
} }
@@ -0,0 +1,5 @@
-- Тип второго мозга из freemium-квиза + UTM-метки для персонализации воронки
ALTER TABLE "User" ADD COLUMN "archetype" TEXT;
ALTER TABLE "User" ADD COLUMN "utmSource" TEXT;
ALTER TABLE "User" ADD COLUMN "utmMedium" TEXT;
ALTER TABLE "User" ADD COLUMN "utmCampaign" TEXT;
@@ -0,0 +1,21 @@
-- Obsidian Wrapped — агрегаты прогона (без имён/текстов заметок)
CREATE TABLE "WrappedRun" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"period" TEXT NOT NULL DEFAULT 'year',
"noteCount" INTEGER NOT NULL,
"linkCount" INTEGER NOT NULL,
"tagCount" INTEGER NOT NULL,
"topTag" TEXT,
"archetype" TEXT,
"score" INTEGER NOT NULL DEFAULT 0,
"shareToken" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "WrappedRun_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "WrappedRun_shareToken_key" ON "WrappedRun"("shareToken");
CREATE INDEX "WrappedRun_userId_idx" ON "WrappedRun"("userId");
ALTER TABLE "WrappedRun" ADD CONSTRAINT "WrappedRun_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,14 @@
-- Obsidian Toolbox — факт использования генератора участником (аналитика)
CREATE TABLE "ToolUsage" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"tool" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ToolUsage_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "ToolUsage_userId_idx" ON "ToolUsage"("userId");
CREATE INDEX "ToolUsage_tool_idx" ON "ToolUsage"("tool");
CREATE INDEX "ToolUsage_createdAt_idx" ON "ToolUsage"("createdAt");
ALTER TABLE "ToolUsage" ADD CONSTRAINT "ToolUsage_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,14 @@
-- Чистый PDF — персональный API-ключ студента (Zotero/curl)
CREATE TABLE "PdfApiKey" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"key" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PdfApiKey_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "PdfApiKey_userId_key" ON "PdfApiKey"("userId");
CREATE UNIQUE INDEX "PdfApiKey_key_key" ON "PdfApiKey"("key");
ALTER TABLE "PdfApiKey" ADD CONSTRAINT "PdfApiKey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+50
View File
@@ -25,6 +25,10 @@ model User {
banExpires DateTime? banExpires DateTime?
comment String? comment String?
mustChangePassword Boolean @default(false) mustChangePassword Boolean @default(false)
archetype String? // тип второго мозга из квиза: architect|gardener|librarian|pragmatist
utmSource String?
utmMedium String?
utmCampaign String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -41,6 +45,52 @@ model User {
questions StudentQuestion[] questions StudentQuestion[]
closedQuestions StudentQuestion[] @relation("QuestionClosedBy") closedQuestions StudentQuestion[] @relation("QuestionClosedBy")
questionMessages StudentQuestionMessage[] questionMessages StudentQuestionMessage[]
wrappedRuns WrappedRun[]
toolUsages ToolUsage[]
pdfApiKey PdfApiKey?
}
// Obsidian Wrapped — агрегаты прогона (БЕЗ имён/текстов заметок; обработка волта 100% client-side)
model WrappedRun {
id String @id @default(cuid())
userId String
period String @default("year") // year | quarter | all
noteCount Int
linkCount Int
tagCount Int
topTag String?
archetype String? // architect | gardener | librarian | pragmatist
score Int @default(0) // 0..100
shareToken String @unique // публичная ссылка /share/wrapped/[token]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
}
// Obsidian Toolbox — факт использования генератора участником (для аналитики)
model ToolUsage {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
tool String // callout | frontmatter | theme | style-settings
createdAt DateTime @default(now())
@@index([userId])
@@index([tool])
@@index([createdAt])
}
// Чистый PDF — персональный API-ключ студента (Zotero/curl)
model PdfApiKey {
id String @id @default(cuid())
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
key String @unique // sbpdf_<random>, показывается студенту на /tools/clean-pdf
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
} }
model Session { model Session {
Binary file not shown.
Binary file not shown.
+66
View File
@@ -0,0 +1,66 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 27.9.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Слой_3" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 283.5 283.5" style="enable-background:new 0 0 283.5 283.5;" xml:space="preserve" fill="#323232">
<g>
<g>
<path d="M141.7,283.5C63.6,283.5,0,219.9,0,141.7C0,63.6,63.6,0,141.7,0s141.7,63.6,141.7,141.7S219.9,283.5,141.7,283.5z
M141.7,2.7c-76.7,0-139,62.4-139,139c0,76.7,62.4,139,139,139s139-62.4,139-139C280.8,65.1,218.4,2.7,141.7,2.7z"/>
</g>
<g>
<path d="M239.1,154.4c2.9-6.2,3.9-12.6,3.1-19c-1.2-8.9-5.8-15.8-10-20.4c0.5-5.1-0.3-10.2-2.4-15.3c-2.8-6.7-7-11.8-12.5-15.2
c-1.6-13.1-9.3-23.2-21.1-27.5c-4.1-9-12.3-15.4-22.2-17.5c-16.1-3.4-27.3,5.5-33.6,14.4c-5.6-8.6-16.1-17.7-31.4-14.5
c-9.7,2-17.3,8.5-20.8,17.5c-14.6,4.4-20.4,16.7-21.1,27.8c-5.9,3-10.3,8-13.1,14.8c-2.2,5.3-2.9,10.6-2,15.8
c-3.9,3.9-9.4,11-10.7,20.4c-0.9,6.6,0.4,13.2,3.9,19.5c-7.4,10.1-7.3,23.6,0.5,33.9c2.8,3.7,6.2,6.6,10.2,8.7
c-0.9,13.3,3.7,29.1,19.9,32.7c4.3,1,8.5,1.2,12.7,0.6c5.3,9.2,15.2,15.1,26,15.1c11,0,20.6-6,25.8-14.8
c5.9,8.9,16,14.8,27.4,14.8c10.8,0,21-5.4,27.1-14.2c3.6,0.3,7.2,0,11-0.9c16.9-3.8,22.7-19.1,22.5-33.1c3.4-2.1,6.4-4.9,8.8-8.2
C245.1,179.2,245.7,165.3,239.1,154.4z M134.9,146.4c0,5.5-5.6-8.7-9.8-12.9c-1.4-1.4-2.8-1.8-4.5-1.9c-1.1-3-2.8-7.5-5.2-9.8
c-3.4-3.2-1.5-7.8-1.4-10.2c0.1-2.4-3.5,1.8-3,5c0.5,3.2,0.6,4.7-0.6,9.7c-0.7,2.6,1.2,4.8,3.2,6.4c-1.9,0.4-4,0.7-6.2,0.7
c-8.1,0-9.1,14.9-7.8,17.5c1.3,2.6,5.8,8.1,5.8-0.6c0-8.7,12-12,17.5-7.9c5.5,4.1,4.9,15,5.2,22.4c0.3,7.4,6.9,1.6,6.9,11.3
c0,9.7-2,12.3-4,16.5c-1.9,4.2-17.8,7.1-14.6,9.4c3.2,2.3,18.5-1,18.5-1v12.2v0.1c0,18.1-21.1,21.7-28.9,20.7
c-7.8-1-11.6-10-9.4-14.9c2.3-4.9,10-2.9,11.6-5.5c1.6-2.6-5.5-0.3-11-5.8c-5.5-5.5-14.6-5.8-10.3-2.3c4.2,3.6,2.6,10.7-1.9,13.6
c-4.5,2.9-18.8,1.6-20.1-9.4c-1.3-11,4.2-14.6,6.1-18.1c1.9-3.6-2.3-14.2-2.3-14.2s-2.3,11-5.8,11.3c-3.6,0.3-11.6-5.5-11.9-12.9
c-0.2-7.4,3.8-10.3,4.7-13.3c1-2.9-1.9-4.9-4.5-20.1c-2.6-15.3,18.4-19.3,20.4-17.4c1.9,1.9,12.6,6.5,10.3,3.2
c-2.3-3.2-2.9-11-4.2-18.1c-1.3-7.1-7.4-4.2-8.7-0.3c-1.3,3.9-6.8,18.1-8.1,4.5c-1.3-13.6,8.4-23,12-25.6c3.6-2.6,3.2-8.1,6.5-15
c3.2-7,11.6-9.5,16.8-8.6c5.2,1,13.3-1.6,13.3-1.6c-1.9-3.2-10-4.5-11.3-4.9c-1.3-0.3,3.6-11,17.5-11.3
c13.9-0.3,18.1,11.6,19.1,18.8c1,7.1-6.5,10.3-6.5,10.3c5.5,3.9,4.5,23.6,4.5,23.6c0.4,12.3-11,14.9-11,14.9s13,5.2,13,9.7V146.4z
M196.7,100.9c3,0,5.5,2.5,5.5,5.5c0,3-2.5,5.5-5.5,5.5c-3,0-5.5-2.5-5.5-5.5C191.2,103.4,193.7,100.9,196.7,100.9z M169.2,75.6
c0,3-2.5,5.5-5.5,5.5c-3,0-5.5-2.5-5.5-5.5c0-3,2.5-5.5,5.5-5.5C166.8,70.1,169.2,72.6,169.2,75.6z M188.2,199.8
c-3,0-5.5-2.5-5.5-5.5c0-3,2.5-5.5,5.5-5.5c3,0,5.5,2.5,5.5,5.5C193.6,197.3,191.2,199.8,188.2,199.8z M230.4,184.7
c-2.3,3-5.1,5.5-8.4,7.1l-2.4,1.3l0.2,2.7c0.4,5.4,0.3,23.4-15.8,27c-3.7,0.8-7.2,1-10.7,0.5l-1.8-0.3v-17
c5.1-1.5,8.8-6.2,8.8-11.7c0-6.7-5.5-12.2-12.2-12.2s-12.2,5.5-12.2,12.2c0,5.6,3.7,10.3,8.8,11.7v24.8c-4.5,4.4-10.6,6.9-17,6.9
c-7.9,0-15-3.8-19.4-9.6l22.2-22.2v-26.8c5.1-1.5,8.8-6.2,8.8-11.7c0-6.7-5.5-12.2-12.2-12.2c-6.7,0-12.2,5.5-12.2,12.2
c0,5.6,3.7,10.3,8.8,11.7v24L144.9,222c-0.3-0.7-0.5-1.3-0.7-2c0.2-1.2,0.3-2.5,0.3-3.8v-69.4l15.3-15.3c4.6,2.6,10.6,1.9,14.5-2
c4.8-4.8,4.8-12.5,0-17.3c-4.8-4.8-12.5-4.8-17.3,0c-3.9,3.9-4.6,9.9-2,14.5l-10.5,10.5v-16.5v-0.3V63.3c2.7-5.5,8-12.4,15.9-15
l0,15.6c-5.1,1.5-8.8,6.2-8.8,11.7c0,6.7,5.5,12.2,12.2,12.2c6.7,0,12.2-5.5,12.2-12.2c0-5.6-3.7-10.3-8.8-11.7l0-16.7
c1.6,0,3.3,0.1,5.1,0.5c7.9,1.7,14.1,6.9,16.9,14.2l0.7,1.9l2,0.6c0.5,0.2,1,0.4,1.5,0.5l0,29.8c-5.1,1.5-8.8,6.2-8.8,11.7
c0,6.7,5.5,12.2,12.2,12.2c6.7,0,12.2-5.5,12.2-12.2c0-5.6-3.7-10.3-8.8-11.7l0-26.1c7.3,5.8,8.8,14.5,9,18.7l0.1,2.4l2.1,1.1
c4.7,2.4,8.3,6.5,10.6,12.1c1.8,4.3,2.3,8.6,1.6,12.8l-0.4,2.1l1.5,1.5c3.5,3.6,8,9.5,9.1,17.2c0,0.3,0.1,0.6,0.1,0.9h-28.6
c-1.5-5.1-6.2-8.8-11.7-8.8c-6.7,0-12.2,5.5-12.2,12.2c0,6.7,5.5,12.2,12.2,12.2c5.6,0,10.3-3.7,11.7-8.8h28.3
c-0.5,2.9-1.5,5.7-3.1,8.5l-1.3,2.3l1.6,2.2c2.2,3,3.6,6.4,4.1,9.8h-14.4c-1.5-5.1-6.2-8.8-11.7-8.8c-6.7,0-12.2,5.5-12.2,12.2
c0,6.7,5.5,12.2,12.2,12.2c5.6,0,10.3-3.7,11.7-8.8h14.4C234.5,177.5,233,181.3,230.4,184.7z M167.2,172.9c-3,0-5.5-2.5-5.5-5.5
c0-3,2.5-5.5,5.5-5.5c3,0,5.5,2.5,5.5,5.5C172.6,170.4,170.2,172.9,167.2,172.9z M161.8,117c2.1-2.1,5.6-2.1,7.7,0
c2.1,2.1,2.1,5.6,0,7.7c-2.1,2.1-5.6,2.1-7.7,0C159.7,122.6,159.7,119.1,161.8,117z M199.1,140.8c0,3-2.5,5.5-5.5,5.5
c-3,0-5.5-2.5-5.5-5.5c0-3,2.5-5.5,5.5-5.5C196.7,135.3,199.1,137.8,199.1,140.8z M214.4,170.4c0,3-2.5,5.5-5.5,5.5
c-3,0-5.5-2.5-5.5-5.5c0-3,2.5-5.5,5.5-5.5C211.9,164.9,214.4,167.4,214.4,170.4z"/>
<path d="M125.6,90.7c-0.8-4.9,0.5-16.5-3.9-15.5c-4.4,1-1.6,6.4-2.6,7.8c-2.4,3.2-0.5,3.7,2.7,7.1c3.2,3.4,3.1,10.5-1,12.8
c-4,2.3-3.8,7.6-1.8,6.8c2.1-0.8,5-3.7,6.4-7.4C127,98.5,126.4,95.6,125.6,90.7z"/>
<path d="M71.9,169.5c-1.8-2.9-3.3,3.7-6.7-0.2c-3.4-3.9-4.4-4.7-6-3.2c-3.9,3.5-1.9,8,1.5,10.9c3.4,2.9,2.9,0,2.7-3.1
c-0.2-3.1,1.8-1.8,5.2-0.6C72,174.3,73.8,172.4,71.9,169.5z"/>
<path d="M114.2,186.3c-3.4,2.6-5,6.1-1.5,6c3.6-0.2,3.2-5.3,6.1-4.7c2.9,0.6,1.7,5.7,4.7,3.6c3-2.1,5.4-11.1,2.8-14.2
c-2.6-3.1-4.8,3.2-5.7,3.4C116.7,181.2,117.6,183.7,114.2,186.3z"/>
<path d="M111,222.4c1.2,1.7,3.4,3.6,9.5,1.2c6.1-2.4,2.3-8.5-1.5-6.5C114.2,219.6,109.8,220.7,111,222.4z"/>
<path d="M116.6,98.2c-1.9-0.7-8.7-7.8-8.7-12.4c0-4.6-5.8,5.8-6.1,8.7c-0.2,2.9-5.3,5.8-9.6,7.3c-4.3,1.5-6.6,5.3-6.4,5.8
c0.2,0.5,3.4-0.2,6.4,0c3,0.2,2.6,6.5,2.4,13.8c-0.2,7.3-3.2,18.7-14.6,17.2c-11.4-1.5-11.4,7.8-13.3,15.8c-1.9,8,9,10.4,11.2,6.8
c2.2-3.6,6.5-7.3,11.2-4c0,0,3.4-5.7,0-7.6c-3.4-1.9-13.1,5.8-13.6,1c-0.5-4.9,4.6-3.4,14.1-8.2c9.5-4.8,9.2-19.2,10.5-22.6
c1.3-3.4-1.1-10.9,0-14.3c1.1-3.4,4.7-6.1,8.6-5.1C112.5,101.3,118.5,98.9,116.6,98.2z"/>
<path d="M122.4,59.4c-4.2-5.4-4.1,5.8-1.2,9C124.1,71.5,125.8,63.7,122.4,59.4z"/>
<path d="M86.3,95c3.2-3.2,1.3-3.4,6.7-4.6c5.4-1.2,4.4-4.9,7-7.8c2.6-2.9,8.6-9.2,3.5-10.2c-5.1-1-12.6-4.6-13.1,0
c-0.5,4.6,5.6,1.9,7.5,3.4c1.9,1.5-3.3,7.1-4.9,8.8c-1.6,1.6-6.7,3.3-6.7,3.3C82.4,91.9,83.1,98.2,86.3,95z"/>
<path d="M83.4,171.2c-0.5,4.9,8.8,9,8.8,9s0.3,0.2,0.7,0.6c-2.7,0.8-5.8,2.5-6.2,3.7c-0.6,1.9-9.1,10.2-12.5,12.2
c-3.4,2-2.7,7.7,1.1,12.2c3.9,4.5,7.6,5,8.4,3.6c0.8-1.5-5.6-4.2-6.7-8.9c-1.1-4.7,5.2-8.2,8.1-9.9c2.9-1.6,6.5-10.7,13.4-8
c2.4,1.9,4.1,9.5,4.1,9.5c2.2,3.2,2.7,1.2,1.9-4.1c-0.7-5.3,0-8.7,3.9-11.2c3.9-2.4,6.1-7,7-13.1c1-6.1,2.2-8.5,0-9.5
c-2.2-1.1-2.4,8.8-4.6,12.6c-2.1,3.8-6.8,5.4-14.1,6.1C89.4,176.8,83.8,166.3,83.4,171.2z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.5 KiB

+133
View File
@@ -0,0 +1,133 @@
-- ⚠️ СПРАВОЧНЫЙ ФАЙЛ — НЕ запускать `psql -f` на проде!
-- CUID здесь синтетические (могут не пройти валидацию схемы Prisma).
-- Создавать курсы biz-brain-base/premium + module-* ТОЛЬКО через LMS Admin UI.
-- Этот SQL — справка по составу/связям курсов.
-- ============================================================
-- WS6: Создание курсов-носителей «Второй мозг для бизнеса»
-- ============================================================
-- ВАЖНО: Выполнять НА ПРОДЕ только после проверки на staging.
-- Запускать через SSH: psql <db_url> -f create_biz_brain_courses.sql
-- Идемпотентно: INSERT IGNORE по slug (уникальный ключ).
--
-- Курсы-носители (5 штук):
-- biz-brain-base — основной курс (ZIP хранилища + видео-онбординг)
-- biz-brain-premium — премиум-раздел (консультации, инструкции по расширениям)
-- module-caldav — расширение CalDAV-календарь
-- module-ai-pdf — расширение AI-разбор PDF
-- module-legacy — расширение Legacy-миграция
-- ============================================================
BEGIN;
-- Функция для генерации cuid-подобного ID (через gen_random_uuid как fallback)
-- В реальности Prisma генерирует cuid автоматически — используем это свойство.
-- Если у вас Postgres 14+: gen_random_uuid() дает UUID, но Prisma ожидает cuid.
-- Безопаснее создать через Prisma API или LMS-Admin.
-- Проверяем что slug ещё не существует перед вставкой
DO $$
BEGIN
-- biz-brain-base
IF NOT EXISTS (SELECT 1 FROM "Course" WHERE slug = 'biz-brain-base') THEN
INSERT INTO "Course" (id, slug, title, description, published, "allowAudio", "order", "createdAt", "updatedAt")
VALUES (
'clbizbrainbase0000001',
'biz-brain-base',
'Второй мозг для бизнеса — Базовый',
'Готовое хранилище Obsidian для собственника: CRM-воронка, финансовый кокпит, журнал решений, витрина команды. Разовый платёж, бессрочный доступ.',
false, -- опубликовать вручную после наполнения контентом
false,
100,
NOW(),
NOW()
);
RAISE NOTICE 'Создан курс biz-brain-base';
ELSE
RAISE NOTICE 'Курс biz-brain-base уже существует — пропуск';
END IF;
-- biz-brain-premium
IF NOT EXISTS (SELECT 1 FROM "Course" WHERE slug = 'biz-brain-premium') THEN
INSERT INTO "Course" (id, slug, title, description, published, "allowAudio", "order", "createdAt", "updatedAt")
VALUES (
'clbizbrainprem0000002',
'biz-brain-premium',
'Второй мозг для бизнеса — Премиум (инструкции и консультации)',
'Раздел для премиум-покупателей: инструкции по установке расширений, запись на 2 персональные консультации, ответы на частые вопросы.',
false,
false,
101,
NOW(),
NOW()
);
RAISE NOTICE 'Создан курс biz-brain-premium';
ELSE
RAISE NOTICE 'Курс biz-brain-premium уже существует — пропуск';
END IF;
-- module-caldav
IF NOT EXISTS (SELECT 1 FROM "Course" WHERE slug = 'module-caldav') THEN
INSERT INTO "Course" (id, slug, title, description, published, "allowAudio", "order", "createdAt", "updatedAt")
VALUES (
'clmodulecaldav00000003',
'module-caldav',
'Расширение: CalDAV-календарь',
'Двусторонняя синхронизация задач и событий Obsidian с Google Calendar / Apple Calendar через CalDAV. Плагин Full Calendar Remastered v0.13.2.',
false,
false,
200,
NOW(),
NOW()
);
RAISE NOTICE 'Создан курс module-caldav';
ELSE
RAISE NOTICE 'Курс module-caldav уже существует — пропуск';
END IF;
-- module-ai-pdf
IF NOT EXISTS (SELECT 1 FROM "Course" WHERE slug = 'module-ai-pdf') THEN
INSERT INTO "Course" (id, slug, title, description, published, "allowAudio", "order", "createdAt", "updatedAt")
VALUES (
'clmoduleaipdf000000004',
'module-ai-pdf',
'Расширение: AI-разбор PDF и исследований',
'Промпты А1/А2/А3 для структурированного разбора PDF-документов, статей и исследований с помощью AI. Включает эталонный прогон и демо-PDF.',
false,
false,
201,
NOW(),
NOW()
);
RAISE NOTICE 'Создан курс module-ai-pdf';
ELSE
RAISE NOTICE 'Курс module-ai-pdf уже существует — пропуск';
END IF;
-- module-legacy
IF NOT EXISTS (SELECT 1 FROM "Course" WHERE slug = 'module-legacy') THEN
INSERT INTO "Course" (id, slug, title, description, published, "allowAudio", "order", "createdAt", "updatedAt")
VALUES (
'clmodulelegacy00000005',
'module-legacy',
'Расширение: Legacy-миграция из Evernote / Notion',
'Пошаговые инструкции и чеклист пост-обработки для переноса данных из Evernote и Notion в хранилище «Второй мозг для бизнеса».',
false,
false,
202,
NOW(),
NOW()
);
RAISE NOTICE 'Создан курс module-legacy';
ELSE
RAISE NOTICE 'Курс module-legacy уже существует — пропуск';
END IF;
END
$$;
COMMIT;
-- Проверка результата
SELECT slug, title, published, "order" FROM "Course"
WHERE slug IN ('biz-brain-base', 'biz-brain-premium', 'module-caldav', 'module-ai-pdf', 'module-legacy')
ORDER BY "order";
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env bash
# ============================================================
# WS6: Проверка /api/internal/grant для biz-brain-* курсов
# ============================================================
# Использование:
# LMS_URL=https://school.second-brain.ru \
# LMS_SECRET=<secret_из_env> \
# TEST_EMAIL=laukhin@dementiy.com \
# bash verify_biz_brain_grant.sh
#
# Проверяет:
# 1. Grant base-курса (biz-brain-base)
# 2. Grant premium-бандла (все 5 курсов)
# 3. Идемпотентность (повтор того же order_id)
# ============================================================
set -euo pipefail
LMS_URL="${LMS_URL:-https://school.second-brain.ru}"
LMS_SECRET="${LMS_SECRET:?Задай переменную LMS_SECRET}"
TEST_EMAIL="${TEST_EMAIL:-laukhin@dementiy.com}"
echo "=== WS6: Проверка grant-эндпоинта LMS ==="
echo " LMS: $LMS_URL"
echo " Email: $TEST_EMAIL"
echo ""
# ── Тест 1: Базовый бандл ────────────────────────────────────────────────────
echo "[1] Grant biz-brain-base..."
RESPONSE=$(curl -sf -X POST "$LMS_URL/api/internal/grant" \
-H "Content-Type: application/json" \
-H "x-internal-secret: $LMS_SECRET" \
-d '{
"email": "'"$TEST_EMAIL"'",
"name": "WS6 Test",
"course_slugs": ["biz-brain-base"],
"order_id": "ws6-test-base-001",
"payment_system": "robokassa"
}')
echo " Ответ: $RESPONSE"
GRANTED=$(echo "$RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('granted',''))")
if [[ "$GRANTED" == *"biz-brain-base"* ]]; then
echo " ✅ biz-brain-base выдан"
else
IDEMPOTENT=$(echo "$RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('idempotent',''))")
if [[ "$IDEMPOTENT" == "True" ]]; then
echo " ℹ️ Идемпотентный ответ (уже выдан ранее)"
else
echo " ❌ ОШИБКА: biz-brain-base не выдан. Ответ: $RESPONSE"
echo " Убедись что курс biz-brain-base создан в LMS!"
exit 1
fi
fi
echo ""
# ── Тест 2: Премиум-бандл ────────────────────────────────────────────────────
echo "[2] Grant biz-brain-premium (5 курсов)..."
RESPONSE=$(curl -sf -X POST "$LMS_URL/api/internal/grant" \
-H "Content-Type: application/json" \
-H "x-internal-secret: $LMS_SECRET" \
-d '{
"email": "'"$TEST_EMAIL"'",
"name": "WS6 Test",
"course_slugs": ["biz-brain-base", "biz-brain-premium", "module-caldav", "module-ai-pdf", "module-legacy"],
"order_id": "ws6-test-premium-001",
"payment_system": "robokassa"
}')
echo " Ответ: $RESPONSE"
UNRESOLVED=$(echo "$RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('unresolved',[]))")
if [[ "$UNRESOLVED" == "[]" ]]; then
echo " ✅ Все 5 курсов разрешены и выданы"
else
echo " ⚠️ Не разрешены: $UNRESOLVED"
echo " Создай недостающие курсы в LMS (slug'и должны точно совпадать)"
fi
echo ""
# ── Тест 3: Идемпотентность ──────────────────────────────────────────────────
echo "[3] Повторный grant того же order_id (должен вернуть idempotent=true)..."
RESPONSE=$(curl -sf -X POST "$LMS_URL/api/internal/grant" \
-H "Content-Type: application/json" \
-H "x-internal-secret: $LMS_SECRET" \
-d '{
"email": "'"$TEST_EMAIL"'",
"name": "WS6 Test",
"course_slugs": ["biz-brain-base"],
"order_id": "ws6-test-base-001",
"payment_system": "robokassa"
}')
IDEMPOTENT=$(echo "$RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('idempotent',''))" 2>/dev/null || echo "")
if [[ "$IDEMPOTENT" == "True" ]]; then
echo " ✅ Идемпотентность работает (повторный вызов вернул idempotent=true)"
else
echo " ⚠️ Ожидался idempotent=true, получено: $RESPONSE"
fi
echo ""
echo "=== Проверка завершена ==="
echo ""
echo "Следующий шаг: опубликовать курсы в admin (published=true) после наполнения контентом."
echo " school.second-brain.ru/admin/courses"
+12 -1
View File
@@ -2,13 +2,20 @@ import { redirect } from "next/navigation";
import { getSettings } from "@/lib/settings"; import { getSettings } from "@/lib/settings";
import { RegisterForm } from "./register-form"; import { RegisterForm } from "./register-form";
export default async function RegisterPage() { export default async function RegisterPage({
searchParams,
}: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
const settings = await getSettings(); const settings = await getSettings();
if (settings.registrationEnabled !== "true") { if (settings.registrationEnabled !== "true") {
redirect("/login?notice=registration_closed"); redirect("/login?notice=registration_closed");
} }
const sp = await searchParams;
const str = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v) ?? "";
return ( return (
<div className="min-h-screen flex items-center justify-center" style={{ backgroundColor: "var(--background)" }}> <div className="min-h-screen flex items-center justify-center" style={{ backgroundColor: "var(--background)" }}>
<div className="w-full max-w-sm"> <div className="w-full max-w-sm">
@@ -26,6 +33,10 @@ export default async function RegisterPage() {
privacyPolicyUrl={settings.privacyPolicyUrl} privacyPolicyUrl={settings.privacyPolicyUrl}
termsUrl={settings.termsUrl} termsUrl={settings.termsUrl}
offerUrl={settings.offerUrl} offerUrl={settings.offerUrl}
archetype={str(sp.archetype)}
utmSource={str(sp.utm_source)}
utmMedium={str(sp.utm_medium)}
utmCampaign={str(sp.utm_campaign)}
/> />
</div> </div>
</div> </div>
+15 -1
View File
@@ -5,6 +5,7 @@ import Link from "next/link";
declare global { declare global {
interface Window { interface Window {
ym?: (counterId: number, action: string, goal: string) => void;
turnstile?: { turnstile?: {
render: ( render: (
container: HTMLElement, container: HTMLElement,
@@ -25,9 +26,13 @@ interface Props {
privacyPolicyUrl: string; privacyPolicyUrl: string;
termsUrl: string; termsUrl: string;
offerUrl: string; offerUrl: string;
archetype?: string;
utmSource?: string;
utmMedium?: string;
utmCampaign?: string;
} }
export function RegisterForm({ showTermsCheckbox, privacyPolicyUrl, termsUrl, offerUrl }: Props) { export function RegisterForm({ showTermsCheckbox, privacyPolicyUrl, termsUrl, offerUrl, archetype, utmSource, utmMedium, utmCampaign }: 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("");
@@ -118,6 +123,10 @@ export function RegisterForm({ showTermsCheckbox, privacyPolicyUrl, termsUrl, of
password, password,
website: honeypot, website: honeypot,
cfTurnstileResponse: turnstileToken, cfTurnstileResponse: turnstileToken,
archetype,
utmSource,
utmMedium,
utmCampaign,
}), }),
}); });
} catch { } catch {
@@ -153,6 +162,11 @@ export function RegisterForm({ showTermsCheckbox, privacyPolicyUrl, termsUrl, of
return; return;
} }
// Яндекс.Метрика: цель «бесплатная регистрация»
const mc = Number(process.env.NEXT_PUBLIC_METRIKA_COUNTER_ID);
if (mc && typeof window !== "undefined" && typeof window.ym === "function") {
window.ym(mc, "reachGoal", "signup_free");
}
setSuccess(true); setSuccess(true);
setLoading(false); setLoading(false);
} }
@@ -10,6 +10,7 @@ import { HomeworkSection } from "@/components/student/homework-section";
import { QuizSection } from "@/components/student/quiz-section"; import { QuizSection } from "@/components/student/quiz-section";
import { LessonComments } from "@/components/student/lesson-comments"; import { LessonComments } from "@/components/student/lesson-comments";
import { FileFormatBadge } from "@/components/shared/file-format-badge"; import { FileFormatBadge } from "@/components/shared/file-format-badge";
import { TripwireGateBanner } from "@/components/student/tripwire-gate-banner";
interface Props { interface Props {
params: Promise<{ slug: string; lessonId: string }>; params: Promise<{ slug: string; lessonId: string }>;
@@ -90,6 +91,8 @@ export default async function LessonPage({ params }: Props) {
if (!lesson || lesson.module.course.slug !== slug) notFound(); if (!lesson || lesson.module.course.slug !== slug) notFound();
const isCompleted = !!progress; const isCompleted = !!progress;
const showTripwireGate =
!isAdmin && isCompleted && lessonId === process.env.WOW_MOMENT_LESSON_ID;
// Build ordered flat list of all lessons for prev/next // Build ordered flat list of all lessons for prev/next
const allLessons = lesson.module.course.modules.flatMap((m) => m.lessons); const allLessons = lesson.module.course.modules.flatMap((m) => m.lessons);
@@ -267,6 +270,8 @@ export default async function LessonPage({ params }: Props) {
)} )}
</div> </div>
{showTripwireGate && <TripwireGateBanner />}
{/* Comments */} {/* Comments */}
{session && ( {session && (
<div <div
+22 -61
View File
@@ -3,22 +3,20 @@ import { auth } from "@/lib/auth";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import Link from "next/link"; import Link from "next/link";
import { ArchetypeBanner } from "@/components/student/archetype-banner";
// Продаваемая линейка курсов → лендинг продукта. Курсы из этого списка, import { ToolboxPromoCard } from "@/components/tools/ToolboxPromoCard";
// которых нет у студента, показываются на дашборде заблюренными карточками import { StoreShowcase } from "@/components/student/store-showcase";
// со ссылкой на лендинг (апселл). Легаси/обычные версии сюда не входят. import { STORE_CATALOG } from "@/lib/store-catalog";
const STORE_LINKS: Record<string, string> = {
"second-brain-vault": "https://second-brain.ru/sb-vault",
"obsidian-full": "https://obsidian.second-brain.ru/",
"claude-obsidian": "https://second-brain.ru/claude-obsidian",
"zotero-full": "https://zotero.second-brain.ru/",
"obsidian-ai": "https://obsidianai.second-brain.ru/",
};
export default async function StudentDashboard() { export default async function StudentDashboard() {
const session = await auth.api.getSession({ headers: await headers() }); const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/login"); if (!session) redirect("/login");
const dbUser = await prisma.user.findUnique({
where: { id: session.user.id },
select: { archetype: true },
});
const enrollments = await prisma.courseEnrollment.findMany({ const enrollments = await prisma.courseEnrollment.findMany({
where: { userId: session.user.id }, where: { userId: session.user.id },
include: { include: {
@@ -63,7 +61,7 @@ export default async function StudentDashboard() {
"zotero-full": "zotero", // ВВ Zotero ← обычный Zotero "zotero-full": "zotero", // ВВ Zotero ← обычный Zotero
}; };
const storeCourses = await prisma.course.findMany({ const storeCourses = await prisma.course.findMany({
where: { slug: { in: Object.keys(STORE_LINKS) }, published: true }, where: { slug: { in: Object.keys(STORE_CATALOG) }, published: true },
select: { id: true, slug: true, title: true, description: true, coverImage: true }, select: { id: true, slug: true, title: true, description: true, coverImage: true },
}); });
const locked = storeCourses.filter( const locked = storeCourses.filter(
@@ -79,6 +77,8 @@ export default async function StudentDashboard() {
{active.length} активных курсов {active.length} активных курсов
</p> </p>
{dbUser?.archetype && <ArchetypeBanner archetype={dbUser.archetype} />}
{active.length === 0 ? ( {active.length === 0 ? (
<div className="card-aubade p-12 text-center"> <div className="card-aubade p-12 text-center">
<p className="text-4xl mb-3">📚</p> <p className="text-4xl mb-3">📚</p>
@@ -159,56 +159,17 @@ export default async function StudentDashboard() {
</div> </div>
)} )}
{process.env.TOOLBOX_VISIBLE === "true" && (
<div className="mt-8">
<ToolboxPromoCard />
</div>
)}
{locked.length > 0 && ( {locked.length > 0 && (
<div className="mt-10"> <StoreShowcase
<p className="text-xs font-bold uppercase tracking-widest mb-3" style={{ color: "var(--muted-foreground)" }}> courses={locked}
Другие курсы Second Brain buyer={{ name: session.user.name, email: session.user.email }}
</p> />
<div className="grid gap-4 sm:grid-cols-2">
{locked.map((course) => (
<a
key={course.id}
href={STORE_LINKS[course.slug]}
target="_blank"
rel="noopener noreferrer"
className="card-aubade p-0 overflow-hidden flex flex-col relative group"
>
{/* Overlay: замок + призыв, поверх размытого контента */}
<div
className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 transition-opacity"
style={{ backgroundColor: "color-mix(in srgb, var(--background) 55%, transparent)" }}
>
<span className="text-2xl">🔒</span>
<span
className="text-sm font-bold px-4 py-2"
style={{ border: "2px solid var(--foreground)", backgroundColor: "var(--accent)", color: "var(--foreground)" }}
>
Открыть курс
</span>
</div>
{/* Размытый контент карточки */}
<div className="flex flex-col flex-1" style={{ filter: "blur(1.8px)", opacity: 0.82, pointerEvents: "none" }}>
{course.coverImage ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={course.coverImage} alt={course.title} className="w-full aspect-video object-cover" />
) : (
<div className="w-full aspect-video flex items-center justify-center text-4xl" style={{ backgroundColor: "var(--color-surface)" }}>
📚
</div>
)}
<div className="p-4 flex-1 flex flex-col gap-2">
<h2 className="font-bold text-lg leading-tight">{course.title}</h2>
{course.description && (
<p className="text-sm line-clamp-3" style={{ color: "var(--muted-foreground)" }}>
{course.description}
</p>
)}
</div>
</div>
</a>
))}
</div>
</div>
)} )}
{expired.length > 0 && ( {expired.length > 0 && (
+8
View File
@@ -54,6 +54,14 @@ export default async function StudentLayout({ children }: { children: React.Reac
{schoolName} {schoolName}
</Link> </Link>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Link href="/wrapped" className="text-sm hover:underline" style={{ color: "var(--muted-foreground)" }}>
Wrapped
</Link>
{process.env.TOOLBOX_VISIBLE === "true" && (
<Link href="/tools" className="text-sm hover:underline" style={{ color: "var(--muted-foreground)" }}>
Инструменты
</Link>
)}
<Link href="/questions" className="text-sm hover:underline" style={{ color: "var(--muted-foreground)" }}> <Link href="/questions" className="text-sm hover:underline" style={{ color: "var(--muted-foreground)" }}>
Вопросы Вопросы
</Link> </Link>
@@ -0,0 +1,93 @@
"use client";
import { useMemo, useState } from "react";
import { buildBases, type BaseViewType, type BaseFilterScope } from "@/lib/tools/bases";
import { CodeOutput } from "@/components/tools/CodeOutput";
const VIEW_TYPES: BaseViewType[] = ["table", "cards", "list", "map"];
const SCOPES: { id: BaseFilterScope; label: string }[] = [
{ id: "none", label: "без фильтра" },
{ id: "folder", label: "по папке" },
{ id: "tag", label: "по тегу" },
{ id: "property", label: "по свойству (выражение)" },
];
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;
const SCOPE_PLACEHOLDER: Record<BaseFilterScope, string> = {
none: "",
folder: "Projects",
tag: "#проект",
property: 'status == "done"',
};
export function BasesForm() {
const [viewType, setViewType] = useState<BaseViewType>("table");
const [viewName, setViewName] = useState("Мои заметки");
const [propsText, setPropsText] = useState("file.name, status");
const [filterScope, setFilterScope] = useState<BaseFilterScope>("folder");
const [filterValue, setFilterValue] = useState("Projects");
const [sortBy, setSortBy] = useState("file.name");
const [sortDir, setSortDir] = useState<"ASC" | "DESC">("ASC");
const [groupBy, setGroupBy] = useState("");
const [limit, setLimit] = useState("");
const yaml = useMemo(
() => buildBases({
viewType, viewName,
properties: propsText.split(",").map((s) => s.trim()).filter(Boolean),
filterScope, filterValue,
sortBy, sortDir, groupBy,
limit: limit ? Number(limit) : undefined,
}),
[viewType, viewName, propsText, filterScope, filterValue, sortBy, sortDir, groupBy, limit],
);
return (
<div className="grid gap-6 md:grid-cols-2">
<div className="flex flex-col gap-3">
<label className="text-sm">Тип представления
<select className={field} style={fieldStyle} value={viewType} onChange={(e) => setViewType(e.target.value as BaseViewType)}>
{VIEW_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
</label>
<label className="text-sm">Имя представления
<input className={field} style={fieldStyle} value={viewName} onChange={(e) => setViewName(e.target.value)} />
</label>
<label className="text-sm">Свойства / колонки (через запятую)
<input className={field} style={fieldStyle} placeholder="file.name, status, …" value={propsText} onChange={(e) => setPropsText(e.target.value)} />
</label>
<div className="flex items-end gap-2">
<label className="text-sm flex-1">Фильтр
<select className={field} style={fieldStyle} value={filterScope} onChange={(e) => setFilterScope(e.target.value as BaseFilterScope)}>
{SCOPES.map((s) => <option key={s.id} value={s.id}>{s.label}</option>)}
</select>
</label>
{filterScope !== "none" && (
<input className={field} style={fieldStyle} placeholder={SCOPE_PLACEHOLDER[filterScope]} value={filterValue} onChange={(e) => setFilterValue(e.target.value)} />
)}
</div>
<div className="flex items-end gap-2">
<label className="text-sm flex-1">Сортировка (свойство)
<input className={field} style={fieldStyle} placeholder="не сортировать" value={sortBy} onChange={(e) => setSortBy(e.target.value)} />
</label>
<select className={field} style={{ ...fieldStyle, width: "auto" }} value={sortDir} onChange={(e) => setSortDir(e.target.value as "ASC" | "DESC")}>
<option value="ASC">ASC</option>
<option value="DESC">DESC</option>
</select>
</div>
<label className="text-sm">Группировка (свойство)
<input className={field} style={fieldStyle} placeholder="без группировки" value={groupBy} onChange={(e) => setGroupBy(e.target.value)} />
</label>
<label className="text-sm">Лимит
<input type="number" min={0} className={field} style={fieldStyle} placeholder="без лимита" value={limit} onChange={(e) => setLimit(e.target.value)} />
</label>
</div>
<div className="flex flex-col gap-4">
<CodeOutput code={yaml} tool="bases" label="Содержимое .base-файла" />
</div>
</div>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { BasesForm } from "./BasesForm";
export const metadata = { title: "Генератор Bases — Obsidian Toolbox" };
export default function BasesToolPage() {
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)" }}>Генератор Bases</h1>
<p className="mt-1 text-sm" style={{ color: "var(--muted-foreground)" }}>Базы Obsidian: представление-таблица с фильтрами и сортировкой (.base-файл).</p>
<div className="mt-6 card-aubade p-4">
<BasesForm />
</div>
</main>
);
}
@@ -0,0 +1,50 @@
"use client";
import { useMemo, useState } from "react";
import { buildCalloutCss } from "@/lib/tools/callout";
import { CodeOutput } from "@/components/tools/CodeOutput";
export function CalloutForm() {
const [id, setId] = useState("idea");
const [title, setTitle] = useState("Идея");
const [color, setColor] = useState("#7C3AED");
const [icon, setIcon] = useState("lightbulb");
const result = useMemo(() => {
try { return buildCalloutCss({ id, title, color, icon }); }
catch { return null; }
}, [id, title, color, icon]);
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;
return (
<div className="grid gap-6 md:grid-cols-2">
<div className="flex flex-col gap-3">
<label className="text-sm">Тип (id)
<input className={field} style={fieldStyle} value={id} onChange={(e) => setId(e.target.value)} />
</label>
<label className="text-sm">Заголовок
<input className={field} style={fieldStyle} value={title} onChange={(e) => setTitle(e.target.value)} />
</label>
<label className="text-sm">Цвет (HEX)
<input className={field} style={fieldStyle} value={color} onChange={(e) => setColor(e.target.value)} />
</label>
<label className="text-sm">Иконка (lucide-id)
<input className={field} style={fieldStyle} value={icon} onChange={(e) => setIcon(e.target.value)} />
</label>
</div>
<div className="flex flex-col gap-4">
{result ? (
<>
<CodeOutput code={result.css} tool="callout" label="CSS-snippet" />
<CodeOutput code={result.markdown} tool="callout" label="Пример разметки" />
</>
) : (
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>
Проверь поля: id латиница и цифры (напр. <code>idea</code>), цвет корректный HEX (напр. <code>#7C3AED</code>).
</p>
)}
</div>
</div>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { CalloutForm } from "./CalloutForm";
export const metadata = { title: "Генератор callout-CSS — Obsidian Toolbox" };
export default function CalloutToolPage() {
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)" }}>Генератор callout-CSS</h1>
<p className="mt-1 text-sm" style={{ color: "var(--muted-foreground)" }}>Кастомные коллауты Obsidian: тип, заголовок, цвет, иконка.</p>
<div className="mt-6 card-aubade p-4">
<CalloutForm />
</div>
</main>
);
}
@@ -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>
);
}
@@ -0,0 +1,103 @@
"use client";
import { useMemo, useState } from "react";
import { Plus, Trash2 } from "lucide-react";
import { buildDataview, type DataviewColumn, type DataviewQueryType } from "@/lib/tools/dataview";
import { CodeOutput } from "@/components/tools/CodeOutput";
const QUERY_TYPES: DataviewQueryType[] = ["TABLE", "LIST", "TASK", "CALENDAR"];
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 DataviewForm() {
const [queryType, setQueryType] = useState<DataviewQueryType>("TABLE");
const [withoutId, setWithoutId] = useState(false);
const [columns, setColumns] = useState<DataviewColumn[]>([
{ expr: "file.name", alias: "Заметка" },
{ expr: "file.mtime", alias: "Изменено" },
]);
const [from, setFrom] = useState("#project");
const [where, setWhere] = useState("");
const [sortField, setSortField] = useState("file.mtime");
const [sortDir, setSortDir] = useState<"ASC" | "DESC">("DESC");
const [limit, setLimit] = useState("");
const query = useMemo(
() => buildDataview({
queryType, withoutId, columns,
from, where, sortField, sortDir,
limit: limit ? Number(limit) : undefined,
}),
[queryType, withoutId, columns, from, where, sortField, sortDir, limit],
);
const showColumns = queryType !== "TASK";
const colLabel = queryType === "CALENDAR" ? "поле даты" : queryType === "LIST" ? "поле (берётся первое)" : "колонки";
function updateCol(i: number, patch: Partial<DataviewColumn>) {
setColumns((prev) => prev.map((c, idx) => (idx === i ? { ...c, ...patch } : c)));
}
return (
<div className="grid gap-6 md:grid-cols-2">
<div className="flex flex-col gap-3">
<label className="text-sm">Тип запроса
<select className={field} style={fieldStyle} value={queryType} onChange={(e) => setQueryType(e.target.value as DataviewQueryType)}>
{QUERY_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
</label>
{(queryType === "TABLE" || queryType === "LIST") && (
<label className="text-sm flex items-center gap-2">
<input type="checkbox" checked={withoutId} onChange={(e) => setWithoutId(e.target.checked)} />
WITHOUT ID (скрыть колонку-ссылку)
</label>
)}
{showColumns && (
<div className="flex flex-col gap-2">
<span className="text-sm">{colLabel}</span>
{columns.map((c, i) => (
<div key={i} className="flex items-center gap-2">
<input className={field} style={fieldStyle} placeholder="выражение (file.name)" value={c.expr} onChange={(e) => updateCol(i, { expr: e.target.value })} />
{queryType === "TABLE" && (
<input className={field} style={fieldStyle} placeholder="алиас" value={c.alias ?? ""} onChange={(e) => updateCol(i, { alias: e.target.value })} />
)}
<button type="button" onClick={() => setColumns((p) => p.filter((_, idx) => idx !== i))} aria-label="Удалить" className="btn-aubade inline-flex items-center justify-center p-2">
<Trash2 size={16} />
</button>
</div>
))}
{queryType === "TABLE" && (
<button type="button" onClick={() => setColumns((p) => [...p, { expr: "" }])} className="btn-aubade inline-flex items-center justify-center gap-2 text-sm">
<Plus size={16} /> Колонка
</button>
)}
</div>
)}
<label className="text-sm">FROM (источник)
<input className={field} style={fieldStyle} placeholder='#tag или "Папка" или [[Заметка]]' value={from} onChange={(e) => setFrom(e.target.value)} />
</label>
<label className="text-sm">WHERE (условие)
<input className={field} style={fieldStyle} placeholder="!completed" value={where} onChange={(e) => setWhere(e.target.value)} />
</label>
<div className="flex items-end gap-2">
<label className="text-sm flex-1">SORT (поле)
<input className={field} style={fieldStyle} value={sortField} onChange={(e) => setSortField(e.target.value)} />
</label>
<select className={field} style={{ ...fieldStyle, width: "auto" }} value={sortDir} onChange={(e) => setSortDir(e.target.value as "ASC" | "DESC")}>
<option value="DESC">DESC</option>
<option value="ASC">ASC</option>
</select>
</div>
<label className="text-sm">LIMIT
<input type="number" min={0} className={field} style={fieldStyle} placeholder="без лимита" value={limit} onChange={(e) => setLimit(e.target.value)} />
</label>
</div>
<div className="flex flex-col gap-4">
<CodeOutput code={query} tool="dataview" label="Запрос Dataview (вставь в заметку)" />
</div>
</div>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { DataviewForm } from "./DataviewForm";
export const metadata = { title: "Генератор Dataview — Obsidian Toolbox" };
export default function DataviewToolPage() {
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)" }}>Генератор Dataview</h1>
<p className="mt-1 text-sm" style={{ color: "var(--muted-foreground)" }}>Запросы DQL: таблицы, списки, задачи и календарь по заметкам.</p>
<div className="mt-6 card-aubade p-4">
<DataviewForm />
</div>
</main>
);
}
@@ -0,0 +1,87 @@
"use client";
import { useMemo, useState } from "react";
import { Plus, Trash2 } from "lucide-react";
import { buildFrontmatter, type PropField, type PropType } from "@/lib/tools/frontmatter";
import { CodeOutput } from "@/components/tools/CodeOutput";
const TYPES: PropType[] = ["text", "number", "checkbox", "date", "list", "tags"];
const TYPE_RU: Record<PropType, string> = {
text: "текст", number: "число", checkbox: "флажок", date: "дата", list: "список", tags: "теги",
};
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 FrontmatterForm() {
const [fields, setFields] = useState<PropField[]>([
{ key: "title", type: "text", value: "Моя заметка" },
{ key: "tags", type: "tags", value: "obsidian, pkm" },
{ key: "created", type: "date", value: "2026-06-23" },
]);
const yaml = useMemo(() => buildFrontmatter(fields), [fields]);
function update(i: number, patch: Partial<PropField>) {
setFields((prev) => prev.map((f, idx) => (idx === i ? { ...f, ...patch } : f)));
}
function addRow() {
setFields((prev) => [...prev, { key: "", type: "text", value: "" }]);
}
function removeRow(i: number) {
setFields((prev) => prev.filter((_, idx) => idx !== i));
}
return (
<div className="grid gap-6 md:grid-cols-2">
<div className="flex flex-col gap-3">
{fields.map((f, i) => (
<div key={i} className="flex flex-col gap-2 p-2" style={{ border: "1px solid var(--border)", borderRadius: "2px" }}>
<div className="flex items-center gap-2">
<input
className={field} style={fieldStyle} placeholder="имя свойства"
value={f.key} onChange={(e) => update(i, { key: e.target.value })}
/>
<button
type="button" onClick={() => removeRow(i)} aria-label="Удалить свойство"
className="btn-aubade inline-flex items-center justify-center p-2"
>
<Trash2 size={16} />
</button>
</div>
<div className="flex items-center gap-2">
<select
className={field} style={fieldStyle}
value={f.type} onChange={(e) => update(i, { type: e.target.value as PropType })}
>
{TYPES.map((t) => <option key={t} value={t}>{TYPE_RU[t]}</option>)}
</select>
{f.type === "checkbox" ? (
<select className={field} style={fieldStyle} value={f.value ?? "false"} onChange={(e) => update(i, { value: e.target.value })}>
<option value="true">true</option>
<option value="false">false</option>
</select>
) : f.type === "date" ? (
<input
type="date" className={field} style={fieldStyle}
value={f.value ?? ""} onChange={(e) => update(i, { value: e.target.value })}
/>
) : (
<input
className={field} style={fieldStyle}
placeholder={f.type === "list" || f.type === "tags" ? "через запятую" : "значение"}
value={f.value ?? ""} onChange={(e) => update(i, { value: e.target.value })}
/>
)}
</div>
</div>
))}
<button type="button" onClick={addRow} className="btn-aubade inline-flex items-center justify-center gap-2 text-sm">
<Plus size={16} /> Добавить свойство
</button>
</div>
<div className="flex flex-col gap-4">
<CodeOutput code={yaml} tool="frontmatter" label="YAML-frontmatter" />
</div>
</div>
);
}
@@ -0,0 +1,15 @@
import { FrontmatterForm } from "./FrontmatterForm";
export const metadata = { title: "Генератор YAML-frontmatter — Obsidian Toolbox" };
export default function FrontmatterToolPage() {
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)" }}>Генератор YAML-frontmatter</h1>
<p className="mt-1 text-sm" style={{ color: "var(--muted-foreground)" }}>Шаблон свойств заметки: имя, тип, значение по умолчанию.</p>
<div className="mt-6 card-aubade p-4">
<FrontmatterForm />
</div>
</main>
);
}
+8
View File
@@ -0,0 +1,8 @@
import { redirect } from "next/navigation";
// Toolbox ещё в доработке: на проде скрыт (флаг не задан) — прямой заход на
// /tools/* уводит на дашборд. На staging выставлен TOOLBOX_VISIBLE=true.
export default function ToolsLayout({ children }: { children: React.ReactNode }) {
if (process.env.TOOLBOX_VISIBLE !== "true") redirect("/dashboard");
return <>{children}</>;
}
+16
View File
@@ -0,0 +1,16 @@
import { TOOLS } from "@/lib/tools/_shared/types";
import { ToolCard } from "@/components/tools/ToolCard";
export const metadata = { title: "Инструменты — Obsidian Toolbox" };
export default function ToolsIndexPage() {
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)" }}>Инструменты</h1>
<p className="mt-1 text-sm" style={{ color: "var(--muted-foreground)" }}>Генераторы синтаксиса Obsidian для участников школы.</p>
<div className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2">
{TOOLS.map((t) => <ToolCard key={t.id} tool={t} />)}
</div>
</main>
);
}
@@ -0,0 +1,78 @@
"use client";
import { useMemo, useState } from "react";
import { Plus, Trash2 } from "lucide-react";
import { buildStyleSettings, type SettingItem, type SettingType } from "@/lib/tools/style-settings";
import { CodeOutput } from "@/components/tools/CodeOutput";
const TYPES: SettingType[] = [
"heading", "class-toggle", "class-select",
"variable-text", "variable-number", "variable-number-slider",
"variable-select", "variable-color", "info-text",
];
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;
function hasDefault(t: SettingType) { return t !== "heading" && t !== "info-text"; }
function hasOptions(t: SettingType) { return t === "variable-select" || t === "class-select"; }
export function StyleSettingsForm() {
const [pluginName, setPluginName] = useState("Моя тема");
const [settings, setSettings] = useState<SettingItem[]>([
{ id: "section", title: "Цвета", type: "heading" },
{ id: "accent", title: "Акцент", type: "variable-color", default: "#7C3AED", format: "hex" },
]);
const output = useMemo(() => buildStyleSettings({ pluginName, settings }), [pluginName, settings]);
function update(i: number, patch: Partial<SettingItem>) {
setSettings((prev) => prev.map((s, idx) => (idx === i ? { ...s, ...patch } : s)));
}
function addRow() {
setSettings((prev) => [...prev, { id: "", title: "", type: "variable-text", default: "" }]);
}
function removeRow(i: number) {
setSettings((prev) => prev.filter((_, idx) => idx !== i));
}
return (
<div className="grid gap-6 md:grid-cols-2">
<div className="flex flex-col gap-3">
<label className="text-sm">Имя темы / плагина
<input className={field} style={fieldStyle} value={pluginName} onChange={(e) => setPluginName(e.target.value)} />
</label>
{settings.map((s, i) => (
<div key={i} className="flex flex-col gap-2 p-2" style={{ border: "1px solid var(--border)", borderRadius: "2px" }}>
<div className="flex items-center gap-2">
<input className={field} style={fieldStyle} placeholder="id" value={s.id} onChange={(e) => update(i, { id: e.target.value })} />
<button type="button" onClick={() => removeRow(i)} aria-label="Удалить настройку" className="btn-aubade inline-flex items-center justify-center p-2">
<Trash2 size={16} />
</button>
</div>
<input className={field} style={fieldStyle} placeholder="заголовок" value={s.title} onChange={(e) => update(i, { title: e.target.value })} />
<select className={field} style={fieldStyle} value={s.type} onChange={(e) => update(i, { type: e.target.value as SettingType })}>
{TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
{hasDefault(s.type) && (
<input className={field} style={fieldStyle} placeholder="значение по умолчанию" value={s.default ?? ""} onChange={(e) => update(i, { default: e.target.value })} />
)}
{hasOptions(s.type) && (
<input className={field} style={fieldStyle} placeholder="варианты через запятую" value={s.options ?? ""} onChange={(e) => update(i, { options: e.target.value })} />
)}
{s.type === "variable-color" && (
<select className={field} style={fieldStyle} value={s.format ?? "hex"} onChange={(e) => update(i, { format: e.target.value })}>
{["hex", "rgb", "hsl"].map((fmt) => <option key={fmt} value={fmt}>{fmt}</option>)}
</select>
)}
</div>
))}
<button type="button" onClick={addRow} className="btn-aubade inline-flex items-center justify-center gap-2 text-sm">
<Plus size={16} /> Добавить настройку
</button>
</div>
<div className="flex flex-col gap-4">
<CodeOutput code={output} tool="style-settings" label="Блок Style Settings (в начало CSS-сниппета)" />
</div>
</div>
);
}
@@ -0,0 +1,15 @@
import { StyleSettingsForm } from "./StyleSettingsForm";
export const metadata = { title: "Генератор Style Settings — Obsidian Toolbox" };
export default function StyleSettingsToolPage() {
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)" }}>Генератор Style Settings</h1>
<p className="mt-1 text-sm" style={{ color: "var(--muted-foreground)" }}>Комментарии-настройки для плагина Style Settings.</p>
<div className="mt-6 card-aubade p-4">
<StyleSettingsForm />
</div>
</main>
);
}
@@ -0,0 +1,58 @@
"use client";
import { useMemo, useState } from "react";
import { buildThemeCss, type ThemeInput } from "@/lib/tools/theme";
import { CodeOutput } from "@/components/tools/CodeOutput";
const FIELDS: { key: keyof ThemeInput; label: string }[] = [
{ key: "background", label: "Фон" },
{ key: "text", label: "Текст" },
{ key: "accent", label: "Акцент" },
{ key: "link", label: "Ссылки" },
{ key: "border", label: "Рамка" },
];
const fieldStyle = { border: "1px solid var(--border)", borderRadius: "2px", backgroundColor: "var(--background)", color: "var(--foreground)" } as const;
export function ThemeForm() {
const [theme, setTheme] = useState<ThemeInput>({
background: "#0F0F0F", text: "#EAEAEA", accent: "#7C3AED", link: "#4EA1FF", border: "#333333",
});
const css = useMemo(() => buildThemeCss(theme), [theme]);
function update(key: keyof ThemeInput, value: string) {
setTheme((prev) => ({ ...prev, [key]: value }));
}
return (
<div className="grid gap-6 md:grid-cols-2">
<div className="flex flex-col gap-3">
{FIELDS.map((f) => (
<label key={f.key} className="flex items-center justify-between gap-3 text-sm">
<span>{f.label}</span>
<span className="flex items-center gap-2">
<input
type="color" value={theme[f.key]} onChange={(e) => update(f.key, e.target.value)}
aria-label={f.label} style={{ width: 36, height: 28, border: "1px solid var(--border)", borderRadius: 2, padding: 0, background: "none" }}
/>
<input
className="w-28 p-2 text-sm" style={fieldStyle}
value={theme[f.key]} onChange={(e) => update(f.key, e.target.value)}
/>
</span>
</label>
))}
<div
className="mt-2 p-4 text-sm"
style={{ backgroundColor: theme.background, color: theme.text, border: `2px solid ${theme.border}`, borderRadius: 2 }}
>
Превью темы. <a href="#" onClick={(e) => e.preventDefault()} style={{ color: theme.link }}>ссылка</a>,{" "}
<span style={{ color: theme.accent, fontWeight: 700 }}>акцент</span>.
</div>
</div>
<div className="flex flex-col gap-4">
<CodeOutput code={css} tool="theme" label="CSS-переменные темы" />
</div>
</div>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { ThemeForm } from "./ThemeForm";
export const metadata = { title: "Генератор темы — Obsidian Toolbox" };
export default function ThemeToolPage() {
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)" }}>Генератор темы / CSS-переменных</h1>
<p className="mt-1 text-sm" style={{ color: "var(--muted-foreground)" }}>Палитра набор CSS-переменных темы Obsidian.</p>
<div className="mt-6 card-aubade p-4">
<ThemeForm />
</div>
</main>
);
}
+354
View File
@@ -0,0 +1,354 @@
"use client";
import { useState, useRef, useCallback } from "react";
import type { Stats, RawNote, Period, RunPayload } from "./_client/types";
import { supportsFSA, readViaFSA, readViaInput, readViaDrop } from "./_client/reader";
type Screen = "intro" | "loading" | "result" | "error";
type View = "card" | "graph";
const ARCHETYPE_RU: Record<string, string> = {
architect: "🏛 Архитектор",
gardener: "🌱 Садовник",
librarian: "📚 Библиотекарь",
pragmatist: "⚡ Прагматик",
};
const PERIOD_RU: Record<Period, string> = { year: "год", quarter: "квартал", all: "всё время" };
export function WrappedClient({ userName }: { userName: string }) {
const [screen, setScreen] = useState<Screen>("intro");
const [stats, setStats] = useState<Stats | null>(null);
const [period, setPeriod] = useState<Period>("year");
const [error, setError] = useState("");
const [shareUrl, setShareUrl] = useState("");
const [sharing, setSharing] = useState(false);
const [view, setView] = useState<View>("card");
const [graphUrl, setGraphUrl] = useState("");
const [graphLabels, setGraphLabels] = useState(false);
const [graphBusy, setGraphBusy] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const notesRef = useRef<RawNote[]>([]);
// Сбросить отрисованный граф (при новых данных / смене периода).
const resetGraph = useCallback(() => {
setView("card");
setGraphUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return "";
});
}, []);
// Отрисовать граф-постер в офф-скрин Canvas → blob URL. force-graph грузим лениво.
const renderGraph = useCallback(
async (labels: boolean) => {
if (!stats || !stats.graph.nodes.length) return;
setGraphBusy(true);
setError("");
try {
const { renderGraphPoster } = await import("./_client/graphPoster");
const blob = await renderGraphPoster(stats.graph, labels);
setGraphUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return URL.createObjectURL(blob);
});
} catch {
setError("Не удалось построить граф");
}
setGraphBusy(false);
},
[stats],
);
const runWorker = useCallback((notes: RawNote[], p: Period) => {
setScreen("loading");
const worker = new Worker(new URL("./_client/parse.worker.ts", import.meta.url), { type: "module" });
worker.onmessage = (e: MessageEvent<Stats>) => {
setStats(e.data);
resetGraph();
setScreen("result");
worker.terminate();
};
worker.onerror = () => {
setError("Не удалось обработать хранилище");
setScreen("error");
worker.terminate();
};
worker.postMessage({ notes, period: p });
}, [resetGraph]);
const handleNotes = useCallback(
(notes: RawNote[]) => {
if (!notes.length) {
setError("В выбранной папке не нашлось .md заметок");
setScreen("error");
return;
}
notesRef.current = notes;
runWorker(notes, period);
},
[period, runWorker],
);
async function pickFSA() {
try {
handleNotes(await readViaFSA());
} catch (e) {
if ((e as Error).name !== "AbortError") {
setError("Не удалось прочитать папку");
setScreen("error");
}
}
}
async function onInput(e: React.ChangeEvent<HTMLInputElement>) {
if (e.target.files) handleNotes(await readViaInput(e.target.files));
}
async function onDrop(e: React.DragEvent) {
e.preventDefault();
if (e.dataTransfer.items) handleNotes(await readViaDrop(e.dataTransfer.items));
}
function changePeriod(p: Period) {
setPeriod(p);
if (notesRef.current.length) runWorker(notesRef.current, p);
}
async function share() {
if (!stats || sharing) return;
setSharing(true);
setError("");
const payload: RunPayload = {
period: stats.period,
noteCount: stats.noteCount,
linkCount: stats.linkCount,
tagCount: stats.tagCount,
topTag: stats.topTags[0]?.tag ?? null,
archetype: stats.archetype,
score: stats.score,
};
try {
const res = await fetch("/api/wrapped/run", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
});
const data = (await res.json()) as { shareToken?: string };
if (data.shareToken) setShareUrl(`${location.origin}/share/wrapped/${data.shareToken}`);
else setError("Не удалось создать ссылку");
} catch {
setError("Не удалось создать ссылку");
}
setSharing(false);
}
return (
<main className="max-w-2xl mx-auto px-6 py-12 w-full">
<p className="text-xs font-bold uppercase tracking-widest mb-2" style={{ color: "var(--muted-foreground)" }}>
Obsidian Wrapped · 2026
</p>
{screen === "intro" && (
<div className="card-aubade p-8">
<h1 className="text-2xl font-bold mb-3">Твой год в заметках, {userName.split(" ")[0]}</h1>
<p className="text-sm mb-6" style={{ color: "var(--muted-foreground)" }}>
Выбери папку своего хранилища всё считается прямо в браузере, заметки никуда не загружаются.
На выходе карточка «твой год в заметках», которой можно поделиться.
</p>
<div
onDrop={onDrop}
onDragOver={(e) => e.preventDefault()}
className="p-8 text-center mb-4"
style={{ border: "2px dashed var(--border)", borderRadius: 2 }}
>
<p className="text-3xl mb-2">📂</p>
{supportsFSA() ? (
<button onClick={pickFSA} className="btn-aubade btn-aubade-accent text-sm">
Выбрать папку хранилища
</button>
) : (
<button onClick={() => inputRef.current?.click()} className="btn-aubade btn-aubade-accent text-sm">
Выбрать папку хранилища
</button>
)}
<p className="text-xs mt-3" style={{ color: "var(--muted-foreground)" }}>
или перетащи папку сюда
</p>
<input
ref={inputRef}
type="file"
// @ts-expect-error webkitdirectory не в типах
webkitdirectory=""
directory=""
multiple
hidden
onChange={onInput}
/>
</div>
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
🔒 Приватность: тексты заметок остаются в браузере. На сервер уходят только числа (сколько заметок, связей, тегов).
</p>
</div>
)}
{screen === "loading" && (
<div className="card-aubade p-12 text-center">
<p className="text-3xl mb-3"></p>
<p className="font-medium">Считаю твой год</p>
</div>
)}
{screen === "error" && (
<div className="card-aubade p-8 text-center">
<p className="text-sm mb-4" style={{ color: "oklch(0.577 0.245 27.325)" }}>{error}</p>
<button onClick={() => { setScreen("intro"); setError(""); }} className="btn-aubade text-sm">
Попробовать снова
</button>
</div>
)}
{screen === "result" && stats && (
<div className="space-y-4">
{/* переключатель периода */}
<div className="flex flex-wrap items-center gap-2">
{(["year", "quarter", "all"] as Period[]).map((p) => (
<button
key={p}
onClick={() => changePeriod(p)}
className="text-xs px-3 py-1.5"
style={{
border: "2px solid var(--foreground)",
borderRadius: 2,
background: period === p ? "var(--accent)" : "var(--background)",
fontWeight: period === p ? 700 : 400,
}}
>
{PERIOD_RU[p]}
</button>
))}
{/* переключатель вида: карточка / граф */}
<div className="flex gap-2 ml-auto">
{(["card", "graph"] as View[]).map((v) => (
<button
key={v}
onClick={() => {
setView(v);
if (v === "graph" && !graphUrl && !graphBusy) renderGraph(graphLabels);
}}
className="text-xs px-3 py-1.5"
style={{
border: "2px solid var(--foreground)",
borderRadius: 2,
background: view === v ? "var(--accent)" : "var(--background)",
fontWeight: view === v ? 700 : 400,
}}
>
{v === "card" ? "Карточка" : "Граф"}
</button>
))}
</div>
</div>
{/* граф-постер хранилища */}
{view === "graph" && (
<div className="card-aubade p-6 space-y-3">
<div className="flex items-center justify-between gap-3">
<h2 className="font-bold text-xl">Граф твоего хранилища</h2>
<label className="text-xs flex items-center gap-1.5" style={{ color: "var(--muted-foreground)" }}>
<input
type="checkbox"
checked={graphLabels}
onChange={(e) => { setGraphLabels(e.target.checked); renderGraph(e.target.checked); }}
/>
подписи узлов
</label>
</div>
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
{stats.graph.nodes.length} самых связанных заметок из твоего хранилища. Имена остаются в браузере.
</p>
<div className="flex items-center justify-center" style={{ border: "2px solid var(--border)", borderRadius: 2, minHeight: 280, background: "#F5F5F0" }}>
{graphBusy ? (
<p className="text-sm py-12" style={{ color: "var(--muted-foreground)" }}>Строю граф</p>
) : graphUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={graphUrl} alt="Граф хранилища" className="w-full h-auto" style={{ borderRadius: 2 }} />
) : stats.graph.nodes.length === 0 ? (
<p className="text-sm py-12 px-4 text-center" style={{ color: "var(--muted-foreground)" }}>
Слишком мало связей между заметками, чтобы построить граф.
</p>
) : null}
</div>
{graphUrl && (
<a href={graphUrl} download="obsidian-graph.png" className="btn-aubade text-sm inline-block">
Скачать граф (PNG)
</a>
)}
</div>
)}
{/* DOM-карточка результата (ДС-2) */}
{view === "card" && (
<div className="card-aubade p-6">
<h2 className="font-bold text-xl mb-1">Ты {ARCHETYPE_RU[stats.archetype]}</h2>
<p className="text-xs uppercase tracking-widest mb-4" style={{ color: "var(--muted-foreground)" }}>
зрелость {stats.score}/100 · период: {PERIOD_RU[stats.period]}
</p>
<div className="grid grid-cols-3 gap-3 mb-4">
{[
{ n: stats.noteCount, l: "заметок" },
{ n: stats.linkCount, l: "связей" },
{ n: stats.tagCount, l: "тегов" },
].map((m) => (
<div key={m.l} className="p-3 text-center" style={{ border: "2px solid var(--border)", boxShadow: "4px 4px 0 0 var(--border)" }}>
<p className="text-2xl font-bold">{m.n}</p>
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>{m.l}</p>
</div>
))}
</div>
{stats.topTags.length > 0 && (
<p className="text-sm mb-2">
<span className="text-xs uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>Топ-теги: </span>
{stats.topTags.map((t) => `#${t.tag}`).join(" ")}
</p>
)}
{stats.achievements.length > 0 && (
<div className="flex flex-wrap gap-2 mt-3">
{stats.achievements.map((a) => (
<span key={a} className="text-xs px-2 py-1" style={{ background: "var(--accent)", border: "2px solid var(--foreground)", borderRadius: 2 }}>
{a}
</span>
))}
</div>
)}
</div>
)}
{/* действия */}
<div className="flex flex-col sm:flex-row gap-3">
<button onClick={share} disabled={sharing} className="btn-aubade btn-aubade-accent text-sm flex-1">
{sharing ? "Создаю ссылку…" : "Поделиться карточкой"}
</button>
<button onClick={() => { setScreen("intro"); setStats(null); setShareUrl(""); resetGraph(); }} className="btn-aubade text-sm flex-1">
Другое хранилище
</button>
</div>
{shareUrl && (
<div className="card-aubade p-4 space-y-3">
<div>
<p className="text-xs uppercase tracking-widest mb-2" style={{ color: "var(--muted-foreground)" }}>Публичная ссылка</p>
<a href={shareUrl} target="_blank" rel="noopener noreferrer" className="text-sm underline break-all" style={{ color: "var(--foreground)" }}>
{shareUrl}
</a>
</div>
<a href={`${shareUrl}/opengraph-image`} download className="btn-aubade text-sm inline-block">
Скачать картинку (PNG)
</a>
</div>
)}
{error && <p className="text-xs" style={{ color: "oklch(0.577 0.245 27.325)" }}>{error}</p>}
</div>
)}
</main>
);
}
@@ -0,0 +1,69 @@
// Граф-постер: force-graph (Canvas) в офф-скрин контейнере → PNG. ДС-2-окрас.
// 100% client-side — имена узлов остаются в браузере, на сервер не уходят.
import ForceGraph from "force-graph";
import type { GraphData } from "./types";
/* eslint-disable @typescript-eslint/no-explicit-any */
const SIZE = 1080;
const BG = "#F5F5F0";
const NODE = "#323232";
const LINK = "#AAAAAA";
export async function renderGraphPoster(graph: GraphData, labels: boolean): Promise<Blob> {
const el = document.createElement("div");
el.style.cssText = "position:fixed;left:-99999px;top:0;width:1080px;height:1080px;";
document.body.appendChild(el);
try {
const fg = (ForceGraph as any)()(el)
.width(SIZE)
.height(SIZE)
.backgroundColor(BG)
.graphData({
nodes: graph.nodes.map((n) => ({ ...n })),
links: graph.links.map((l) => ({ ...l })),
})
.nodeId("id")
.nodeRelSize(labels ? 3 : 2.5)
.nodeVal((n: any) => 1 + Math.sqrt(n.deg ?? 1))
.nodeColor(() => NODE)
.linkColor(() => LINK)
.linkWidth(0.4)
.enableNodeDrag(false)
.enablePointerInteraction(false)
.cooldownTime(3500);
if (labels) {
fg.nodeCanvasObjectMode(() => "after").nodeCanvasObject((node: any, ctx: CanvasRenderingContext2D, scale: number) => {
if ((node.deg ?? 0) >= 6) {
const fontSize = 11 / scale;
ctx.font = `${fontSize}px "Fira Mono", monospace`;
ctx.fillStyle = NODE;
ctx.textBaseline = "middle";
ctx.fillText(String(node.id), node.x + 2 + Math.sqrt(node.deg) / scale, node.y);
}
});
}
// дождаться остановки симуляции (или таймаут)
await new Promise<void>((resolve) => {
let done = false;
const finish = () => { if (!done) { done = true; resolve(); } };
fg.onEngineStop(finish);
setTimeout(finish, 5000);
});
fg.zoomToFit(0, 50);
await new Promise((r) => setTimeout(r, 400));
const canvas = el.querySelector("canvas") as HTMLCanvasElement | null;
if (!canvas) throw new Error("canvas not found");
return await new Promise<Blob>((resolve, reject) =>
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error("toBlob failed"))), "image/png"),
);
} finally {
document.body.removeChild(el);
}
}
@@ -0,0 +1,96 @@
// Web Worker: парсит .md-тексты в агрегаты (off-main, без фриза UI).
// Получает RawNote[], возвращает Stats. Тексты заметок дальше воркера не идут.
import type { RawNote, Stats, Period, Archetype, GraphData } from "./types";
const WIKILINK = /\[\[([^\]|#]+)/g;
const TAG = /(^|\s)#([\p{L}\d_/-]+)/gu;
const DAY = 864e5;
function filterByPeriod(notes: RawNote[], period: Period): RawNote[] {
if (period === "all") return notes;
const cutoff = Date.now() - (period === "year" ? 365 * DAY : 90 * DAY);
return notes.filter((n) => n.mtime >= cutoff);
}
function buildGraph(degree: Map<string, number>, edges: { source: string; target: string }[]): GraphData {
const TOP = 150;
const top = [...degree.entries()].sort((a, b) => b[1] - a[1]).slice(0, TOP);
const nodes = top.map(([id, deg]) => ({ id, deg }));
const ids = new Set(nodes.map((n) => n.id));
const links = edges.filter((e) => ids.has(e.source) && ids.has(e.target)).slice(0, 600);
return { nodes, links };
}
function buildStats(
notes: RawNote[],
degree: Map<string, number>,
tags: Map<string, number>,
linkCount: number,
period: Period,
edges: { source: string; target: string }[],
): Stats {
const noteCount = notes.length;
const topTags = [...tags.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([tag, n]) => ({ tag, n }));
const topLinked = [...degree.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([name, d]) => ({ name, degree: d }));
// Сирота — заметка, на которую никто не ссылается (грубая оценка по имени).
const linkedTargets = new Set(degree.keys());
const orphans = notes.filter((n) => !linkedTargets.has(n.name.replace(/\.md$/i, ""))).length;
const months = new Map<string, number>();
for (const n of notes) {
const d = new Date(n.mtime);
const k = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
months.set(k, (months.get(k) ?? 0) + 1);
}
const busiestMonth = [...months.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? "";
const linkDensity = noteCount ? linkCount / noteCount : 0;
const tagDensity = noteCount ? tags.size / noteCount : 0;
const orphanRatio = noteCount ? orphans / noteCount : 1;
let archetype: Archetype;
if (linkDensity >= 2) archetype = "architect";
else if (tagDensity >= 0.5) archetype = "librarian";
else if (orphanRatio >= 0.4) archetype = "gardener";
else archetype = "pragmatist";
const score = Math.max(0, Math.min(100, Math.round(
Math.min(linkDensity / 3, 1) * 45 + Math.min(tagDensity, 1) * 25 + Math.min(noteCount / 300, 1) * 30,
)));
const achievements: string[] = [];
if (noteCount >= 100) achievements.push(`${noteCount} заметок`);
if (linkCount >= 200) achievements.push(`${linkCount} связей`);
if (tags.size >= 30) achievements.push(`${tags.size} тегов`);
if (linkDensity >= 3) achievements.push("Плотная сеть знаний");
if (orphanRatio < 0.1 && noteCount >= 50) achievements.push("Почти нет сирот");
return { noteCount, linkCount, tagCount: tags.size, topTags, topLinked, orphans, busiestMonth, achievements, archetype, score, period, graph: buildGraph(degree, edges) };
}
self.onmessage = (e: MessageEvent<{ notes: RawNote[]; period: Period }>) => {
const { notes, period } = e.data;
const filtered = filterByPeriod(notes, period);
const degree = new Map<string, number>();
const tags = new Map<string, number>();
let linkCount = 0;
const edges: { source: string; target: string }[] = [];
for (const n of filtered) {
const src = n.name.replace(/\.md$/i, "");
for (const m of n.text.matchAll(WIKILINK)) {
linkCount++;
const t = m[1].trim();
if (t) {
degree.set(t, (degree.get(t) ?? 0) + 1);
edges.push({ source: src, target: t });
}
}
for (const m of n.text.matchAll(TAG)) {
const t = m[2];
tags.set(t, (tags.get(t) ?? 0) + 1);
}
}
self.postMessage(buildStats(filtered, degree, tags, linkCount, period, edges));
};
@@ -0,0 +1,79 @@
// Чтение хранилища в браузере — три пути ввода, все → RawNote[].
// Содержимое заметок остаётся в браузере (приватность-моат).
import type { RawNote } from "./types";
const SKIP = ["/.obsidian/", "/.trash/", "/.git/"];
function isMd(path: string): boolean {
return path.toLowerCase().endsWith(".md") && !SKIP.some((s) => path.includes(s));
}
/* eslint-disable @typescript-eslint/no-explicit-any */
export function supportsFSA(): boolean {
return typeof window !== "undefined" && "showDirectoryPicker" in window;
}
// 1. File System Access API (Chromium: Chrome/Edge/Brave)
export async function readViaFSA(): Promise<RawNote[]> {
const dir: any = await (window as any).showDirectoryPicker();
const notes: RawNote[] = [];
async function walk(handle: any, prefix: string): Promise<void> {
for await (const [name, h] of handle.entries()) {
const path = prefix + "/" + name;
if (h.kind === "directory") {
if (name.startsWith(".")) continue;
await walk(h, path);
} else if (isMd(path)) {
const file: File = await h.getFile();
notes.push({ name, text: await file.text(), ctime: file.lastModified, mtime: file.lastModified });
}
}
}
await walk(dir, "");
return notes;
}
// 2. <input type="file" webkitdirectory> (Safari / Firefox)
export async function readViaInput(files: FileList): Promise<RawNote[]> {
const notes: RawNote[] = [];
for (const file of Array.from(files)) {
const path = (file as any).webkitRelativePath || file.name;
if (!isMd(path)) continue;
notes.push({ name: file.name, text: await file.text(), ctime: file.lastModified, mtime: file.lastModified });
}
return notes;
}
// 3. drag-drop папки (webkitGetAsEntry)
export async function readViaDrop(items: DataTransferItemList): Promise<RawNote[]> {
const notes: RawNote[] = [];
const roots: any[] = [];
for (const item of Array.from(items)) {
const entry = (item as any).webkitGetAsEntry?.();
if (entry) roots.push(entry);
}
async function readEntry(entry: any, prefix: string): Promise<void> {
const path = prefix + "/" + entry.name;
if (entry.isDirectory) {
if (entry.name.startsWith(".")) return;
const reader = entry.createReader();
const children: any[] = await new Promise((resolve) => {
const all: any[] = [];
const readBatch = () =>
reader.readEntries((batch: any[]) => {
if (!batch.length) return resolve(all);
all.push(...batch);
readBatch();
});
readBatch();
});
for (const c of children) await readEntry(c, path);
} else if (isMd(path)) {
const file: File = await new Promise((resolve, reject) => entry.file(resolve, reject));
notes.push({ name: entry.name, text: await file.text(), ctime: file.lastModified, mtime: file.lastModified });
}
}
for (const e of roots) await readEntry(e, "");
return notes;
}
@@ -0,0 +1,46 @@
// Сквозные типы Obsidian Wrapped (общие: reader, worker, рендер, API).
// Приватность: имена/тексты заметок живут ТОЛЬКО в браузере; в БД/сеть уходят
// лишь числовые/строковые агрегаты (RunPayload).
export type Archetype = "architect" | "gardener" | "librarian" | "pragmatist";
export type Period = "year" | "quarter" | "all";
// Граф для постера (топ-N узлов по степени). Имена живут только в браузере.
export interface GraphData {
nodes: { id: string; deg: number }[];
links: { source: string; target: string }[];
}
export interface Stats {
noteCount: number;
linkCount: number;
tagCount: number;
topTags: { tag: string; n: number }[]; // top-5
topLinked: { name: string; degree: number }[]; // top-5 самых связанных
orphans: number;
busiestMonth: string; // "2026-03"
achievements: string[];
archetype: Archetype;
score: number; // 0..100
period: Period;
graph: GraphData; // топ-N узлов/рёбер для граф-постера (не уходит в БД)
}
// Сырьё — НИКОГДА не покидает браузер.
export interface RawNote {
name: string;
text: string;
ctime: number;
mtime: number;
}
// То, что реально уходит в POST /api/wrapped/run — БЕЗ имён/текстов.
export interface RunPayload {
period: Period;
noteCount: number;
linkCount: number;
tagCount: number;
topTag: string | null;
archetype: Archetype;
score: number;
}
+11
View File
@@ -0,0 +1,11 @@
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import { WrappedClient } from "./WrappedClient";
// Авторизованный генератор Obsidian Wrapped. Гость → /register (приток из публичной карточки).
export default async function WrappedPage() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/register");
return <WrappedClient userName={session.user.name} />;
}
+151
View File
@@ -0,0 +1,151 @@
import { NextResponse } from "next/server";
const ARCHETYPES = ["architect", "gardener", "librarian", "pragmatist"] as const;
type Archetype = (typeof ARCHETYPES)[number];
interface ListmonkSubscriber {
id: number;
}
interface ListmonkSearchResult {
data?: {
results?: ListmonkSubscriber[];
};
}
function buildAuth(): string {
return (
"Basic " +
Buffer.from(
`${process.env.LISTMONK_USER}:${process.env.LISTMONK_TOKEN}`,
).toString("base64")
);
}
function listmonkUrl(path: string): string {
return `${process.env.LISTMONK_URL}${path}`;
}
function getListId(): number {
return Number(process.env.LISTMONK_QUIZ_LIST_ID);
}
function isValidEmail(email: string): boolean {
return /^[^@]+@[^@]+\.[^@]+$/.test(email);
}
function isValidArchetype(value: string): value is Archetype {
return (ARCHETYPES as readonly string[]).includes(value);
}
/**
* POST /api/internal/quiz-lead
*
* Receives a completed Typebot quiz lead and idempotently upserts
* the subscriber in Listmonk with archetype + score attributes.
*
* Auth: x-quiz-secret header must match QUIZ_LEAD_SECRET env var.
*/
export async function POST(req: Request): Promise<NextResponse> {
// --- Auth ---
const secret = req.headers.get("x-quiz-secret");
if (!secret || secret !== process.env.QUIZ_LEAD_SECRET) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
// --- Parse body ---
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "bad json" }, { status: 400 });
}
// --- Validate fields ---
const raw = body as Record<string, unknown>;
const email = String(raw.email ?? "")
.trim()
.toLowerCase();
const archetype = String(raw.archetype ?? "");
const score = Number(raw.score);
if (!isValidEmail(email) || !isValidArchetype(archetype) || !Number.isFinite(score)) {
return NextResponse.json({ error: "invalid fields" }, { status: 400 });
}
const attribs = {
archetype,
score,
utm_source: raw.utm_source != null ? String(raw.utm_source) : null,
utm_medium: raw.utm_medium != null ? String(raw.utm_medium) : null,
utm_campaign: raw.utm_campaign != null ? String(raw.utm_campaign) : null,
};
const auth = buildAuth();
const listId = getListId();
// --- Upsert to Listmonk ---
try {
const createRes = await fetch(listmonkUrl("/api/subscribers"), {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: auth },
body: JSON.stringify({
email,
name: email.split("@")[0],
lists: [listId],
attribs,
status: "enabled",
preconfirm_subscriptions: true,
}),
});
if (createRes.status === 409) {
// Subscriber exists — look up by email and update
const searchRes = await fetch(
listmonkUrl("/api/subscribers?query=") +
encodeURIComponent(`subscribers.email='${email}'`),
{ headers: { Authorization: auth } },
);
const searchJson = (await searchRes.json()) as ListmonkSearchResult;
const existingId = searchJson?.data?.results?.[0]?.id;
if (!existingId) {
console.error("quiz-lead: subscriber lookup failed for", email);
return NextResponse.json({ error: "lookup failed" }, { status: 502 });
}
const updateRes = await fetch(listmonkUrl(`/api/subscribers/${existingId}`), {
method: "PUT",
headers: { "Content-Type": "application/json", Authorization: auth },
body: JSON.stringify({
email,
name: email.split("@")[0],
lists: [listId],
attribs,
status: "enabled",
}),
});
if (!updateRes.ok) {
console.error(
"quiz-lead: subscriber update failed",
updateRes.status,
await updateRes.text(),
);
return NextResponse.json({ error: "listmonk" }, { status: 502 });
}
} else if (!createRes.ok) {
console.error(
"quiz-lead: listmonk create failed",
createRes.status,
await createRes.text(),
);
return NextResponse.json({ error: "listmonk" }, { status: 502 });
}
} catch (e) {
console.error("quiz-lead: upstream error", e);
return NextResponse.json({ error: "upstream" }, { status: 502 });
}
return NextResponse.json({ ok: true });
}
+84
View File
@@ -0,0 +1,84 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import {
BURST_LIMIT, PDF_TOOL_ID, getBurstUsage, getMonthlyLimit, getMonthlyUsage, hasPaidAccess,
} from "@/lib/clean-pdf/access";
import { BlockedUrlError } from "@/lib/clean-pdf/ssrf";
import { EmptyContentError, RenderError, generateCleanPdf } from "@/lib/clean-pdf/generate";
import { safePdfFilename } from "@/lib/clean-pdf/template";
import { PDF_KEY_PREFIX } from "@/lib/clean-pdf/api-key";
export const dynamic = "force-dynamic";
const querySchema = z.object({
url: z.string().min(1).max(2000),
format: z.enum(["A4", "Letter"]).default("A4"),
theme: z.enum(["light", "dark"]).default("light"),
});
function jsonError(status: number, error: string, extra?: Record<string, string>) {
return NextResponse.json({ error }, { status, headers: extra });
}
async function resolveUserId(req: NextRequest): Promise<string | null> {
const authHeader = req.headers.get("authorization");
if (authHeader?.startsWith("Bearer ")) {
const key = authHeader.slice("Bearer ".length).trim();
if (!key.startsWith(PDF_KEY_PREFIX)) return null;
const record = await prisma.pdfApiKey.findUnique({ where: { key }, select: { userId: true } });
return record?.userId ?? null;
}
const session = await auth.api.getSession({ headers: req.headers });
return session?.user.id ?? null;
}
export async function GET(req: NextRequest) {
try {
const userId = await resolveUserId(req);
if (!userId) return jsonError(401, "Нужен API-ключ или вход в аккаунт школы");
if (!(await hasPaidAccess(userId))) {
return jsonError(403, "Инструмент доступен студентам платных курсов школы");
}
const parsed = querySchema.safeParse(Object.fromEntries(req.nextUrl.searchParams));
if (!parsed.success) return jsonError(422, "Проверьте параметры: url, format (A4|Letter), theme (light|dark)");
if ((await getBurstUsage(userId)) >= BURST_LIMIT) {
return jsonError(429, "Слишком часто: подождите минуту", { "X-Max-Uses": String(getMonthlyLimit()) });
}
const limit = getMonthlyLimit();
const used = await getMonthlyUsage(userId);
if (used >= limit) {
return jsonError(429, `Лимит ${limit} PDF в месяц исчерпан`, {
"X-Uses-Count": String(used), "X-Max-Uses": String(limit),
});
}
const { pdf, title } = await generateCleanPdf(parsed.data);
await prisma.toolUsage.create({ data: { userId, tool: PDF_TOOL_ID } });
const filename = safePdfFilename(title);
return new NextResponse(new Uint8Array(pdf), {
status: 200,
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="document.pdf"; filename*=UTF-8''${encodeURIComponent(filename)}.pdf`,
"X-Uses-Count": String(used + 1),
"X-Max-Uses": String(limit),
},
});
} catch (e) {
if (e instanceof BlockedUrlError || e instanceof EmptyContentError) {
return jsonError(422, e.message);
}
if (e instanceof RenderError) {
console.error("[clean-pdf] render", e);
return jsonError(504, "Не получилось отрендерить страницу, попробуйте позже");
}
console.error("[clean-pdf]", e);
return jsonError(504, "Не получилось сгенерировать PDF, попробуйте позже");
}
}
+2 -11
View File
@@ -3,6 +3,7 @@ import { headers } from "next/headers";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { sendQuestionFollowUpEmail, sendQuestionReplyEmail } from "@/lib/email"; import { sendQuestionFollowUpEmail, sendQuestionReplyEmail } from "@/lib/email";
import { isAllowedPublicUrl } from "@/lib/s3";
interface FileAttachment { interface FileAttachment {
name: string; name: string;
@@ -10,13 +11,6 @@ interface FileAttachment {
size: number; size: number;
} }
function buildS3Prefix(): string {
const endpoint = process.env.S3_ENDPOINT ?? "";
const bucket = process.env.S3_BUCKET ?? "";
// e.g. https://fsn1.your-objectstorage.com/lms-uploads/
return `${endpoint}/${bucket}/`;
}
export async function POST( export async function POST(
req: NextRequest, req: NextRequest,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> }
@@ -52,14 +46,11 @@ export async function POST(
return NextResponse.json({ error: "text is required" }, { status: 400 }); return NextResponse.json({ error: "text is required" }, { status: 400 });
} }
const s3Prefix = buildS3Prefix();
const safeFiles = files const safeFiles = files
?.filter( ?.filter(
(f) => (f) =>
typeof f.name === "string" && typeof f.name === "string" &&
typeof f.url === "string" && isAllowedPublicUrl(f.url) &&
f.url.startsWith("https://") &&
f.url.startsWith(s3Prefix) &&
typeof f.size === "number" typeof f.size === "number"
) )
.map((f) => ({ name: f.name.slice(0, 255), url: f.url, size: Math.max(0, f.size) })); .map((f) => ({ name: f.name.slice(0, 255), url: f.url, size: Math.max(0, f.size) }));
+2 -10
View File
@@ -3,6 +3,7 @@ import { headers } from "next/headers";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { sendQuestionCreatedEmail } from "@/lib/email"; import { sendQuestionCreatedEmail } from "@/lib/email";
import { isAllowedPublicUrl } from "@/lib/s3";
interface FileAttachment { interface FileAttachment {
name: string; name: string;
@@ -10,12 +11,6 @@ interface FileAttachment {
size: number; size: number;
} }
function buildS3Prefix(): string {
const endpoint = process.env.S3_ENDPOINT ?? "";
const bucket = process.env.S3_BUCKET ?? "";
return `${endpoint}/${bucket}/`;
}
export async function GET() { export async function GET() {
const session = await auth.api.getSession({ headers: await headers() }); const session = await auth.api.getSession({ headers: await headers() });
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
@@ -82,14 +77,11 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "title and text are required" }, { status: 400 }); return NextResponse.json({ error: "title and text are required" }, { status: 400 });
} }
const s3Prefix = buildS3Prefix();
const safeFiles = files const safeFiles = files
?.filter( ?.filter(
(f) => (f) =>
typeof f.name === "string" && typeof f.name === "string" &&
typeof f.url === "string" && isAllowedPublicUrl(f.url) &&
f.url.startsWith("https://") &&
f.url.startsWith(s3Prefix) &&
typeof f.size === "number" typeof f.size === "number"
) )
.map((f) => ({ name: f.name.slice(0, 255), url: f.url, size: Math.max(0, f.size) })); .map((f) => ({ name: f.name.slice(0, 255), url: f.url, size: Math.max(0, f.size) }));
+14 -1
View File
@@ -37,7 +37,7 @@ export async function POST(request: NextRequest) {
return jsonError("Неверный формат запроса", 400); return jsonError("Неверный формат запроса", 400);
} }
const { name, email, password, website, cfTurnstileResponse } = body; const { name, email, password, website, cfTurnstileResponse, archetype, utmSource, utmMedium, utmCampaign } = 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) {
@@ -102,6 +102,19 @@ export async function POST(request: NextRequest) {
select: { id: true }, select: { id: true },
}), }),
]); ]);
// Тип второго мозга из квиза + UTM — сохраняем на юзере для персонализации
const VALID_ARCHETYPES = ["architect", "gardener", "librarian", "pragmatist"];
if (user && (archetype || utmSource || utmMedium || utmCampaign)) {
await prisma.user.update({
where: { id: user.id },
data: {
archetype: VALID_ARCHETYPES.includes(String(archetype)) ? String(archetype) : null,
utmSource: utmSource ? String(utmSource) : null,
utmMedium: utmMedium ? String(utmMedium) : null,
utmCampaign: utmCampaign ? String(utmCampaign) : null,
},
});
}
if (user && course) { if (user && course) {
await prisma.courseEnrollment.upsert({ await prisma.courseEnrollment.upsert({
where: { userId_courseId: { userId: user.id, courseId: course.id } }, where: { userId_courseId: { userId: user.id, courseId: course.id } },
+47
View File
@@ -0,0 +1,47 @@
import { NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { randomBytes } from "crypto";
// Пишет агрегаты прогона Wrapped. userId — строго из сессии, не из тела.
// Имена/тексты заметок сюда не приходят (только числа/строки-агрегаты).
export async function POST(req: Request) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
let b: {
period?: string;
noteCount?: number;
linkCount?: number;
tagCount?: number;
topTag?: string | null;
archetype?: string | null;
score?: number;
};
try {
b = await req.json();
} catch {
return NextResponse.json({ error: "bad json" }, { status: 400 });
}
const period = ["year", "quarter", "all"].includes(String(b.period)) ? String(b.period) : "year";
const VALID = ["architect", "gardener", "librarian", "pragmatist"];
const run = await prisma.wrappedRun.create({
data: {
userId: session.user.id,
period,
noteCount: Number(b.noteCount) || 0,
linkCount: Number(b.linkCount) || 0,
tagCount: Number(b.tagCount) || 0,
topTag: b.topTag ? String(b.topTag).slice(0, 60) : null,
archetype: VALID.includes(String(b.archetype)) ? String(b.archetype) : null,
score: Math.max(0, Math.min(100, Number(b.score) || 0)),
shareToken: randomBytes(9).toString("base64url"),
},
select: { shareToken: true },
});
return NextResponse.json({ shareToken: run.shareToken });
}
@@ -108,6 +108,51 @@ export async function setReviewing(submissionId: string) {
revalidatePath(`/curator/homework/${submissionId}`); revalidatePath(`/curator/homework/${submissionId}`);
} }
/**
* Принять ДЗ без письменного ответа: ставим APPROVED и засчитываем урок,
* фидбек не создаём и студенту письмо не шлём (тихое принятие).
*/
export async function approveWithoutFeedback(submissionId: string) {
await requireCurator();
const submission = await prisma.homeworkSubmission.findUnique({
where: { id: submissionId },
include: {
homework: {
include: {
lesson: {
select: {
id: true,
module: { select: { course: { select: { slug: true } } } },
},
},
},
},
},
});
if (!submission) throw new Error("Submission not found");
const lesson = submission.homework.lesson;
await prisma.$transaction(async (tx) => {
await tx.homeworkSubmission.update({
where: { id: submissionId },
data: { status: "APPROVED", statusAt: new Date() },
});
await tx.lessonProgress.upsert({
where: { userId_lessonId: { userId: submission.userId, lessonId: lesson.id } },
create: { userId: submission.userId, lessonId: lesson.id },
update: {},
});
});
revalidatePath("/curator/homework");
revalidatePath(`/curator/homework/${submissionId}`);
revalidatePath(`/courses/${lesson.module.course.slug}/lessons/${lesson.id}`);
revalidatePath(`/courses/${lesson.module.course.slug}`);
revalidatePath("/dashboard");
}
export async function deleteSubmission(submissionId: string) { export async function deleteSubmission(submissionId: string) {
await requireCurator(); await requireCurator();
await prisma.homeworkSubmission.delete({ where: { id: submissionId } }); await prisma.homeworkSubmission.delete({ where: { id: submissionId } });
@@ -3,7 +3,7 @@
import { useState, useTransition, useRef } from "react"; import { useState, useTransition, useRef } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { submitFeedback, setReviewing } from "./actions"; import { submitFeedback, setReviewing, approveWithoutFeedback } from "./actions";
import { AudioRecorder } from "@/components/curator/audio-recorder"; import { AudioRecorder } from "@/components/curator/audio-recorder";
interface FileItem { interface FileItem {
@@ -69,6 +69,13 @@ export function FeedbackForm({
}); });
} }
function handleApproveNoFeedback() {
startTransition(async () => {
await approveWithoutFeedback(submissionId);
router.push("/curator/homework");
});
}
const isWorking = pending || uploading; const isWorking = pending || uploading;
return ( return (
@@ -164,6 +171,17 @@ export function FeedbackForm({
{pending ? "Отправка..." : "Отправить ответ"} {pending ? "Отправка..." : "Отправить ответ"}
</button> </button>
<button
type="button"
onClick={handleApproveNoFeedback}
disabled={isWorking}
className="btn-aubade px-4 py-2 text-sm"
style={{ opacity: isWorking ? 0.5 : 1 }}
title="Засчитать ДЗ без письменного ответа"
>
Принять и отметить выполненным
</button>
{currentStatus !== "REVIEWING" && ( {currentStatus !== "REVIEWING" && (
<button <button
type="button" type="button"
@@ -1,6 +1,7 @@
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { linkify } from "@/lib/linkify";
import { FeedbackForm } from "./feedback-form"; import { FeedbackForm } from "./feedback-form";
import { ContentTabs } from "./content-tabs"; import { ContentTabs } from "./content-tabs";
import { DeleteSubmissionButton } from "./delete-button"; import { DeleteSubmissionButton } from "./delete-button";
@@ -151,7 +152,7 @@ export default async function SubmissionPage({ params }: Props) {
className="px-4 py-3 text-sm whitespace-pre-wrap" className="px-4 py-3 text-sm whitespace-pre-wrap"
style={{ border: "2px solid var(--border)" }} style={{ border: "2px solid var(--border)" }}
> >
{submission.text} {linkify(submission.text)}
</div> </div>
) : ( ) : (
<p className="text-sm italic" style={{ color: "var(--muted-foreground)" }}> <p className="text-sm italic" style={{ color: "var(--muted-foreground)" }}>
+16 -3
View File
@@ -166,9 +166,22 @@ export default async function HomeworkListPage({ searchParams }: Props) {
<div className="space-y-1.5"> <div className="space-y-1.5">
{submissions.map((s) => { {submissions.map((s) => {
const hasReview = s.feedbacks.length > 0; const hasReview = s.feedbacks.length > 0;
const reviewBadge = hasReview // Метка по статусу проверки, а не только по наличию письменного отзыва:
? { label: "С отзывом", bg: "oklch(0.88 0.1 145)", color: "oklch(0.35 0.15 145)" } // принятое кнопкой ДЗ (APPROVED без фидбека) — это «Принято», не «Без ответа».
: { label: "Без ответа", bg: "var(--foreground)", color: "var(--background)" }; let reviewBadge: { label: string; bg: string; color: string };
if (s.status === "APPROVED") {
reviewBadge = {
label: hasReview ? "С отзывом" : "Принято",
bg: "oklch(0.88 0.1 145)",
color: "oklch(0.35 0.15 145)",
};
} else if (s.status === "REJECTED") {
reviewBadge = { label: "Отклонено", bg: "oklch(0.9 0.06 27)", color: "oklch(0.45 0.2 27)" };
} else if (s.status === "REVIEWING") {
reviewBadge = { label: "На рассмотрении", bg: "oklch(0.9 0.08 80)", color: "oklch(0.4 0.1 80)" };
} else {
reviewBadge = { label: "Без ответа", bg: "var(--foreground)", color: "var(--background)" };
}
return ( return (
<Link <Link
key={s.id} key={s.id}
+6 -6
View File
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { Fira_Mono } from "next/font/google"; import { Fira_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { getSettings } from "@/lib/settings"; import { getSettings } from "@/lib/settings";
import { HtmlInjector } from "@/components/layout/html-injector";
const firaMono = Fira_Mono({ const firaMono = Fira_Mono({
weight: ["400", "500", "700"], weight: ["400", "500", "700"],
@@ -28,14 +29,13 @@ export default async function RootLayout({
return ( return (
<html lang="ru" className={`${firaMono.variable} h-full antialiased`}> <html lang="ru" className={`${firaMono.variable} h-full antialiased`}>
{settings.headCode ? (
<head dangerouslySetInnerHTML={{ __html: settings.headCode }} />
) : null}
<body className="min-h-full flex flex-col"> <body className="min-h-full flex flex-col">
{/* Код внедрения инжектится через DOM после гидрации (HtmlInjector),
а НЕ через <head dangerouslySetInnerHTML> — иначе ручной <head>
затирает хойстнутый React'ом <link rel="stylesheet"> и слетает дизайн. */}
{settings.headCode ? <HtmlInjector html={settings.headCode} target="head" /> : null}
{children} {children}
{settings.bodyCode ? ( {settings.bodyCode ? <HtmlInjector html={settings.bodyCode} target="body" /> : null}
<div dangerouslySetInnerHTML={{ __html: settings.bodyCode }} />
) : null}
</body> </body>
</html> </html>
); );
+2 -1
View File
@@ -1,12 +1,13 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { WelcomePage } from "@/components/welcome/welcome-page";
export default async function HomePage() { export default async function HomePage() {
const session = await auth.api.getSession({ headers: await headers() }); const session = await auth.api.getSession({ headers: await headers() });
if (!session) { if (!session) {
redirect("/login"); return <WelcomePage />;
} }
const role = session.user.role; const role = session.user.role;
@@ -0,0 +1,42 @@
import { ImageResponse } from "next/og";
import { readFile } from "fs/promises";
import path from "path";
import { prisma } from "@/lib/prisma";
import { wrappedCard } from "@/lib/wrapped-card";
import type { Archetype, Period } from "@/app/(student)/wrapped/_client/types";
export const size = { width: 1080, height: 1920 };
export const contentType = "image/png";
export const dynamic = "force-dynamic";
// Серверный PNG 1080×1920 карточки Wrapped по shareToken (для OG-превью в соцсетях
// и для скачивания участником). Рендерится через next/og (Satori + resvg-wasm).
export default async function OG({ params }: { params: Promise<{ token: string }> }) {
const { token } = await params;
const run = await prisma.wrappedRun.findUnique({ where: { shareToken: token } });
const [reg, med] = await Promise.all([
readFile(path.join(process.cwd(), "public/fonts/FiraMono-Regular.ttf")),
readFile(path.join(process.cwd(), "public/fonts/FiraMono-Medium.ttf")),
]);
const data = run
? {
noteCount: run.noteCount,
linkCount: run.linkCount,
tagCount: run.tagCount,
topTag: run.topTag,
archetype: run.archetype as Archetype | null,
score: run.score,
period: run.period as Period,
}
: { noteCount: 0, linkCount: 0, tagCount: 0, topTag: null, archetype: null, score: 0, period: "year" as Period };
return new ImageResponse(wrappedCard(data), {
...size,
fonts: [
{ name: "Fira Mono", data: reg, weight: 400, style: "normal" as const },
{ name: "Fira Mono", data: med, weight: 500, style: "normal" as const },
],
});
}
+92
View File
@@ -0,0 +1,92 @@
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
import Link from "next/link";
import type { Metadata } from "next";
const ARCHETYPE_RU: Record<string, string> = {
architect: "Архитектор",
gardener: "Садовник",
librarian: "Библиотекарь",
pragmatist: "Прагматик",
};
export async function generateMetadata({ params }: { params: Promise<{ token: string }> }): Promise<Metadata> {
const { token } = await params;
return {
title: "Моя карта знаний в Obsidian — Second Brain",
description: "Я собрал статистику своего хранилища знаний. Сделай свою карточку.",
openGraph: {
title: "Моя карта знаний в Obsidian",
description: "Statистика хранилища в Obsidian — Second Brain Wrapped.",
images: [{ url: `/share/wrapped/${token}/opengraph-image`, width: 1080, height: 1920 }],
},
twitter: { card: "summary_large_image" },
};
}
// Публичная страница карточки (без сессии). Показывает только агрегаты — никаких имён заметок.
export default async function SharePage({ params }: { params: Promise<{ token: string }> }) {
const { token } = await params;
const run = await prisma.wrappedRun.findUnique({ where: { shareToken: token } });
if (!run) notFound();
const metric = (n: number, l: string) => (
<div
key={l}
style={{ display: "flex", flexDirection: "column", alignItems: "center", padding: "16px 12px", background: "#FFFFFF", border: "2px solid var(--border)", boxShadow: "4px 4px 0 0 var(--border)" }}
>
<span style={{ fontSize: 32, fontWeight: 700 }}>{n}</span>
<span style={{ fontSize: 12, color: "var(--muted-foreground)", textTransform: "uppercase", letterSpacing: 1 }}>{l}</span>
</div>
);
return (
<main style={{ background: "var(--background)", color: "var(--foreground)", minHeight: "100vh", display: "flex", flexDirection: "column", alignItems: "center", padding: "48px 24px" }}>
<div style={{ width: "100%", maxWidth: 480 }}>
<p style={{ fontSize: 12, fontWeight: 700, textTransform: "uppercase", letterSpacing: 3, color: "var(--muted-foreground)", marginBottom: 8 }}>
Obsidian Wrapped · 2026
</p>
<h1 style={{ fontSize: 28, fontWeight: 700, margin: "0 0 4px" }}>
{run.archetype ? ARCHETYPE_RU[run.archetype] : "Карта знаний"}
</h1>
<p style={{ fontSize: 13, textTransform: "uppercase", letterSpacing: 2, color: "var(--muted-foreground)", margin: "0 0 24px" }}>
зрелость {run.score}/100
</p>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 12, marginBottom: 20 }}>
{metric(run.noteCount, "заметок")}
{metric(run.linkCount, "связей")}
{metric(run.tagCount, "тегов")}
</div>
{run.topTag && (
<div style={{ padding: "16px 20px", background: "var(--accent)", border: "2px solid var(--foreground)", marginBottom: 24, fontSize: 16 }}>
Топ-тег: #{run.topTag}
</div>
)}
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<Link
href="/register"
className="btn-aubade btn-aubade-accent"
style={{ textAlign: "center", fontSize: 15, fontWeight: 700, padding: "14px" }}
>
Сделать свою карточку
</Link>
<a
href={`/share/wrapped/${token}/opengraph-image`}
download={`obsidian-wrapped-${token}.png`}
className="btn-aubade"
style={{ textAlign: "center", fontSize: 14, padding: "12px" }}
>
Скачать картинку (1080×1920)
</a>
</div>
<p style={{ fontSize: 12, color: "var(--muted-foreground)", marginTop: 28, textAlign: "center" }}>
🔒 Здесь только числа. Содержимое заметок остаётся на устройстве автора.
</p>
</div>
</main>
);
}
+5
View File
@@ -274,6 +274,11 @@ export function SettingsForm({ initial }: { initial: Settings }) {
checked={bool("notifyOnHomework")} checked={bool("notifyOnHomework")}
onChange={(v) => set("notifyOnHomework", v ? "true" : "false")} onChange={(v) => set("notifyOnHomework", v ? "true" : "false")}
/> />
<Toggle
label="Уведомлять о новом комментарии под уроком"
checked={bool("notifyOnComment")}
onChange={(v) => set("notifyOnComment", v ? "true" : "false")}
/>
<Toggle <Toggle
label="Уведомлять о новой регистрации ученика" label="Уведомлять о новой регистрации ученика"
checked={bool("notifyOnRegistration")} checked={bool("notifyOnRegistration")}
+44
View File
@@ -0,0 +1,44 @@
"use client";
import { useEffect } from "react";
/**
* Безопасно инжектит произвольный HTML (код внедрения из Настроек) в <head> или
* <body> ЧЕРЕЗ DOM после гидрации.
*
* Почему не <head dangerouslySetInnerHTML>: в App Router + React 19 ручной <head>
* при гидрации затирает хойстнутый React'ом <link rel="stylesheet"> → весь дизайн
* слетает. Этот компонент добавляет узлы программно, не трогая управление head,
* поэтому стили остаются на месте. <script> из innerHTML не исполняются — поэтому
* пересоздаём их как настоящие script-элементы.
*/
export function HtmlInjector({ html, target }: { html: string; target: "head" | "body" }) {
useEffect(() => {
if (!html) return;
const dest = target === "head" ? document.head : document.body;
const tpl = document.createElement("template");
tpl.innerHTML = html;
const added: Node[] = [];
Array.from(tpl.content.childNodes).forEach((node) => {
let toAppend: Node;
if (node.nodeName === "SCRIPT") {
const orig = node as HTMLScriptElement;
const s = document.createElement("script");
for (const attr of Array.from(orig.attributes)) s.setAttribute(attr.name, attr.value);
s.textContent = orig.textContent;
toAppend = s;
} else {
toAppend = node.cloneNode(true);
}
dest.appendChild(toAppend);
added.push(toAppend);
});
return () => {
added.forEach((n) => n.parentNode?.removeChild(n));
};
}, [html, target]);
return null;
}
@@ -0,0 +1,68 @@
// Персональный блок «твой путь» по типу второго мозга из квиза.
// Server component, без клиентского кода. ДС-2 (Aubade).
const TRIPWIRE_URL = "https://second-brain.ru/entrepreneur";
const ARCHETYPE_INFO: Record<
string,
{ emoji: string; name: string; desc: string; cta: string; href: string }
> = {
architect: {
emoji: "🏛",
name: "Архитектор",
desc: "Ты мыслишь системами: структура, иерархия, порядок. Тебе подойдёт системный путь.",
cta: "Полный курс «Obsidian»",
href: "https://obsidian.second-brain.ru/",
},
gardener: {
emoji: "🌱",
name: "Садовник",
desc: "Ты выращиваешь идеи и связи. Тебе подойдёт работа с мышлением и AI.",
cta: "Курс «Obsidian + AI»",
href: "https://obsidianai.second-brain.ru/",
},
librarian: {
emoji: "📚",
name: "Библиотекарь",
desc: "Ты собираешь и хранишь надёжно. Начни с готового хранилища и руководств.",
cta: "Демо-хранилище",
href: "https://second-brain.ru/sb-vault",
},
pragmatist: {
emoji: "⚡",
name: "Прагматик",
desc: "Тебе важен результат: заметки работают на дело, а не ради заметок.",
cta: "Второй мозг для предпринимателя",
href: TRIPWIRE_URL,
},
};
export function ArchetypeBanner({ archetype }: { archetype: string }) {
const info = ARCHETYPE_INFO[archetype];
if (!info) return null;
return (
<div className="card-aubade p-5 mb-8" style={{ background: "var(--accent)" }}>
<p
className="text-xs font-bold uppercase tracking-widest mb-1"
style={{ color: "var(--muted-foreground)" }}
>
Твой тип второго мозга
</p>
<h2 className="font-bold text-lg mb-1">
{info.emoji} {info.name}
</h2>
<p className="text-sm mb-3" style={{ color: "var(--foreground)" }}>
{info.desc} Начни с бесплатного курса «Obsidian. Старт» ниже а когда захочешь дальше, тебе откроется:
</p>
<a
href={info.href}
target="_blank"
rel="noopener noreferrer"
className="btn-aubade text-sm inline-block"
>
{info.cta}
</a>
</div>
);
}
@@ -0,0 +1,20 @@
"use client";
import { useEffect } from "react";
declare global {
interface Window {
ym?: (counterId: number, action: string, goal: string) => void;
}
}
// Фиксирует показ gate-лестницы в Яндекс.Метрике (цель gate_view).
export function GateViewTracker() {
useEffect(() => {
const c = Number(process.env.NEXT_PUBLIC_METRIKA_COUNTER_ID);
if (c && typeof window !== "undefined" && typeof window.ym === "function") {
window.ym(c, "reachGoal", "gate_view");
}
}, []);
return null;
}
+47 -5
View File
@@ -3,6 +3,7 @@
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { submitHomework } from "@/lib/actions/student-actions"; import { submitHomework } from "@/lib/actions/student-actions";
import { AudioRecorder } from "@/components/curator/audio-recorder"; import { AudioRecorder } from "@/components/curator/audio-recorder";
import { linkify } from "@/lib/linkify";
interface HWFile { name: string; url: string; size: number } interface HWFile { name: string; url: string; size: number }
@@ -21,6 +22,7 @@ interface Submission {
files: HWFile[]; files: HWFile[];
audioUrl?: string | null; audioUrl?: string | null;
submittedAt: Date; submittedAt: Date;
status: string;
feedbacks: Feedback[]; feedbacks: Feedback[];
} }
@@ -47,6 +49,9 @@ export function HomeworkSection({ homework, submission, slug, lessonId, allowAud
const [editing, setEditing] = useState(!submission); const [editing, setEditing] = useState(!submission);
const isReviewed = submission && submission.feedbacks.length > 0; const isReviewed = submission && submission.feedbacks.length > 0;
const isApproved = submission?.status === "APPROVED";
// Финализировано: есть фидбек ИЛИ ДЗ принято кнопкой без ответа. Нельзя пересдавать.
const isLocked = Boolean(isReviewed || isApproved);
const inputStyle = { const inputStyle = {
border: "2px solid var(--border)", border: "2px solid var(--border)",
@@ -95,7 +100,7 @@ export function HomeworkSection({ homework, submission, slug, lessonId, allowAud
<div className="space-y-2"> <div className="space-y-2">
<p className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>Ваш ответ</p> <p className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>Ваш ответ</p>
<div className="px-4 py-3 text-sm whitespace-pre-wrap opacity-70" style={{ border: "2px solid var(--border)" }}> <div className="px-4 py-3 text-sm whitespace-pre-wrap opacity-70" style={{ border: "2px solid var(--border)" }}>
{submission!.text || "—"} {submission!.text ? linkify(submission!.text) : "—"}
</div> </div>
{submission!.audioUrl && ( {submission!.audioUrl && (
<div className="px-4 py-2" style={{ border: "1px solid var(--border)" }}> <div className="px-4 py-2" style={{ border: "1px solid var(--border)" }}>
@@ -114,7 +119,7 @@ export function HomeworkSection({ homework, submission, slug, lessonId, allowAud
{fb.curator.name} · {new Date(fb.createdAt).toLocaleDateString("ru-RU")} {fb.curator.name} · {new Date(fb.createdAt).toLocaleDateString("ru-RU")}
</span> </span>
</div> </div>
<p className="text-sm whitespace-pre-wrap">{fb.text}</p> <p className="text-sm whitespace-pre-wrap">{linkify(fb.text)}</p>
{fbFiles.length > 0 && ( {fbFiles.length > 0 && (
<div className="space-y-1"> <div className="space-y-1">
{fbFiles.map((f) => ( {fbFiles.map((f) => (
@@ -138,8 +143,45 @@ export function HomeworkSection({ homework, submission, slug, lessonId, allowAud
</div> </div>
)} )}
{/* Принято без письменного ответа */}
{isApproved && !isReviewed && submission && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<span className="text-xs px-2 py-0.5 font-bold uppercase tracking-widest" style={{ border: "2px solid var(--foreground)", background: "var(--accent)" }}>
ДЗ принято
</span>
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
Сдано {new Date(submission.submittedAt).toLocaleDateString("ru-RU")}
</span>
</div>
{submission.text && (
<div className="px-4 py-3 text-sm whitespace-pre-wrap opacity-70" style={{ border: "2px solid var(--border)" }}>
{linkify(submission.text)}
</div>
)}
{submission.audioUrl && (
<div className="px-4 py-2" style={{ border: "1px solid var(--border)" }}>
<p className="text-xs mb-1" style={{ color: "var(--muted-foreground)" }}>Аудио-ответ:</p>
<audio controls src={submission.audioUrl} style={{ height: 32, width: "100%" }} />
</div>
)}
{submission.files.length > 0 && (
<div className="space-y-1">
{submission.files.map((f) => (
<a key={f.url} href={f.url} target="_blank" rel="noopener noreferrer"
className="flex items-center gap-2 px-3 py-2 text-xs" style={{ border: "2px solid var(--border)" }}>
<span>📎</span>
<span className="flex-1 underline">{f.name}</span>
<span style={{ color: "var(--muted-foreground)" }}>{formatSize(f.size)}</span>
</a>
))}
</div>
)}
</div>
)}
{/* Submitted, pending review */} {/* Submitted, pending review */}
{submission && !isReviewed && !editing && ( {submission && !isLocked && !editing && (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -156,7 +198,7 @@ export function HomeworkSection({ homework, submission, slug, lessonId, allowAud
</div> </div>
{submission.text && ( {submission.text && (
<div className="px-4 py-3 text-sm whitespace-pre-wrap" style={{ border: "2px solid var(--border)" }}> <div className="px-4 py-3 text-sm whitespace-pre-wrap" style={{ border: "2px solid var(--border)" }}>
{submission.text} {linkify(submission.text)}
</div> </div>
)} )}
{submission.audioUrl && ( {submission.audioUrl && (
@@ -182,7 +224,7 @@ export function HomeworkSection({ homework, submission, slug, lessonId, allowAud
)} )}
{/* Form */} {/* Form */}
{editing && !isReviewed && ( {editing && !isLocked && (
<div className="space-y-3"> <div className="space-y-3">
<textarea <textarea
value={text} value={text}
@@ -0,0 +1,372 @@
"use client";
import { useEffect, useId, useRef, useState, type CSSProperties } from "react";
import {
PAY_METHODS_URL,
PAY_ORDER_URL,
type StoreCatalogEntry,
} from "@/lib/store-catalog";
export interface LockedCourse {
id: string;
slug: string;
title: string;
description: string | null;
coverImage: string | null;
}
interface PayMethod {
id: string;
label: string;
provider: string;
kind: "online" | "manual";
}
interface Props {
course: LockedCourse;
entry: StoreCatalogEntry;
buyer: { name: string; email: string };
onClose: () => void;
}
function formatRub(v: number) {
return `${v.toLocaleString("ru-RU")}`;
}
const inputStyle: CSSProperties = {
width: "100%",
border: "2px solid var(--border)",
background: "var(--background)",
color: "var(--foreground)",
padding: "0.5rem 0.6rem",
fontSize: "14px",
fontFamily: "inherit",
outline: "none",
};
export function StoreCheckoutModal({ course, entry, buyer, onClose }: Props) {
const [selectedSku, setSelectedSku] = useState(entry.tariffs[0].sku);
const [methods, setMethods] = useState<PayMethod[] | null>(null);
const [method, setMethod] = useState("robokassa");
const [otherOpen, setOtherOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const dialogRef = useRef<HTMLDivElement>(null);
const titleId = useId();
const multiTariff = entry.tariffs.length > 1;
const currentMethod = methods?.find((m) => m.id === method);
// Payment methods dropdown, same source as the landings. On any failure the
// hidden method=robokassa keeps the checkout fully functional.
useEffect(() => {
fetch(PAY_METHODS_URL, { signal: AbortSignal.timeout(5000) })
.then((r) => (r.ok ? r.json() : Promise.reject(new Error("bad status"))))
.then((data: { methods?: PayMethod[] }) => {
const ms = data?.methods ?? [];
if (ms.length >= 2) {
setMethods(ms);
setMethod((cur) => (cur === "robokassa" ? ms[0].id : cur));
}
})
.catch(() => {});
}, []);
// Close on Escape
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onClose]);
// Lock body scroll while the modal is open
useEffect(() => {
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, []);
// Focus the dialog on open, restore focus on close
useEffect(() => {
const prev = document.activeElement as HTMLElement | null;
dialogRef.current?.focus();
return () => prev?.focus();
}, []);
// Coming back from the payment page via bfcache must not leave the
// submit button stuck in the "submitting" state.
useEffect(() => {
const onPageShow = (e: PageTransitionEvent) => {
if (e.persisted) setSubmitting(false);
};
window.addEventListener("pageshow", onPageShow);
return () => window.removeEventListener("pageshow", onPageShow);
}, []);
// Minimal focus trap: keep Tab cycling inside the dialog
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key !== "Tab" || !dialogRef.current) return;
const focusables = dialogRef.current.querySelectorAll<HTMLElement>(
'button, [href], input, select, [tabindex]:not([tabindex="-1"])'
);
if (focusables.length === 0) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
const labelClass = "block text-xs font-bold uppercase tracking-widest mb-1.5";
const primary = methods?.[0];
const rest = methods?.slice(1) ?? [];
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center px-4"
style={{ background: "rgba(50,50,50,0.45)" }}
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
tabIndex={-1}
onKeyDown={handleKeyDown}
className="w-full max-w-md max-h-[90dvh] overflow-y-auto p-6 space-y-4"
style={{
background: "var(--background)",
border: "2px solid var(--foreground)",
boxShadow: "6px 6px 0 0 var(--foreground)",
}}
>
{/* Header */}
<div className="flex items-start justify-between gap-2">
<p
className="text-xs font-bold uppercase tracking-widest"
style={{ color: "var(--muted-foreground)" }}
>
Покупка курса
</p>
<button
type="button"
onClick={onClose}
aria-label="Закрыть"
className="-mt-3 -mr-3 flex h-11 w-11 items-center justify-center"
style={{ color: "var(--muted-foreground)", fontSize: "22px", lineHeight: 1 }}
>
×
</button>
</div>
<div className="flex items-start gap-3">
{course.coverImage ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={course.coverImage}
alt=""
className="w-24 aspect-video object-cover flex-shrink-0"
style={{ border: "2px solid var(--border)" }}
/>
) : (
<div
className="w-24 aspect-video flex items-center justify-center text-xl flex-shrink-0"
style={{ backgroundColor: "var(--color-surface)", border: "2px solid var(--border)" }}
>
📚
</div>
)}
<h2 id={titleId} className="font-bold text-lg leading-tight">
{course.title}
</h2>
</div>
{course.description && (
<p className="text-sm line-clamp-4" style={{ color: "var(--muted-foreground)" }}>
{course.description}
</p>
)}
<form method="POST" action={PAY_ORDER_URL} onSubmit={() => setSubmitting(true)}>
<input type="hidden" name="name" value={buyer.name} />
<input type="hidden" name="email" value={buyer.email} />
<input type="hidden" name="method" value={method} />
{!multiTariff && <input type="hidden" name="sku" value={entry.tariffs[0].sku} />}
{/* Tariff */}
<div className="mb-4">
<span className={labelClass} style={{ color: "var(--muted-foreground)" }}>
Тариф
</span>
{multiTariff ? (
<div className="space-y-2">
{entry.tariffs.map((t) => (
<label
key={t.sku}
className="flex items-start gap-3 p-3 cursor-pointer min-h-[44px]"
style={{
border:
"2px solid " +
(selectedSku === t.sku ? "var(--foreground)" : "var(--border)"),
background: selectedSku === t.sku ? "var(--accent)" : "transparent",
}}
>
<input
type="radio"
name="sku"
value={t.sku}
checked={selectedSku === t.sku}
onChange={() => setSelectedSku(t.sku)}
className="mt-1"
style={{ accentColor: "var(--foreground)" }}
/>
<span className="flex-1">
<span className="flex items-baseline justify-between gap-2">
<span className="text-sm font-bold">{t.label}</span>
<span className="text-sm font-bold whitespace-nowrap">
{formatRub(t.priceRub)}
</span>
</span>
{t.includes && (
<span
className="block text-xs mt-0.5"
style={{ color: "var(--muted-foreground)" }}
>
{t.includes}
</span>
)}
</span>
</label>
))}
</div>
) : (
<p className="font-bold text-lg">{formatRub(entry.tariffs[0].priceRub)}</p>
)}
</div>
{/* Payment method — rendered only when the list actually loaded */}
{methods && primary && rest.length > 0 && (
<div className="mb-4">
<span className={labelClass} style={{ color: "var(--muted-foreground)" }}>
Способ оплаты
</span>
<div className="space-y-2">
<label className="flex items-center gap-3 p-2 cursor-pointer min-h-[44px]">
<input
type="radio"
name="method_ui"
checked={!otherOpen}
onChange={() => {
setOtherOpen(false);
setMethod(primary.id);
}}
style={{ accentColor: "var(--foreground)" }}
/>
<span className="text-sm">
<span className="font-bold">{primary.label}</span>{" "}
<span style={{ color: "var(--muted-foreground)" }}>{primary.provider}</span>
</span>
</label>
<label className="flex items-center gap-3 p-2 cursor-pointer min-h-[44px]">
<input
type="radio"
name="method_ui"
checked={otherOpen}
onChange={() => {
setOtherOpen(true);
setMethod(rest[0].id);
}}
style={{ accentColor: "var(--foreground)" }}
/>
<span className="text-sm">Другой способ</span>
</label>
{otherOpen && (
<select
value={method}
onChange={(e) => setMethod(e.target.value)}
aria-label="Другой способ оплаты"
style={inputStyle}
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
>
{rest.map((m) => (
<option key={m.id} value={m.id}>
{m.label} {m.provider}
</option>
))}
</select>
)}
{currentMethod?.kind === "manual" && (
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
Счёт выставим вручную доступ откроется после подтверждения оплаты.
</p>
)}
</div>
</div>
)}
{/* Promo code */}
<div className="mb-4">
<label
className={labelClass}
style={{ color: "var(--muted-foreground)" }}
htmlFor={`${titleId}-promo`}
>
Промокод
</label>
<input
id={`${titleId}-promo`}
type="text"
name="promo_code"
maxLength={40}
autoComplete="off"
placeholder="Если есть"
style={inputStyle}
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
/>
</div>
<p className="text-xs mb-3" style={{ color: "var(--muted-foreground)" }}>
Доступ придёт на <span className="font-bold">{buyer.email}</span>
</p>
<button
type="submit"
disabled={submitting}
className="btn-aubade btn-aubade-accent w-full min-h-[44px] text-sm"
>
{submitting ? "Переходим к оплате…" : "Перейти к оплате →"}
</button>
</form>
{/* Footer */}
<div className="space-y-2 pt-1">
<a
href={entry.landingUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm underline inline-block"
style={{ color: "var(--foreground)" }}
>
Подробнее о курсе на сайте
</a>
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
Сумма на странице оплаты отобразится в тенге (). Доступ откроется автоматически
после оплаты.
</p>
</div>
</div>
</div>
);
}
+124
View File
@@ -0,0 +1,124 @@
"use client";
import { useState } from "react";
import { STORE_CATALOG } from "@/lib/store-catalog";
import { StoreCheckoutModal, type LockedCourse } from "./store-checkout-modal";
interface Props {
courses: LockedCourse[];
buyer: { name: string; email: string };
}
// Blurred card body shared by both the modal-trigger button and the
// fallback landing link.
function CardBody({ course }: { course: LockedCourse }) {
return (
<>
{/* Overlay: замок + призыв, поверх размытого контента */}
<div
className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 transition-opacity"
style={{ backgroundColor: "color-mix(in srgb, var(--background) 55%, transparent)" }}
>
<span className="text-2xl">🔒</span>
<span
className="text-sm font-bold px-4 py-2"
style={{
border: "2px solid var(--foreground)",
backgroundColor: "var(--accent)",
color: "var(--foreground)",
}}
>
Подробнее
</span>
</div>
{/* Размытый контент карточки */}
<div
className="flex flex-col flex-1"
style={{ filter: "blur(1.8px)", opacity: 0.82, pointerEvents: "none" }}
>
{course.coverImage ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={course.coverImage}
alt={course.title}
className="w-full aspect-video object-cover"
/>
) : (
<div
className="w-full aspect-video flex items-center justify-center text-4xl"
style={{ backgroundColor: "var(--color-surface)" }}
>
📚
</div>
)}
<div className="p-4 flex-1 flex flex-col gap-2">
<h2 className="font-bold text-lg leading-tight">{course.title}</h2>
{course.description && (
<p className="text-sm line-clamp-3" style={{ color: "var(--muted-foreground)" }}>
{course.description}
</p>
)}
</div>
</div>
</>
);
}
export function StoreShowcase({ courses, buyer }: Props) {
const [selectedSlug, setSelectedSlug] = useState<string | null>(null);
const selected = courses.find((c) => c.slug === selectedSlug);
const selectedEntry = selected ? STORE_CATALOG[selected.slug] : undefined;
const cardClass = "card-aubade p-0 overflow-hidden flex flex-col relative group";
return (
<div className="mt-10">
<p
className="text-xs font-bold uppercase tracking-widest mb-3"
style={{ color: "var(--muted-foreground)" }}
>
Другие курсы Second Brain
</p>
<div className="grid gap-4 sm:grid-cols-2">
{courses.map((course) => {
const entry = STORE_CATALOG[course.slug];
if (!entry || entry.tariffs.length === 0) {
// Fallback: no catalog entry → keep the old landing link
return (
<a
key={course.id}
href={entry?.landingUrl ?? "https://second-brain.ru"}
target="_blank"
rel="noopener noreferrer"
className={cardClass}
>
<CardBody course={course} />
</a>
);
}
return (
<button
key={course.id}
type="button"
aria-haspopup="dialog"
onClick={() => setSelectedSlug(course.slug)}
className={`${cardClass} text-left w-full cursor-pointer`}
>
<CardBody course={course} />
</button>
);
})}
</div>
{selected && selectedEntry && (
<StoreCheckoutModal
course={selected}
entry={selectedEntry}
buyer={buyer}
onClose={() => setSelectedSlug(null)}
/>
)}
</div>
);
}
@@ -0,0 +1,48 @@
// Gate-лестница после wow-урока (ежедневные заметки). Двойной оффер:
// основной — трипвайр «Второй мозг для предпринимателя», вторичный — полный курс.
// Server component, ДС-2 (Aubade).
import { GateViewTracker } from "./gate-view-tracker";
const TRIPWIRE_URL = "https://second-brain.ru/entrepreneur";
const FULL_COURSE_URL = "https://obsidian.second-brain.ru/";
export function TripwireGateBanner() {
return (
<div
className="card-aubade p-6 mt-10"
style={{ background: "var(--accent)", boxShadow: "4px 4px 0 0 var(--foreground)" }}
>
<GateViewTracker />
<p
className="text-xs font-bold uppercase tracking-widest mb-2"
style={{ color: "var(--muted-foreground)" }}
>
Ты освоил главную привычку
</p>
<h2 className="font-bold text-xl mb-2">🎯 Что дальше?</h2>
<p className="text-sm mb-4" style={{ color: "var(--foreground)" }}>
Ежедневные заметки это фундамент второго мозга. Дальше система начинает
работать на тебя. Выбери свой следующий шаг:
</p>
<div className="flex flex-col sm:flex-row gap-3">
<a
href={TRIPWIRE_URL}
target="_blank"
rel="noopener noreferrer"
className="btn-aubade btn-aubade-accent text-sm flex-1 text-center font-bold"
>
Второй мозг для предпринимателя
</a>
<a
href={FULL_COURSE_URL}
target="_blank"
rel="noopener noreferrer"
className="btn-aubade text-sm flex-1 text-center"
>
🏛 Полный курс «Obsidian»
</a>
</div>
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
import { CopyButton } from "./CopyButton";
import type { ToolId } from "@/lib/tools/_shared/types";
export function CodeOutput({ code, tool, label }: { code: string; tool: ToolId; label?: string }) {
return (
<div className="flex flex-col gap-2">
{label && <div className="text-xs font-bold tracking-wide" style={{ color: "var(--muted-foreground)" }}>{label}</div>}
<pre
className="overflow-auto p-3 text-sm"
style={{
fontFamily: "var(--font-mono, 'Fira Mono', monospace)",
border: "1px solid var(--border)",
borderRadius: "2px",
backgroundColor: "var(--background)",
color: "var(--foreground)",
}}
>{code}</pre>
<div><CopyButton text={code} tool={tool} /></div>
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
"use client";
import { useState } from "react";
import { Copy, Check } from "lucide-react";
import { logToolUsage } from "@/lib/actions/tool-usage";
import type { ToolId } from "@/lib/tools/_shared/types";
export function CopyButton({ text, tool }: { text: string; tool: ToolId }) {
const [copied, setCopied] = useState(false);
async function onCopy() {
try {
await navigator.clipboard.writeText(text);
} catch {
return; // буфер недоступен (нет разрешения / insecure context) — молча
}
setCopied(true);
logToolUsage({ tool }).catch(() => {}); // аналитика — best-effort, не роняем UI
setTimeout(() => setCopied(false), 1500);
}
return (
<button type="button" onClick={onCopy} className="btn-aubade btn-aubade-accent inline-flex items-center gap-2 text-sm">
{copied ? <Check size={16} /> : <Copy size={16} />}
{copied ? "Скопировано" : "Скопировать"}
</button>
);
}
+27
View File
@@ -0,0 +1,27 @@
import Link from "next/link";
import { MessageSquareQuote, FileCode2, Palette, SlidersHorizontal, Table2, Database, FileText, Wrench, type LucideIcon } from "lucide-react";
import type { ToolMeta } from "@/lib/tools/_shared/types";
// Явная карта (не `import * as Icons`) — иначе весь набор lucide попадает в бандл.
const ICONS: Record<string, LucideIcon> = {
MessageSquareQuote,
FileCode2,
Palette,
SlidersHorizontal,
Table2,
Database,
FileText,
};
export function ToolCard({ tool }: { tool: ToolMeta }) {
const Icon = ICONS[tool.icon] ?? Wrench;
return (
<Link href={`/tools/${tool.id}`} className="card-aubade p-4 flex flex-col gap-2 no-underline">
<div className="flex items-center gap-2" style={{ color: "var(--foreground)" }}>
<Icon size={20} />
<span className="font-bold">{tool.title}</span>
</div>
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>{tool.description}</p>
</Link>
);
}
+26
View File
@@ -0,0 +1,26 @@
import Link from "next/link";
import { Wrench } from "lucide-react";
// Промо-карточка Toolbox для раздела «Мои курсы» — точка входа в /tools.
export function ToolboxPromoCard() {
return (
<Link
href="/tools"
className="card-aubade p-5 flex items-center gap-4 no-underline"
style={{ background: "var(--accent)" }}
>
<Wrench size={28} style={{ color: "var(--foreground)", flexShrink: 0 }} />
<div className="flex-1">
<h2 className="font-bold text-lg leading-tight" style={{ color: "var(--foreground)" }}>
Инструменты Obsidian
</h2>
<p className="text-sm" style={{ color: "var(--foreground)" }}>
Генераторы синтаксиса: callout, frontmatter, темы, Style Settings, Dataview, Bases.
</p>
</div>
<span className="text-sm font-bold whitespace-nowrap" style={{ color: "var(--foreground)" }}>
Открыть
</span>
</Link>
);
}
@@ -0,0 +1,56 @@
"use client";
import { useEffect, useRef } from "react";
// Draws 2px rays from the edge of every [data-scheme-card] to the central
// [data-scheme-logo] circle. Redraws on container resize and after fonts load.
export function SchemeConnectors() {
const ref = useRef<SVGSVGElement>(null);
useEffect(() => {
const svg = ref.current;
const scheme = svg?.parentElement;
if (!svg || !scheme) return;
function draw() {
if (!svg || !scheme) return;
const logo = scheme.querySelector<HTMLElement>("[data-scheme-logo]");
if (!logo) return;
const s = scheme.getBoundingClientRect();
const l = logo.getBoundingClientRect();
const cx = l.left + l.width / 2 - s.left;
const cy = l.top + l.height / 2 - s.top;
const r = l.width / 2 + 4;
svg.setAttribute("viewBox", `0 0 ${s.width} ${s.height}`);
let lines = "";
scheme.querySelectorAll<HTMLElement>("[data-scheme-card]").forEach((card) => {
const c = card.getBoundingClientRect();
const isLeft = card.dataset.schemeCard === "left";
const x1 = (isLeft ? c.right : c.left) - s.left;
const y1 = c.top + c.height / 2 - s.top;
const dx = cx - x1;
const dy = cy - y1;
const d = Math.hypot(dx, dy) || 1;
const x2 = cx - (dx / d) * r;
const y2 = cy - (dy / d) * r;
lines += `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" />`;
});
svg.innerHTML = lines;
}
draw();
const ro = new ResizeObserver(draw);
ro.observe(scheme);
document.fonts?.ready.then(draw).catch(() => {});
return () => ro.disconnect();
}, []);
return (
<svg
ref={ref}
aria-hidden="true"
className="hidden md:block absolute inset-0 w-full h-full pointer-events-none"
style={{ stroke: "var(--border)", strokeWidth: 2 }}
/>
);
}
+195
View File
@@ -0,0 +1,195 @@
// Public landing shown on "/" for unauthenticated visitors.
// DS-2 rework of the old platform's "second brain" infographic.
import Link from "next/link";
import {
StickyNote,
BookOpen,
Lightbulb,
Database,
GraduationCap,
Wrench,
type LucideIcon,
} from "lucide-react";
import { SchemeConnectors } from "./scheme-connectors";
interface SchemeBlock {
icon: LucideIcon;
title: string;
items: string[];
}
const BLOCKS_LEFT: SchemeBlock[] = [
{
icon: StickyNote,
title: "Заметки",
items: [
"разбираемся, как создавать качественные заметки, а не просто писать всё подряд",
"изучаем самые эффективные подходы в заметкоделии и заметковедении",
],
},
{
icon: BookOpen,
title: "Знания",
items: [
"превращаем сырые данные и разрозненную информацию в устойчивые знания, готовые к повторному применению",
],
},
{
icon: Lightbulb,
title: "Идеи",
items: [
"фиксируем, развиваем и воплощаем гениальные идеи в жизнь",
"и не даём им сгинуть в процессе",
],
},
];
const BLOCKS_RIGHT: SchemeBlock[] = [
{
icon: Database,
title: "Базы знаний",
items: [
"создаём картотеку, строим структуру, наполняем её качественными заметками",
"связываем отдельные элементы в единую сеть идей и размышлений",
],
},
{
icon: GraduationCap,
title: "Обучение",
items: [
"учимся правильно учиться, осваиваем эффективные стратегии",
"выстраиваем свой индивидуальный трек развития",
],
},
{
icon: Wrench,
title: "Инструменты",
items: [
"разбираемся с особенностями инструментов и выбираем самые подходящие",
],
},
];
const ROW_START = ["md:row-start-1", "md:row-start-2", "md:row-start-3"];
function SchemeCard({ block, side, row }: { block: SchemeBlock; side: "left" | "right"; row: number }) {
const Icon = block.icon;
const colClass = side === "left" ? "md:col-start-1" : "md:col-start-3";
return (
<div
data-scheme-card={side}
className={`card-aubade relative z-[1] p-5 ${colClass} ${ROW_START[row]}`}
>
<div className="flex items-center gap-3 mb-3">
<span
className="inline-flex items-center justify-center w-10 h-10 flex-shrink-0"
style={{ backgroundColor: "var(--accent)", border: "2px solid var(--foreground)" }}
>
<Icon size={22} style={{ color: "var(--foreground)" }} />
</span>
<span className="font-bold uppercase" style={{ fontSize: "16px", letterSpacing: "0.08em" }}>
{block.title}
</span>
</div>
<ul className="space-y-2">
{block.items.map((item) => (
<li
key={item}
className="relative pl-4 leading-normal"
style={{ fontSize: "14.5px" }}
>
<span className="absolute left-0" style={{ color: "var(--muted-foreground)" }}>
</span>
{item}
</li>
))}
</ul>
</div>
);
}
export function WelcomePage() {
return (
<div className="min-h-screen flex flex-col">
<header
className="flex flex-wrap items-center justify-between gap-3 px-4 sm:px-8 py-3.5"
style={{ borderBottom: "2px solid var(--border)" }}
>
<span className="font-bold text-lg whitespace-nowrap">Second Brain</span>
<nav className="flex gap-3">
<Link href="/login" className="btn-aubade text-sm whitespace-nowrap">
Войти
</Link>
<Link href="/register" className="btn-aubade btn-aubade-accent text-sm whitespace-nowrap">
Зарегистрироваться
</Link>
</nav>
</header>
<main className="flex-1 w-full max-w-[1240px] mx-auto px-4 sm:px-8 pt-10 md:pt-14 pb-16">
<p
className="text-center text-xs font-bold uppercase mb-3.5"
style={{ color: "var(--muted-foreground)", letterSpacing: "0.18em" }}
>
Образовательная платформа Second Brain
</p>
<h1 className="text-center font-bold leading-tight text-[26px] md:text-[40px] mb-2.5">
Создаём и развиваем
<br />
второй мозг
</h1>
<p
className="text-center mb-8 md:mb-14"
style={{ color: "var(--muted-foreground)", fontSize: "17px" }}
>
Заметки, знания и идеи в одной системе, которая работает на вас
</p>
<div className="relative grid grid-cols-1 md:grid-cols-[1fr_320px_1fr] gap-y-5 md:gap-y-7 gap-x-11 items-center">
<SchemeConnectors />
<div className="md:col-start-2 md:row-start-1 md:row-span-3 flex flex-col items-center gap-4 mb-4 md:mb-0">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src="/logo.svg"
alt="Second Brain"
data-scheme-logo
className="w-[150px] h-[150px] md:w-[280px] md:h-[280px]"
/>
<span
className="text-xs font-bold uppercase"
style={{ color: "var(--muted-foreground)", letterSpacing: "0.18em" }}
>
Second Brain
</span>
</div>
{BLOCKS_LEFT.map((block, i) => (
<SchemeCard key={block.title} block={block} side="left" row={i} />
))}
{BLOCKS_RIGHT.map((block, i) => (
<SchemeCard key={block.title} block={block} side="right" row={i} />
))}
</div>
<div className="flex flex-col sm:flex-row justify-center gap-4 mt-10 md:mt-14">
<Link href="/register" className="btn-aubade btn-aubade-accent px-6 py-2.5">
Начать учиться
</Link>
<Link href="/login" className="btn-aubade px-6 py-2.5">
У меня уже есть доступ
</Link>
</div>
</main>
<footer
className="flex flex-col sm:flex-row justify-between gap-1.5 px-4 sm:px-8 py-4 text-sm"
style={{ borderTop: "2px solid var(--border)", color: "var(--muted-foreground)" }}
>
<span>ИП Second Brain Production</span>
<a href="https://second-brain.ru" className="underline" style={{ color: "var(--muted-foreground)" }}>
second-brain.ru все курсы и материалы
</a>
</footer>
</div>
);
}
+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 };
}
+23 -3
View File
@@ -4,7 +4,7 @@ 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 } from "@/lib/email"; import { sendHomeworkSubmittedEmail, sendCommentNotificationEmail } from "@/lib/email";
import { getSettings, parseNotificationEmails, asBool } from "@/lib/settings"; import { getSettings, parseNotificationEmails, asBool } from "@/lib/settings";
// ── Lesson Progress ─────────────────────────────────────────────────────────── // ── Lesson Progress ───────────────────────────────────────────────────────────
@@ -56,7 +56,7 @@ export async function submitHomework(
include: { feedbacks: true }, include: { feedbacks: true },
}); });
if (existing?.feedbacks && existing.feedbacks.length > 0) { if ((existing?.feedbacks && existing.feedbacks.length > 0) || existing?.status === "APPROVED") {
throw new Error("Работа уже проверена"); throw new Error("Работа уже проверена");
} }
@@ -156,7 +156,7 @@ export async function addComment(lessonId: string, slug: string, text: string, p
const lesson = await prisma.lesson.findUnique({ const lesson = await prisma.lesson.findUnique({
where: { id: lessonId }, where: { id: lessonId },
select: { module: { select: { course: { select: { id: true } } } } }, select: { title: true, module: { select: { course: { select: { id: true, slug: true } } } } },
}); });
if (!lesson) throw new Error("Lesson not found"); if (!lesson) throw new Error("Lesson not found");
@@ -185,6 +185,26 @@ 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 } : {}) },
}); });
// Уведомление админам/кураторам о новом комментарии студента (не о своих ответах)
if (!isAdmin && !isCurator) {
const settings = await getSettings();
if (asBool(settings.notifyOnComment)) {
const configured = parseNotificationEmails(settings.notificationEmails);
const recipients = configured.length > 0
? configured.map((email) => ({ email, name: "" }))
: await prisma.user.findMany({
where: { role: { in: ["admin", "curator"] } },
select: { email: true, name: true },
});
const lessonUrl = `${process.env.BETTER_AUTH_URL ?? "https://school.second-brain.ru"}/courses/${slug}/lessons/${lessonId}`;
await Promise.all(
recipients.map((r) =>
sendCommentNotificationEmail(r.email, r.name, session.user.name, lesson.title, trimmed, lessonUrl)
)
);
}
}
revalidatePath(`/courses/${slug}/lessons/${lessonId}`); revalidatePath(`/courses/${slug}/lessons/${lessonId}`);
} }
+25
View File
@@ -0,0 +1,25 @@
"use server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { TOOL_IDS, type ToolId } from "@/lib/tools/_shared/types";
// Дедуп-окно: один (userId, tool) пишем раз в N минут — чистит аналитику и
// снимает storage-abuse (Server Action — публичный RPC, не покрыт Better Auth rateLimit).
const DEDUP_WINDOW_MS = 10 * 60 * 1000;
export async function logToolUsage({ tool }: { tool: ToolId }): Promise<{ ok: boolean }> {
// auth-first: сессия раньше валидации ввода
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return { ok: false };
if (!TOOL_IDS.includes(tool)) return { ok: false };
const recent = await prisma.toolUsage.findFirst({
where: { userId: session.user.id, tool, createdAt: { gte: new Date(Date.now() - DEDUP_WINDOW_MS) } },
select: { id: true },
});
if (recent) return { ok: true };
await prisma.toolUsage.create({ data: { userId: session.user.id, tool } });
return { ok: true };
}
+5
View File
@@ -71,6 +71,11 @@ export const auth = betterAuth({
defaultValue: "student", defaultValue: "student",
input: false, input: false,
}, },
archetype: {
type: "string",
required: false,
input: false,
},
}, },
}, },
}); });
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { decidePaidAccess, monthStartUtc } from "@/lib/clean-pdf/access";
const now = new Date("2026-07-06T10:00:00Z");
const freeSlug = "obsidian-start";
describe("decidePaidAccess", () => {
it("платный enrollment без срока — да", () => {
expect(decidePaidAccess({ role: "student", banned: false, freeSlug, now,
enrollments: [{ courseSlug: "zotero", expiresAt: null }] })).toBe(true);
});
it("только бесплатный курс — нет", () => {
expect(decidePaidAccess({ role: "student", banned: false, freeSlug, now,
enrollments: [{ courseSlug: "obsidian-start", expiresAt: null }] })).toBe(false);
});
it("без enrollments — нет", () => {
expect(decidePaidAccess({ role: "student", banned: false, freeSlug, now, enrollments: [] })).toBe(false);
});
it("истёкший платный — нет, живой срок — да", () => {
expect(decidePaidAccess({ role: "student", banned: false, freeSlug, now,
enrollments: [{ courseSlug: "zotero", expiresAt: new Date("2026-01-01") }] })).toBe(false);
expect(decidePaidAccess({ role: "student", banned: false, freeSlug, now,
enrollments: [{ courseSlug: "zotero", expiresAt: new Date("2027-01-01") }] })).toBe(true);
});
it("admin и curator — да даже без курсов", () => {
expect(decidePaidAccess({ role: "admin", banned: false, freeSlug, now, enrollments: [] })).toBe(true);
expect(decidePaidAccess({ role: "curator", banned: false, freeSlug, now, enrollments: [] })).toBe(true);
});
it("бан всё перекрывает", () => {
expect(decidePaidAccess({ role: "admin", banned: true, freeSlug, now, enrollments: [] })).toBe(false);
});
});
describe("monthStartUtc", () => {
it("возвращает первое число месяца 00:00 UTC", () => {
expect(monthStartUtc(new Date("2026-07-06T23:59:59Z")).toISOString()).toBe("2026-07-01T00:00:00.000Z");
});
});
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { PDF_KEY_PREFIX, generatePdfKey } from "@/lib/clean-pdf/api-key";
describe("generatePdfKey", () => {
it("формат sbpdf_<base64url>, достаточная длина", () => {
const key = generatePdfKey();
expect(key.startsWith(PDF_KEY_PREFIX)).toBe(true);
expect(key.length).toBeGreaterThanOrEqual(PDF_KEY_PREFIX.length + 32);
expect(key.slice(PDF_KEY_PREFIX.length)).toMatch(/^[A-Za-z0-9_-]+$/);
});
it("две генерации различаются", () => {
expect(generatePdfKey()).not.toBe(generatePdfKey());
});
});
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { generateCleanPdf } from "@/lib/clean-pdf/generate";
const enabled = process.env.RUN_PDF_INTEGRATION === "1";
describe.runIf(enabled)("generateCleanPdf (integration, нужен browserless)", () => {
it("генерирует PDF реальной статьи", async () => {
const { pdf, title } = await generateCleanPdf({
url: "https://habr.com/ru/articles/942236/",
format: "A4",
theme: "light",
});
expect(pdf.length).toBeGreaterThan(20_000);
expect(pdf.subarray(0, 5).toString()).toBe("%PDF-");
expect(title.length).toBeGreaterThan(3);
}, 120_000);
});
+70
View File
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import { BlockedUrlError, assertPublicUrl, isPrivateAddress } from "@/lib/clean-pdf/ssrf";
describe("isPrivateAddress", () => {
const privateIps = [
"127.0.0.1", "0.0.0.0", "10.1.2.3", "100.64.0.1", "169.254.169.254",
"172.16.0.1", "172.31.255.255", "192.168.1.1", "192.0.0.8", "198.18.0.1",
"224.0.0.1", "255.255.255.255",
"::1", "::", "fc00::1", "fd12:3456::1", "fe80::1", "::ffff:10.0.0.1", "::ffff:127.0.0.1",
"::ffff:7f00:1", "::ffff:a00:1", "::ffff:c0a8:101", "::ffff:a9fe:a9fe",
];
const publicIps = ["93.184.216.34", "8.8.8.8", "172.32.0.1", "2606:2800:220:1::1", "::ffff:8.8.8.8", "::ffff:808:808"];
it.each(privateIps)("блокирует %s", (ip) => expect(isPrivateAddress(ip)).toBe(true));
it.each(publicIps)("пропускает %s", (ip) => expect(isPrivateAddress(ip)).toBe(false));
});
describe("assertPublicUrl", () => {
const publicResolve = async () => [{ address: "93.184.216.34", family: 4 }];
const privateResolve = async () => [{ address: "172.18.0.2", family: 4 }];
const mixedResolve = async () => [
{ address: "93.184.216.34", family: 4 },
{ address: "10.0.0.5", family: 4 },
];
it("пропускает публичный https-URL", async () => {
const url = await assertPublicUrl("https://example.com/article", publicResolve);
expect(url.hostname).toBe("example.com");
});
it.each([
"file:///etc/passwd",
"ftp://example.com/x",
"chrome://settings",
"not a url",
])("блокирует %s", async (raw) => {
await expect(assertPublicUrl(raw, publicResolve)).rejects.toThrow(BlockedUrlError);
});
it("блокирует localhost и .local без резолва", async () => {
await expect(assertPublicUrl("http://localhost:3000/x", publicResolve)).rejects.toThrow(BlockedUrlError);
await expect(assertPublicUrl("http://printer.local/x", publicResolve)).rejects.toThrow(BlockedUrlError);
});
it("блокирует литеральный приватный IP", async () => {
await expect(assertPublicUrl("http://192.168.1.1/admin", publicResolve)).rejects.toThrow(BlockedUrlError);
await expect(assertPublicUrl("http://[::1]:8080/", publicResolve)).rejects.toThrow(BlockedUrlError);
});
it("блокирует хост, резолвящийся в приватный IP (включая частично)", async () => {
await expect(assertPublicUrl("https://evil.example/x", privateResolve)).rejects.toThrow(BlockedUrlError);
await expect(assertPublicUrl("https://evil.example/x", mixedResolve)).rejects.toThrow(BlockedUrlError);
});
it("блокирует хост, который не резолвится", async () => {
const failResolve = async () => { throw new Error("ENOTFOUND"); };
await expect(assertPublicUrl("https://nope.example/x", failResolve)).rejects.toThrow(BlockedUrlError);
});
it("блокирует v4-mapped IPv6 в bracket-нотации (SSRF-обход)", async () => {
for (const raw of [
"http://[::ffff:127.0.0.1]/",
"http://[::ffff:10.0.0.1]/",
"http://[::ffff:169.254.169.254]/",
"http://[::ffff:192.168.1.1]/",
]) {
await expect(assertPublicUrl(raw, publicResolve)).rejects.toThrow(BlockedUrlError);
}
});
});
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import { buildCleanHtml, safePdfFilename } from "@/lib/clean-pdf/template";
const base = {
title: "Заголовок статьи",
url: "https://example.com/article",
theme: "light" as const,
};
describe("buildCleanHtml", () => {
it("экранирует HTML в метаполях", () => {
const html = buildCleanHtml({ ...base, title: `<script>alert(1)</script>`, contentHtml: "<p>ок</p>" });
expect(html).not.toContain("<script>alert(1)</script>");
expect(html).toContain("&lt;script&gt;");
});
it("вставляет контент и шапку", () => {
const html = buildCleanHtml({ ...base, author: "Автор", site: "Example", contentHtml: "<p>Текст статьи</p>" });
expect(html).toContain("<p>Текст статьи</p>");
expect(html).toContain("Заголовок статьи");
expect(html).toContain("Автор");
expect(html).toContain("https://example.com/article");
});
it("строит оглавление при 3+ заголовках и проставляет якоря", () => {
const content = "<h2>Один</h2><p>a</p><h2>Два</h2><p>b</p><h3>Три</h3><p>c</p>";
const html = buildCleanHtml({ ...base, contentHtml: content });
expect(html).toContain("Содержание");
expect(html).toMatch(/<h2 id="[^"]+">Один<\/h2>/);
expect((html.match(/class="toc-item/g) ?? []).length).toBe(3);
});
it("не строит оглавление при <3 заголовках", () => {
const html = buildCleanHtml({ ...base, contentHtml: "<h2>Один</h2><p>a</p>" });
expect(html).not.toContain("Содержание");
});
it("уникализирует одинаковые якоря", () => {
const content = "<h2>Раздел</h2><h2>Раздел</h2><h2>Раздел</h2>";
const html = buildCleanHtml({ ...base, contentHtml: content });
const ids = [...html.matchAll(/<h2 id="([^"]+)"/g)].map((m) => m[1]);
expect(new Set(ids).size).toBe(3);
});
it("вырезает script и inline-обработчики из контента", () => {
const html = buildCleanHtml({ ...base, contentHtml: `<p onclick="alert(1)">t</p><script>alert(2)</script><a href="javascript:alert(3)">x</a>` });
expect(html).not.toContain("<script>alert(2)");
expect(html).not.toContain("onclick");
expect(html).not.toContain("javascript:alert(3)");
expect(html).toContain(">t<");
});
it("переключает тёмную тему", () => {
const light = buildCleanHtml({ ...base, contentHtml: "<p>x</p>" });
const dark = buildCleanHtml({ ...base, theme: "dark", contentHtml: "<p>x</p>" });
expect(light).toContain('data-theme="light"');
expect(dark).toContain('data-theme="dark"');
});
});
describe("safePdfFilename", () => {
it("убирает опасные символы и ограничивает длину", () => {
expect(safePdfFilename('Статья: как/не\\надо "делать"?')).toBe("Статья_ как_не_надо _делать_");
expect(safePdfFilename("")).toBe("document");
expect(safePdfFilename("a".repeat(300)).length).toBeLessThanOrEqual(140);
});
});
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { buildZoteroScript } from "@/lib/clean-pdf/zotero-script";
describe("buildZoteroScript", () => {
it("подставляет ключ и адрес школы", () => {
const s = buildZoteroScript({ apiKey: "sbpdf_test123", baseUrl: "https://school.second-brain.ru" });
expect(s).toContain("'sbpdf_test123'");
expect(s).toContain("'https://school.second-brain.ru'");
expect(s).toContain("/api/pdf?url=");
expect(s).not.toContain("YOUR_KEY");
});
it("генерирует синтаксически валидный JS", () => {
const s = buildZoteroScript({ apiKey: "sbpdf_test123", baseUrl: "https://school.second-brain.ru" });
expect(() => new Function(s)).not.toThrow();
});
});
+60
View File
@@ -0,0 +1,60 @@
import { prisma } from "@/lib/prisma";
// НЕ "clean-pdf": так CopyButton логирует копирования — они не должны тратить лимит
export const PDF_TOOL_ID = "clean-pdf-generate";
export const BURST_LIMIT = 5; // успешных генераций в минуту
const BURST_WINDOW_MS = 60_000;
export function decidePaidAccess(input: {
role: string;
banned: boolean;
enrollments: { courseSlug: string; expiresAt: Date | null }[];
freeSlug: string;
now?: Date;
}): boolean {
const now = input.now ?? new Date();
if (input.banned) return false;
if (input.role === "admin" || input.role === "curator") return true;
return input.enrollments.some(
(e) => e.courseSlug !== input.freeSlug && (e.expiresAt === null || e.expiresAt > now),
);
}
export async function hasPaidAccess(userId: string): Promise<boolean> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
role: true,
banned: true,
enrollments: { select: { expiresAt: true, course: { select: { slug: true } } } },
},
});
if (!user) return false;
return decidePaidAccess({
role: user.role,
banned: user.banned ?? false,
freeSlug: process.env.FREE_COURSE_SLUG ?? "obsidian-start",
enrollments: user.enrollments.map((e) => ({ courseSlug: e.course.slug, expiresAt: e.expiresAt })),
});
}
export function monthStartUtc(now: Date): Date {
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
}
export function getMonthlyLimit(): number {
const n = Number(process.env.PDF_MONTHLY_LIMIT);
return Number.isFinite(n) && n > 0 ? n : 100;
}
export async function getMonthlyUsage(userId: string): Promise<number> {
return prisma.toolUsage.count({
where: { userId, tool: PDF_TOOL_ID, createdAt: { gte: monthStartUtc(new Date()) } },
});
}
export async function getBurstUsage(userId: string): Promise<number> {
return prisma.toolUsage.count({
where: { userId, tool: PDF_TOOL_ID, createdAt: { gte: new Date(Date.now() - BURST_WINDOW_MS) } },
});
}
+7
View File
@@ -0,0 +1,7 @@
import { randomBytes } from "node:crypto";
export const PDF_KEY_PREFIX = "sbpdf_";
export function generatePdfKey(): string {
return PDF_KEY_PREFIX + randomBytes(24).toString("base64url");
}
+139
View File
@@ -0,0 +1,139 @@
import { isIP } from "node:net";
import { chromium } from "playwright-core";
import { JSDOM } from "jsdom";
import { Defuddle } from "defuddle/node";
import { assertPublicUrl, isPrivateAddress } from "@/lib/clean-pdf/ssrf";
import { buildCleanHtml } from "@/lib/clean-pdf/template";
export class EmptyContentError extends Error {}
export class RenderError extends Error {}
/**
* Оборачивает промис в app-level таймаут: если `p` не завершится за `ms`,
* гонка завершится отказом с RenderError, и внешний `finally` (browser.close())
* освободит слот browserless вместо того, чтобы держать его до зависшего вызова.
*/
function withTimeout<T>(p: Promise<T>, ms: number, message: string): Promise<T> {
let timer: ReturnType<typeof setTimeout>;
const guard = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new RenderError(message)), ms);
timer.unref?.();
});
return Promise.race([p, guard]).finally(() => clearTimeout(timer)) as Promise<T>;
}
const GOTO_TIMEOUT_MS = 30_000;
const SETTLE_TIMEOUT_MS = 10_000;
const PDF_TIMEOUT_MS = 30_000;
const UA =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36";
function browserWsUrl(): string {
const base = process.env.BROWSER_WS_URL;
if (!base) throw new RenderError("BROWSER_WS_URL не задан");
const token = process.env.BROWSERLESS_TOKEN;
return token ? `${base}${base.includes("?") ? "&" : "?"}token=${token}` : base;
}
export async function generateCleanPdf(opts: {
url: string;
format: "A4" | "Letter";
theme: "light" | "dark";
}): Promise<{ pdf: Buffer; title: string }> {
const target = await assertPublicUrl(opts.url);
const browser = await chromium.connectOverCDP(browserWsUrl()).catch((e) => {
throw new RenderError(`Браузер недоступен: ${e.message}`);
});
try {
const context = await browser.newContext({ userAgent: UA, viewport: { width: 1280, height: 800 } });
// Вторая линия SSRF-обороны: страница не может дёргать приватные адреса
// (литеральные IP и localhost; hostnames уже проверены до goto).
await context.route("**/*", (route) => {
try {
const u = new URL(route.request().url());
const host = u.hostname.replace(/^\[|\]$/g, "").replace(/\.$/, "");
if (
(u.protocol !== "http:" && u.protocol !== "https:") ||
host === "localhost" || host.endsWith(".local") || host.endsWith(".internal") ||
(isIP(host) !== 0 && isPrivateAddress(host))
) {
return route.abort();
}
} catch {
return route.abort();
}
return route.continue();
});
// context.route НЕ перехватывает WebSocket — отдельная защита от SSRF через ws://
await context.routeWebSocket("**/*", (ws) => {
try {
const u = new URL(ws.url());
const host = u.hostname.replace(/^\[|\]$/g, "").replace(/\.$/, "");
if (
(u.protocol !== "ws:" && u.protocol !== "wss:") ||
host === "localhost" || host.endsWith(".local") || host.endsWith(".internal") ||
(isIP(host) !== 0 && isPrivateAddress(host))
) {
ws.close();
return;
}
} catch {
ws.close();
return;
}
ws.connectToServer();
});
const page = await context.newPage();
await page.goto(target.href, { waitUntil: "domcontentloaded", timeout: GOTO_TIMEOUT_MS }).catch((e) => {
throw new RenderError(`Страница не открылась: ${e.message}`);
});
await page.waitForLoadState("networkidle", { timeout: SETTLE_TIMEOUT_MS }).catch(() => {});
const rawHtml = await page.content();
const pageTitle = await page.title().catch(() => "");
const dom = new JSDOM(rawHtml, { url: target.href });
const article = await Defuddle(dom.window.document, target.href);
// DefuddleResponse (node_modules/defuddle/dist/types.d.ts) типизирует
// content/title/author/site/domain/wordCount как обязательные (не optional) поля —
// сам объект тоже не nullable (Defuddle() не возвращает null/undefined).
// Отсутствие контента выражается пустой строкой/нулевым wordCount, не отсутствием поля.
if (!article.content || article.wordCount < 10) {
throw new EmptyContentError("Не удалось выделить содержимое страницы");
}
const title = article.title || pageTitle || target.hostname;
const cleanHtml = buildCleanHtml({
title,
author: article.author || undefined,
site: article.site || article.domain || undefined,
url: target.href,
contentHtml: article.content,
theme: opts.theme,
});
const pdfPage = await context.newPage();
await pdfPage.setContent(cleanHtml, { waitUntil: "networkidle", timeout: SETTLE_TIMEOUT_MS }).catch(() => {});
// page.pdf() в playwright-core не принимает опцию timeout (проверено по
// node_modules/playwright-core/types/types.d.ts) и не имеет implicit-бага —
// оборачиваем в app-level withTimeout, чтобы зависание всё же дошло до
// finally (browser.close()) и не держало CONCURRENT-слот browserless.
const pdf = await withTimeout(
pdfPage.pdf({
format: opts.format,
printBackground: true,
margin: { top: "15mm", bottom: "18mm", left: "15mm", right: "15mm" },
}),
PDF_TIMEOUT_MS,
"Печать PDF не завершилась вовремя",
);
return { pdf, title };
} finally {
await browser.close().catch(() => {});
}
}
+23
View File
@@ -0,0 +1,23 @@
import { prisma } from "@/lib/prisma";
import { generatePdfKey } from "@/lib/clean-pdf/api-key";
export async function getOrCreatePdfKey(userId: string): Promise<string> {
const existing = await prisma.pdfApiKey.findUnique({ where: { userId } });
if (existing) return existing.key;
const created = await prisma.pdfApiKey.upsert({
where: { userId },
create: { userId, key: generatePdfKey() },
update: {},
});
return created.key;
}
export async function regenerateKey(userId: string): Promise<string> {
const key = generatePdfKey();
await prisma.pdfApiKey.upsert({
where: { userId },
create: { userId, key },
update: { key },
});
return key;
}
+119
View File
@@ -0,0 +1,119 @@
import { lookup } from "node:dns/promises";
import { isIP } from "node:net";
export class BlockedUrlError extends Error {}
export type LookupFn = (
hostname: string,
opts: { all: true },
) => Promise<{ address: string; family: number }[]>;
function v4ToInt(ip: string): number {
return ip.split(".").reduce((acc, o) => acc * 256 + Number(o), 0);
}
// [начало включительно, конец включительно] в виде 32-битных чисел
const V4_PRIVATE: Array<[number, number]> = [
["0.0.0.0", "0.255.255.255"], // "this network"
["10.0.0.0", "10.255.255.255"], // RFC1918
["100.64.0.0", "100.127.255.255"], // CGNAT
["127.0.0.0", "127.255.255.255"], // loopback
["169.254.0.0", "169.254.255.255"], // link-local / cloud metadata
["172.16.0.0", "172.31.255.255"], // RFC1918 (docker-сети попадают сюда)
["192.0.0.0", "192.0.0.255"], // IETF protocol assignments
["192.168.0.0", "192.168.255.255"], // RFC1918
["198.18.0.0", "198.19.255.255"], // benchmarking
["224.0.0.0", "255.255.255.255"], // multicast + reserved + broadcast
].map(([a, b]) => [v4ToInt(a), v4ToInt(b)] as [number, number]);
function isPrivateV4(ip: string): boolean {
const n = v4ToInt(ip);
return V4_PRIVATE.some(([lo, hi]) => n >= lo && n <= hi);
}
/** Разворачивает IPv6 (в т.ч. сжатый `::` и v4-mapped/embedded dotted) в 8 групп по 16 бит. */
function expandV6(ip: string): number[] | null {
let s = ip.toLowerCase();
// Встроенный dotted-хвост (::ffff:127.0.0.1, ::127.0.0.1) → в hex-группы
const dotted = s.match(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
if (dotted) {
if (isIP(dotted[1]) !== 4) return null;
const n = v4ToInt(dotted[1]);
s = s.slice(0, dotted.index) + ((n >>> 16) & 0xffff).toString(16) + ":" + (n & 0xffff).toString(16);
}
const halves = s.split("::");
if (halves.length > 2) return null;
const head = halves[0] ? halves[0].split(":") : [];
const tail = halves.length === 2 ? (halves[1] ? halves[1].split(":") : []) : [];
let groups: string[];
if (halves.length === 2) {
const missing = 8 - head.length - tail.length;
if (missing < 0) return null;
groups = [...head, ...Array(missing).fill("0"), ...tail];
} else {
groups = head;
}
if (groups.length !== 8) return null;
const nums = groups.map((g) => (g === "" ? 0 : parseInt(g, 16)));
if (nums.some((x) => Number.isNaN(x) || x < 0 || x > 0xffff)) return null;
return nums;
}
export function isPrivateAddress(ip: string): boolean {
if (isIP(ip) === 4) return isPrivateV4(ip);
if (isIP(ip) !== 6) return true; // не IP — не пропускаем
const groups = expandV6(ip);
if (!groups) return true; // не смогли разобрать v6 — не пропускаем
// ::/96 (v4-compatible, вкл. :: и ::1) и ::ffff:0:0/96 (v4-mapped):
// младшие 32 бита содержат IPv4 — проверяем его напрямую.
const firstFiveZero = groups.slice(0, 5).every((g) => g === 0);
if (firstFiveZero && (groups[5] === 0 || groups[5] === 0xffff)) {
const v4num = groups[6] * 0x10000 + groups[7];
const dottedV4 = `${(v4num >>> 24) & 255}.${(v4num >>> 16) & 255}.${(v4num >>> 8) & 255}.${v4num & 255}`;
return isPrivateV4(dottedV4);
}
const first = groups[0];
if (first >= 0xfc00 && first <= 0xfdff) return true; // fc00::/7 (ULA)
if (first >= 0xfe80 && first <= 0xfebf) return true; // fe80::/10 (link-local)
return false;
}
export async function assertPublicUrl(raw: string, resolve: LookupFn = lookup): Promise<URL> {
let url: URL;
try {
url = new URL(raw);
} catch {
throw new BlockedUrlError("Некорректный URL");
}
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new BlockedUrlError("Поддерживаются только http и https");
}
const hostname = url.hostname.replace(/^\[|\]$/g, "").replace(/\.$/, ""); // [::1] → ::1
if (hostname === "localhost" || hostname.endsWith(".local") || hostname.endsWith(".internal")) {
throw new BlockedUrlError("Адрес недоступен");
}
if (isIP(hostname)) {
if (isPrivateAddress(hostname)) throw new BlockedUrlError("Адрес недоступен");
return url;
}
let addresses: { address: string; family: number }[];
try {
addresses = await resolve(hostname, { all: true });
} catch {
throw new BlockedUrlError("Не удалось определить адрес сайта");
}
if (addresses.length === 0 || addresses.some((a) => isPrivateAddress(a.address))) {
throw new BlockedUrlError("Адрес недоступен");
}
return url;
}
+118
View File
@@ -0,0 +1,118 @@
import { JSDOM } from "jsdom";
export interface CleanArticle {
title: string;
author?: string;
site?: string;
url: string;
contentHtml: string;
theme: "light" | "dark";
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
export function safePdfFilename(title: string): string {
const cleaned = title.replace(/[\\/:"*?<>|\n\r]+/g, "_").trim().slice(0, 140);
return cleaned || "document";
}
interface TocEntry { id: string; text: string; level: 2 | 3 }
/** Проставляет id заголовкам h2/h3 и возвращает контент + записи оглавления. */
function prepareContent(contentHtml: string): { html: string; toc: TocEntry[] } {
const dom = new JSDOM(`<body>${contentHtml}</body>`);
const doc = dom.window.document;
const used = new Set<string>();
const toc: TocEntry[] = [];
doc.querySelectorAll("h2, h3").forEach((h) => {
const text = (h.textContent ?? "").trim();
if (!text) return;
let id = text.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "") || "section";
let i = 2;
while (used.has(id)) id = `${id}-${i++}`;
used.add(id);
h.id = id;
toc.push({ id, text, level: h.tagName === "H2" ? 2 : 3 });
});
// Defuddle отдаёт очищенный, но не полностью доверенный HTML исходной
// страницы — вырезаем активное содержимое перед печатью в реальном браузере.
doc.querySelectorAll("script, style, iframe, object, embed").forEach((el) => el.remove());
doc.querySelectorAll("*").forEach((el) => {
for (const attr of [...el.attributes]) {
if (attr.name.startsWith("on") || (attr.name === "href" && attr.value.trim().toLowerCase().startsWith("javascript:"))) {
el.removeAttribute(attr.name);
}
}
});
return { html: doc.body.innerHTML, toc };
}
const CSS = `
:root[data-theme="light"] { --bg: #faf7f0; --fg: #1f1d1a; --muted: #6b6459; --line: #d8d2c4; --accent: #7a2e2e; }
:root[data-theme="dark"] { --bg: #201e1b; --fg: #e8e4dc; --muted: #a39b8d; --line: #3d3a34; --accent: #d4a0a0; }
* { box-sizing: border-box; }
body { background: var(--bg); color: var(--fg); font-family: Georgia, "Times New Roman", serif;
font-size: 12.5pt; line-height: 1.65; margin: 0; }
main { max-width: 100%; }
h1 { font-size: 22pt; line-height: 1.25; margin: 0 0 6pt; }
h2 { font-size: 16pt; margin: 20pt 0 8pt; }
h3 { font-size: 13.5pt; margin: 16pt 0 6pt; }
p { margin: 0 0 9pt; }
a { color: var(--accent); text-decoration: none; }
img, video, iframe { max-width: 100%; height: auto; }
pre { background: rgba(127,127,127,.08); border: 1px solid var(--line); padding: 8pt;
overflow-x: hidden; white-space: pre-wrap; word-wrap: break-word;
font-family: "SF Mono", Menlo, Consolas, monospace; font-size: 9.5pt; }
code { font-family: "SF Mono", Menlo, Consolas, monospace; font-size: 0.9em; }
blockquote { border-left: 3px solid var(--line); margin: 0 0 9pt; padding: 2pt 0 2pt 12pt; color: var(--muted); }
table { border-collapse: collapse; width: 100%; font-size: 10.5pt; }
th, td { border: 1px solid var(--line); padding: 4pt 6pt; text-align: left; }
figure { margin: 0 0 9pt; } figcaption { color: var(--muted); font-size: 9.5pt; }
.meta { color: var(--muted); font-size: 10pt; margin-bottom: 4pt; }
.meta a { color: var(--muted); }
.head-rule { border: 0; border-top: 1px solid var(--line); margin: 12pt 0 16pt; }
.toc { border: 1px solid var(--line); padding: 10pt 14pt; margin: 0 0 16pt; }
.toc-title { font-weight: bold; margin-bottom: 6pt; }
.toc-item { display: block; margin: 2pt 0; }
.toc-item.lvl3 { padding-left: 14pt; font-size: 0.92em; }
h2, h3 { break-after: avoid; } pre, blockquote, figure, table { break-inside: avoid; }
`;
export function buildCleanHtml(article: CleanArticle): string {
const { html, toc } = prepareContent(article.contentHtml);
const metaParts = [article.site, article.author].filter(Boolean).map((s) => escapeHtml(s!));
const tocHtml = toc.length >= 3
? `<nav class="toc"><div class="toc-title">Содержание</div>${toc
.map((t) => `<a class="toc-item lvl${t.level}" href="#${t.id}">${escapeHtml(t.text)}</a>`)
.join("")}</nav>`
: "";
return `<!DOCTYPE html>
<html lang="ru" data-theme="${article.theme}">
<head>
<meta charset="utf-8">
<title>${escapeHtml(article.title)}</title>
<style>${CSS}</style>
</head>
<body>
<main>
<h1>${escapeHtml(article.title)}</h1>
${metaParts.length ? `<div class="meta">${metaParts.join(" · ")}</div>` : ""}
<div class="meta"><a href="${escapeHtml(article.url)}">${escapeHtml(article.url)}</a></div>
<hr class="head-rule">
${tocHtml}
${html}
</main>
</body>
</html>`;
}
+80
View File
@@ -0,0 +1,80 @@
// Адаптация скрипта Clean PDF (pdf.brainysnipe.ru/zotero-script.js) под школу.
export function buildZoteroScript({ apiKey, baseUrl }: { apiKey: string; baseUrl: string }): string {
return `// ── Настройки ──────────────────────────────────────────────────────────────
const API_KEY = '${apiKey}'; // персональный ключ из ${baseUrl}/tools/clean-pdf
const SERVICE_URL = '${baseUrl}';
const FORMAT = 'A4'; // 'A4' или 'Letter'
const THEME = 'light'; // 'light' или 'dark'
// ───────────────────────────────────────────────────────────────────────────
if (typeof item === "undefined" || !item) return;
(async function () {
let tempFilePath = null;
try {
function joinPath(dir, filename) {
if (typeof OS !== "undefined" && OS.Path && OS.Path.join) return OS.Path.join(dir, filename);
const separator = dir.includes("\\\\") ? "\\\\" : "/";
return dir.replace(/[\\\\/]+$/, "") + separator + filename;
}
let targetItem = item;
if (!targetItem.isRegularItem()) {
if (targetItem.isAttachment() || targetItem.isNote())
targetItem = Zotero.Items.get(targetItem.parentID);
}
if (!targetItem?.isRegularItem()) {
Zotero.alert(null, "Чистый PDF", "Выберите обычную запись Zotero.");
return;
}
const url = targetItem.getField("url");
if (!url) { Zotero.alert(null, "Чистый PDF", "У записи нет URL."); return; }
const pw = new Zotero.ProgressWindow();
pw.changeHeadline("Чистый PDF");
const progress = new pw.ItemProgress(targetItem.getImageSrc(), targetItem.getField("title"));
pw.show();
pw.addDescription("Генерируем PDF…");
const response = await Zotero.getMainWindow().fetch(
SERVICE_URL + "/api/pdf?url=" + encodeURIComponent(url) + "&format=" + FORMAT + "&theme=" + THEME,
{ headers: { 'Authorization': 'Bearer ' + API_KEY } }
);
if (!response.ok) {
const body = await response.json().catch(() => ({}));
progress.setError();
pw.addDescription("Ошибка: " + (body.error || ("HTTP " + response.status)));
pw.startCloseTimer(8000);
return;
}
const uint8Array = new Uint8Array(await (await response.blob()).arrayBuffer());
if (uint8Array.length < 1000) {
progress.setError(); pw.addDescription("Пришёл пустой PDF."); pw.startCloseTimer(8000); return;
}
const safeTitle = (targetItem.getField("title") || "Document").replace(/[\\\\/:"*?<>|]+/g, "_").slice(0, 140);
tempFilePath = joinPath(Zotero.getTempDirectory().path, safeTitle + ".pdf");
await Zotero.getMainWindow().IOUtils.write(tempFilePath, uint8Array);
await Zotero.Attachments.importFromFile({ file: tempFilePath, parentItemID: targetItem.id, contentType: "application/pdf" });
for (const attId of targetItem.getAttachments()) {
const att = Zotero.Items.get(attId);
const ct = att?.attachmentContentType || "";
if (ct === "text/html" || ct === "application/zip") await att.eraseTx();
}
progress.setProgress(100);
const usesCount = response.headers.get('X-Uses-Count');
const maxUses = response.headers.get('X-Max-Uses');
const usageInfo = (usesCount && maxUses) ? (" (" + usesCount + "/" + maxUses + " за месяц)") : "";
pw.addDescription("PDF прикреплён." + usageInfo);
pw.startCloseTimer(4000);
} catch (e) {
Zotero.alert(null, "Ошибка", e.toString());
} finally {
if (tempFilePath) {
await Zotero.getMainWindow().IOUtils.remove(tempFilePath, { ignoreAbsent: true }).catch(() => {});
}
}
})();`;
}
+25
View File
@@ -137,6 +137,31 @@ export async function sendHomeworkSubmittedEmail(
}).catch((e) => console.error("[email] sendHomeworkSubmittedEmail:", e)); }).catch((e) => console.error("[email] sendHomeworkSubmittedEmail:", e));
} }
export async function sendCommentNotificationEmail(
to: string,
recipientName: string,
studentName: string,
lessonTitle: string,
commentText: string,
lessonUrl: string
) {
const school = await getSchoolName();
const esc = (t: string) => t.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const preview = commentText.length > 400 ? commentText.slice(0, 400) + "…" : commentText;
await getResend().emails.send({
from: FROM,
to,
subject: `Новый комментарий под уроком — ${lessonTitle}`,
html: base(`
<p ${p}>Привет${recipientName ? `, ${esc(recipientName)}` : ""}!</p>
<p ${p}><strong>${esc(studentName)}</strong> оставил комментарий под уроком <strong>«${lessonTitle}»</strong>:</p>
${quote(esc(preview))}
<p ${pLast}>Открыть урок и ответить:</p>
${btn(lessonUrl, "Открыть урок")}
`, school),
}).catch((e) => console.error("[email] sendCommentNotificationEmail:", e));
}
export async function sendFeedbackReceivedEmail( export async function sendFeedbackReceivedEmail(
to: string, to: string,
studentName: string, studentName: string,
+15
View File
@@ -19,6 +19,21 @@ export function getPublicUrl(key: string): string {
return `${process.env.S3_ENDPOINT}/${BUCKET}/${key}`; return `${process.env.S3_ENDPOINT}/${BUCKET}/${key}`;
} }
/**
* Валидна ли публичная ссылка на наш загруженный файл. Принимает И CDN-домен
* (`S3_CDN_URL`, напр. files.second-brain.ru), И прямой S3-эндпоинт — иначе
* после включения CDN ссылки от uploadFile (getPublicUrl) перестают проходить
* валидацию (вложения молча отбрасываются). Нужна, чтобы клиент не подсунул
* произвольный URL — только наши бакет/CDN.
*/
export function isAllowedPublicUrl(url: string): boolean {
if (typeof url !== "string" || !url.startsWith("https://")) return false;
const prefixes: string[] = [];
if (process.env.S3_CDN_URL) prefixes.push(`${process.env.S3_CDN_URL}/`);
if (process.env.S3_ENDPOINT) prefixes.push(`${process.env.S3_ENDPOINT}/${BUCKET}/`);
return prefixes.some((p) => url.startsWith(p));
}
/** /**
* Заголовок Content-Disposition для принудительного скачивания с человеческим * Заголовок Content-Disposition для принудительного скачивания с человеческим
* именем. RFC 5987: ASCII-фолбэк + UTF-8 (кириллица percent-encoded). * именем. RFC 5987: ASCII-фолбэк + UTF-8 (кириллица percent-encoded).
+1
View File
@@ -14,6 +14,7 @@ export const SETTINGS_DEFAULTS = {
// Notifications // Notifications
notificationEmails: "", // newline-separated list notificationEmails: "", // newline-separated list
notifyOnHomework: "true", notifyOnHomework: "true",
notifyOnComment: "true",
notifyOnRegistration: "true", notifyOnRegistration: "true",
notifyStudentOnFeedback: "true", notifyStudentOnFeedback: "true",

Some files were not shown because too many files have changed in this diff Show More