Compare commits
41 Commits
39e2102a18
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 92fd88964e | |||
| 33c0d3cafd | |||
| 5d94949810 | |||
| 98cc023709 | |||
| 53caf54b03 | |||
| 4734b99bea | |||
| 118fa3961f | |||
| 1ccf994112 | |||
| 02b16e311b | |||
| f143f58cea | |||
| fd40b1e394 | |||
| 0cbea10e1a | |||
| 668dd29397 | |||
| 9ad4d762d7 | |||
| c50a295290 | |||
| 0c56b0b809 | |||
| 89383fab1d | |||
| ef46cdbe29 | |||
| b49680c118 | |||
| 1d5b4f9251 | |||
| 6316c09993 | |||
| 9a74339087 | |||
| 2c619be043 | |||
| 52073fd914 | |||
| f5768331cf | |||
| 541853bb1d | |||
| e795c4d655 | |||
| aaf84ea931 | |||
| 16b17685f6 | |||
| 5b81a7b594 | |||
| 980c4d621e | |||
| 8c2bd42be3 | |||
| fa7c25465c | |||
| 1133557a62 | |||
| a6835567f1 | |||
| c2f84079ac | |||
| f8f246ca9b | |||
| d2094c9541 | |||
| 7c8e72cd94 | |||
| d20e277535 | |||
| 47e301a5b8 |
@@ -45,3 +45,9 @@ 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"
|
||||
|
||||
@@ -48,3 +48,9 @@ next-env.d.ts
|
||||
|
||||
# Claude Code local plugins (external git repos, не коммитим)
|
||||
.claude/plugins/
|
||||
|
||||
# agent-browser auth state
|
||||
lms-auth.json
|
||||
|
||||
# SDD scratch
|
||||
.superpowers/
|
||||
|
||||
@@ -171,6 +171,39 @@ CSS-классы: `.card-aubade`, `.btn-aubade`, `.btn-aubade-accent`, `.tag-aub
|
||||
|---|---|---|---|
|
||||
| `POST` | `/api/auth/[...all]` | Better Auth handler | Все |
|
||||
| `POST` | `/api/admin/upload` | Загрузка файла в S3, возвращает `{ url, key }` | admin |
|
||||
| `GET` | `/api/pdf` | Чистый PDF из URL (Bearer-ключ или сессия) — см. раздел ниже | платный студент, admin, curator |
|
||||
|
||||
---
|
||||
|
||||
## Чистый PDF (`/tools/clean-pdf`, `/api/pdf`)
|
||||
|
||||
Инструмент Obsidian Toolbox: превращает произвольный URL в чистый PDF (без рекламы и меню, с типографикой, оглавлением, A4/Letter, light/dark). Полный дизайн-документ: [`docs/specs/20260706-clean-pdf-design.md`](docs/specs/20260706-clean-pdf-design.md).
|
||||
|
||||
**Пайплайн генерации:**
|
||||
|
||||
```
|
||||
URL студента
|
||||
→ browserless (Chromium по WebSocket, playwright-core) загружает страницу
|
||||
→ Next.js забирает итоговый HTML
|
||||
→ Defuddle (JSDOM) выделяет основной контент
|
||||
→ HTML-шаблон (типографика, A4/Letter, light/dark, оглавление)
|
||||
→ Playwright page.pdf() в том же browserless
|
||||
→ application/pdf в ответе
|
||||
```
|
||||
|
||||
Доступ: любой платный студент (есть `CourseEnrollment` вне `FREE_COURSE_SLUG`), 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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
+21
-2
@@ -13,24 +13,43 @@ services:
|
||||
NEXT_PUBLIC_APP_URL: "https://school.second-brain.ru"
|
||||
RESEND_API_KEY: "${RESEND_API_KEY}"
|
||||
EMAIL_FROM: "${EMAIL_FROM}"
|
||||
BROWSER_WS_URL: "ws://browserless:3000"
|
||||
BROWSERLESS_TOKEN: "${BROWSERLESS_TOKEN}"
|
||||
PDF_MONTHLY_LIMIT: "${PDF_MONTHLY_LIMIT:-100}"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
browserless:
|
||||
condition: service_started
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
# ⚠️ PG18 сменил раскладку: том монтируется в /var/lib/postgresql (НЕ .../data),
|
||||
# кластер лежит в подкаталоге 18/docker. Мажор прода сделан 09.07.2026.
|
||||
# Не откатывать тег и mount по отдельности — иначе initdb на непустом томе → рестарт-луп.
|
||||
# В этом каталоге на Hetzner том lms-sb_postgres_data = hot-standby (тоже PG18).
|
||||
image: postgres:18-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: lms_user
|
||||
POSTGRES_PASSWORD: "${DB_PASSWORD}"
|
||||
POSTGRES_DB: lms_db
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- postgres_data:/var/lib/postgresql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U lms_user -d lms_db"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
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:
|
||||
postgres_data:
|
||||
|
||||
+13
-3
@@ -1,15 +1,25 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
image: postgres:18-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: lms_user
|
||||
POSTGRES_PASSWORD: lms_password
|
||||
POSTGRES_DB: lms_db
|
||||
ports:
|
||||
- "5432:5432"
|
||||
- "127.0.0.1:5432:5432"
|
||||
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:
|
||||
postgres_data:
|
||||
|
||||
@@ -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.16–31.x, 192.168.x, 169.254.x (метаданные облаков), ::1, fc00::/7, плюс docker-хостнеймы стенда (`db`, `app`, `browserless`).
|
||||
- Внутри browserless — перехват сетевых запросов страницы (`context.route`/`context.routeWebSocket`): блокирует буквальные приватные/зарезервированные IP и хосты `localhost`/`*.local`/`*.internal`. **Честно про предел этой защиты:** она не резолвит 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
@@ -3,7 +3,7 @@ import type { NextConfig } from "next";
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
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;
|
||||
|
||||
Generated
+785
-2
File diff suppressed because it is too large
Load Diff
@@ -34,14 +34,17 @@
|
||||
"better-auth": "^1.6.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"defuddle": "^0.19.1",
|
||||
"disposable-email-domains": "^1.0.62",
|
||||
"force-graph": "^1.51.4",
|
||||
"gray-matter": "^4.0.3",
|
||||
"iconv-lite": "^0.7.2",
|
||||
"jsdom": "^29.1.1",
|
||||
"lucide-react": "^1.7.0",
|
||||
"next": "16.2.2",
|
||||
"next-themes": "^0.4.6",
|
||||
"pg": "^8.20.0",
|
||||
"playwright-core": "^1.61.1",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"remark-parse": "^11.0.0",
|
||||
@@ -54,6 +57,7 @@
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/jsdom": "^28.0.3",
|
||||
"@types/node": "^20",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^19",
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Чистый PDF — персональный API-ключ студента (Zotero/curl)
|
||||
CREATE TABLE "PdfApiKey" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"key" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "PdfApiKey_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "PdfApiKey_userId_key" ON "PdfApiKey"("userId");
|
||||
CREATE UNIQUE INDEX "PdfApiKey_key_key" ON "PdfApiKey"("key");
|
||||
|
||||
ALTER TABLE "PdfApiKey" ADD CONSTRAINT "PdfApiKey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -47,6 +47,7 @@ model User {
|
||||
questionMessages StudentQuestionMessage[]
|
||||
wrappedRuns WrappedRun[]
|
||||
toolUsages ToolUsage[]
|
||||
pdfApiKey PdfApiKey?
|
||||
}
|
||||
|
||||
// Obsidian Wrapped — агрегаты прогона (БЕЗ имён/текстов заметок; обработка волта 100% client-side)
|
||||
@@ -82,6 +83,16 @@ model ToolUsage {
|
||||
@@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 {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
|
||||
@@ -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 |
@@ -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";
|
||||
Executable
+108
@@ -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"
|
||||
@@ -149,9 +149,8 @@ export default async function LessonPage({ params }: Props) {
|
||||
{lesson.files.map((file) => (
|
||||
<a
|
||||
key={file.id}
|
||||
href={file.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={`/api/lesson-files/${file.id}/download`}
|
||||
download={file.name}
|
||||
className="flex items-center gap-3 px-4 py-3 text-sm transition-colors hover:[border-color:var(--foreground)]"
|
||||
style={{ border: "2px solid var(--border)" }}
|
||||
>
|
||||
|
||||
@@ -5,17 +5,8 @@ import { prisma } from "@/lib/prisma";
|
||||
import Link from "next/link";
|
||||
import { ArchetypeBanner } from "@/components/student/archetype-banner";
|
||||
import { ToolboxPromoCard } from "@/components/tools/ToolboxPromoCard";
|
||||
|
||||
// Продаваемая линейка курсов → лендинг продукта. Курсы из этого списка,
|
||||
// которых нет у студента, показываются на дашборде заблюренными карточками
|
||||
// со ссылкой на лендинг (апселл). Легаси/обычные версии сюда не входят.
|
||||
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/",
|
||||
};
|
||||
import { StoreShowcase } from "@/components/student/store-showcase";
|
||||
import { STORE_CATALOG } from "@/lib/store-catalog";
|
||||
|
||||
export default async function StudentDashboard() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
@@ -70,7 +61,7 @@ export default async function StudentDashboard() {
|
||||
"zotero-full": "zotero", // ВВ Zotero ← обычный Zotero
|
||||
};
|
||||
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 },
|
||||
});
|
||||
const locked = storeCourses.filter(
|
||||
@@ -175,55 +166,10 @@ export default async function StudentDashboard() {
|
||||
)}
|
||||
|
||||
{locked.length > 0 && (
|
||||
<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">
|
||||
{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>
|
||||
<StoreShowcase
|
||||
courses={locked}
|
||||
buyer={{ name: session.user.name, email: session.user.email }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{expired.length > 0 && (
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
|
||||
const field = "w-full p-2 text-sm";
|
||||
const fieldStyle = { border: "1px solid var(--border)", borderRadius: "2px", backgroundColor: "var(--background)", color: "var(--foreground)" } as const;
|
||||
|
||||
export function CleanPdfForm({ initialUsed, limit }: { initialUsed: number; limit: number }) {
|
||||
const [url, setUrl] = useState("");
|
||||
const [format, setFormat] = useState<"A4" | "Letter">("A4");
|
||||
const [theme, setTheme] = useState<"light" | "dark">("light");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [used, setUsed] = useState(initialUsed);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!url.trim() || busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params = new URLSearchParams({ url: url.trim(), format, theme });
|
||||
const res = await fetch(`/api/pdf?${params}`);
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ error: `Ошибка ${res.status}` }));
|
||||
setError(body.error ?? `Ошибка ${res.status}`);
|
||||
return;
|
||||
}
|
||||
const usesCount = res.headers.get("X-Uses-Count");
|
||||
if (usesCount) setUsed(Number(usesCount));
|
||||
|
||||
const blob = await res.blob();
|
||||
const disposition = res.headers.get("Content-Disposition") ?? "";
|
||||
const match = disposition.match(/filename\*=UTF-8''([^;]+)/);
|
||||
const filename = match ? decodeURIComponent(match[1]) : "document.pdf";
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
} catch {
|
||||
setError("Сеть недоступна, попробуйте ещё раз");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
||||
<label className="text-sm">Адрес статьи
|
||||
<input className={field} style={fieldStyle} type="url" required placeholder="https://…"
|
||||
value={url} onChange={(e) => setUrl(e.target.value)} />
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<label className="text-sm">Формат
|
||||
<select className={field} style={fieldStyle} value={format} onChange={(e) => setFormat(e.target.value as "A4" | "Letter")}>
|
||||
<option value="A4">A4</option>
|
||||
<option value="Letter">Letter</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-sm">Тема
|
||||
<select className={field} style={fieldStyle} value={theme} onChange={(e) => setTheme(e.target.value as "light" | "dark")}>
|
||||
<option value="light">Светлая</option>
|
||||
<option value="dark">Тёмная</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<button type="submit" disabled={busy} className="btn-aubade px-4 py-2 text-sm font-bold disabled:opacity-50">
|
||||
{busy ? "Генерируем… (до минуты)" : "Скачать PDF"}
|
||||
</button>
|
||||
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>Использовано {used} из {limit} в этом месяце</span>
|
||||
</div>
|
||||
{error && <p className="text-sm" style={{ color: "var(--destructive)" }}>{error}</p>}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
import { useState, useTransition } from "react";
|
||||
import { CodeOutput } from "@/components/tools/CodeOutput";
|
||||
import { regeneratePdfApiKey } from "@/lib/actions/pdf-key-actions";
|
||||
import { buildZoteroScript } from "@/lib/clean-pdf/zotero-script";
|
||||
|
||||
export function ZoteroSection({ apiKey, baseUrl }: { apiKey: string; baseUrl: string }) {
|
||||
const [key, setKey] = useState(apiKey);
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const shownKey = revealed ? key : key.slice(0, 8) + "…" + key.slice(-4);
|
||||
const curlExample = `curl -H "Authorization: Bearer ${key}" \\\n "${baseUrl}/api/pdf?url=https://example.com/article&format=A4&theme=light" \\\n -o article.pdf`;
|
||||
const script = buildZoteroScript({ apiKey: key, baseUrl });
|
||||
|
||||
function regenerate() {
|
||||
if (!confirm("Старый ключ перестанет работать (в том числе в Zotero). Продолжить?")) return;
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const res = await regeneratePdfApiKey();
|
||||
if (res.ok && res.key) {
|
||||
setKey(res.key);
|
||||
setRevealed(true);
|
||||
} else {
|
||||
setError("Не удалось перевыпустить ключ, попробуйте ещё раз");
|
||||
}
|
||||
} catch {
|
||||
setError("Не удалось перевыпустить ключ, попробуйте ещё раз");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 className="text-lg font-bold" style={{ color: "var(--foreground)" }}>Zotero и API</h2>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="text-xs font-bold tracking-wide" style={{ color: "var(--muted-foreground)" }}>ВАШ КЛЮЧ</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="p-2 text-sm" style={{ border: "1px solid var(--border)", borderRadius: "2px" }}>{shownKey}</code>
|
||||
<button type="button" className="text-xs underline" onClick={() => setRevealed((v) => !v)}>
|
||||
{revealed ? "скрыть" : "показать"}
|
||||
</button>
|
||||
<button type="button" className="text-xs underline" onClick={() => navigator.clipboard.writeText(key)}>скопировать</button>
|
||||
<button type="button" className="text-xs underline" disabled={pending} onClick={regenerate}>
|
||||
{pending ? "меняем…" : "перевыпустить"}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||
Ключ персональный — не публикуйте его. Если ключ утёк, перевыпустите: старый сразу отключится.
|
||||
</p>
|
||||
{error && <p className="text-sm" style={{ color: "var(--destructive)" }}>{error}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 text-sm" style={{ color: "var(--foreground)" }}>
|
||||
<div className="text-xs font-bold tracking-wide" style={{ color: "var(--muted-foreground)" }}>НАСТРОЙКА ZOTERO</div>
|
||||
<ol className="list-decimal pl-5 flex flex-col gap-1">
|
||||
<li>Установите плагин <a className="underline" href="https://github.com/windingwind/zotero-actions-tags/releases" target="_blank" rel="noreferrer">Actions & Tags</a>.</li>
|
||||
<li>В настройках плагина нажмите «+» и создайте действие: Name — <b>Чистый PDF</b>, Operation — <b>Script</b>.</li>
|
||||
<li>В поле Data вставьте скрипт ниже (ключ уже подставлен).</li>
|
||||
<li>Menu Label — <b>_чистый PDF</b>, поставьте галочку <b>In item Menu</b>, нажмите Save.</li>
|
||||
<li>Готово: правый клик на записи с URL → «_чистый PDF» — файл прикрепится к записи.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<CodeOutput code={script} tool="clean-pdf" label="СКРИПТ ДЛЯ ACTIONS & TAGS" />
|
||||
<CodeOutput code={curlExample} tool="clean-pdf" label="ПРИМЕР ДЛЯ API (CURL)" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { headers } from "next/headers";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { getMonthlyLimit, getMonthlyUsage, hasPaidAccess } from "@/lib/clean-pdf/access";
|
||||
import { getOrCreatePdfKey } from "@/lib/clean-pdf/keys";
|
||||
import { CleanPdfForm } from "./CleanPdfForm";
|
||||
import { ZoteroSection } from "./ZoteroSection";
|
||||
|
||||
export const metadata = { title: "Чистый PDF — Obsidian Toolbox" };
|
||||
|
||||
export default async function CleanPdfToolPage() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
const userId = session?.user.id;
|
||||
const paid = userId ? await hasPaidAccess(userId) : false;
|
||||
|
||||
if (!userId || !paid) {
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-5xl px-6 py-8">
|
||||
<h1 className="text-2xl font-bold tracking-wide" style={{ color: "var(--foreground)" }}>Чистый PDF</h1>
|
||||
<div className="mt-6 card-aubade p-4">
|
||||
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>
|
||||
Инструмент доступен студентам платных курсов школы. Если у вас есть купленный курс — проверьте, что вы вошли в нужный аккаунт.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const [key, used] = await Promise.all([getOrCreatePdfKey(userId), getMonthlyUsage(userId)]);
|
||||
const limit = getMonthlyLimit();
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL ?? "https://school.second-brain.ru";
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-5xl px-6 py-8">
|
||||
<h1 className="text-2xl font-bold tracking-wide" style={{ color: "var(--foreground)" }}>Чистый PDF</h1>
|
||||
<p className="mt-1 text-sm" style={{ color: "var(--muted-foreground)" }}>
|
||||
Превращает любую веб-страницу в аккуратный PDF: без рекламы, меню и мусора — только текст, картинки и оглавление.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 card-aubade p-4">
|
||||
<CleanPdfForm initialUsed={used} limit={limit} />
|
||||
</div>
|
||||
|
||||
<div className="mt-6 card-aubade p-4">
|
||||
<ZoteroSection apiKey={key} baseUrl={baseUrl} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -200,6 +200,7 @@ export async function applyImport(
|
||||
data: {
|
||||
name: row.name,
|
||||
email: row.email,
|
||||
phone: row.phone || null,
|
||||
emailVerified: options.autoVerifyEmail,
|
||||
role: "student",
|
||||
},
|
||||
@@ -231,10 +232,16 @@ export async function applyImport(
|
||||
|
||||
created++;
|
||||
} else if (row.status === "update" && row.existingId) {
|
||||
const existing = await prisma.user.findUnique({
|
||||
where: { id: row.existingId },
|
||||
select: { phone: true },
|
||||
});
|
||||
await prisma.user.update({
|
||||
where: { id: row.existingId },
|
||||
data: {
|
||||
name: row.name || undefined,
|
||||
// don't overwrite an already-filled phone
|
||||
phone: existing?.phone ? undefined : row.phone || undefined,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ export async function POST(request: NextRequest) {
|
||||
let body: {
|
||||
email?: string;
|
||||
name?: string;
|
||||
phone?: string;
|
||||
course_slugs?: string[];
|
||||
order_id?: string;
|
||||
};
|
||||
@@ -30,6 +31,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const email = (body.email ?? "").trim().toLowerCase();
|
||||
const name = (body.name ?? "").trim();
|
||||
const phone = (body.phone ?? "").trim().slice(0, 30);
|
||||
const slugs = Array.isArray(body.course_slugs)
|
||||
? body.course_slugs.filter((s) => typeof s === "string" && s.trim())
|
||||
: [];
|
||||
@@ -66,7 +68,7 @@ export async function POST(request: NextRequest) {
|
||||
// Find or create user
|
||||
let user = await prisma.user.findFirst({
|
||||
where: { email: { equals: email, mode: "insensitive" } },
|
||||
select: { id: true, name: true },
|
||||
select: { id: true, name: true, email: true, phone: true },
|
||||
});
|
||||
let tempPassword: string | null = null;
|
||||
if (!user) {
|
||||
@@ -76,14 +78,25 @@ export async function POST(request: NextRequest) {
|
||||
data: {
|
||||
email,
|
||||
name: name || email,
|
||||
phone: phone || null,
|
||||
emailVerified: true,
|
||||
mustChangePassword: true,
|
||||
accounts: {
|
||||
create: { accountId: email, providerId: "credential", password: hash },
|
||||
},
|
||||
},
|
||||
select: { id: true, name: true },
|
||||
select: { id: true, name: true, email: true, phone: true },
|
||||
});
|
||||
} else {
|
||||
// Enrich existing user from the order, never overwriting meaningful data:
|
||||
// name only if the current one is empty or an email placeholder, phone only if empty.
|
||||
const enrich: { name?: string; phone?: string } = {};
|
||||
if (name && (!user.name || user.name === user.email)) enrich.name = name;
|
||||
if (phone && !user.phone) enrich.phone = phone;
|
||||
if (Object.keys(enrich).length > 0) {
|
||||
await prisma.user.update({ where: { id: user.id }, data: enrich });
|
||||
if (enrich.name) user.name = enrich.name;
|
||||
}
|
||||
}
|
||||
|
||||
// Enroll + audit log
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { headers } from "next/headers";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
// Streams a lesson material from the CDN with a forced attachment header and a
|
||||
// human-readable filename. Direct CDN links open the PDF inline in a new tab
|
||||
// (no Content-Disposition), which reads as "can't download" for users with a
|
||||
// popup blocker or on mobile. This route makes the browser save the file.
|
||||
|
||||
const CONTENT_TYPES: Record<string, string> = {
|
||||
pdf: "application/pdf",
|
||||
zip: "application/zip",
|
||||
doc: "application/msword",
|
||||
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
ppt: "application/vnd.ms-powerpoint",
|
||||
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
xls: "application/vnd.ms-excel",
|
||||
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
png: "image/png",
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
webp: "image/webp",
|
||||
};
|
||||
|
||||
function extFromUrl(url: string): string {
|
||||
const clean = url.split("?")[0];
|
||||
const dot = clean.lastIndexOf(".");
|
||||
return dot >= 0 ? clean.slice(dot + 1).toLowerCase() : "";
|
||||
}
|
||||
|
||||
// Types a browser renders inline (so the direct link "opens in a tab" instead of
|
||||
// downloading). Only these need proxying with a forced attachment header. ZIP,
|
||||
// DOCX, etc. already download natively → redirect straight to the CDN.
|
||||
const INLINE_EXT = new Set([
|
||||
"pdf", "png", "jpg", "jpeg", "webp", "gif", "svg", "avif",
|
||||
"txt", "htm", "html", "mp4", "mov", "webm", "mp3", "wav",
|
||||
]);
|
||||
// Above this, don't proxy through the app — redirect so the file streams from
|
||||
// the CDN/Caddy with Range support (e.g. the 157 MB Second Brain Vault archive).
|
||||
const PROXY_MAX_BYTES = 50 * 1024 * 1024;
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const file = await prisma.lessonFile.findUnique({
|
||||
where: { id },
|
||||
include: { lesson: { include: { module: { select: { courseId: true } } } } },
|
||||
});
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: "not_found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Access: staff always; students must be enrolled in the file's course.
|
||||
const role = session.user.role;
|
||||
if (role !== "admin" && role !== "curator") {
|
||||
const enrollment = await prisma.courseEnrollment.findFirst({
|
||||
where: { userId: session.user.id, courseId: file.lesson.module.courseId },
|
||||
select: { userId: true },
|
||||
});
|
||||
if (!enrollment) {
|
||||
return NextResponse.json({ error: "forbidden" }, { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
const ext = extFromUrl(file.url);
|
||||
|
||||
// Non-inline types download natively — send the browser straight to the CDN
|
||||
// (native download, Range/resume, no app load). Auth is already enforced above.
|
||||
if (!INLINE_EXT.has(ext)) {
|
||||
return NextResponse.redirect(file.url, 302);
|
||||
}
|
||||
|
||||
const upstream = await fetch(file.url);
|
||||
if (!upstream.ok || !upstream.body) {
|
||||
return NextResponse.json({ error: "file_unavailable" }, { status: 502 });
|
||||
}
|
||||
|
||||
// Guard: a very large inline file still shouldn't stream through the app.
|
||||
const upstreamLen = Number(upstream.headers.get("content-length") ?? "0");
|
||||
if (upstreamLen > PROXY_MAX_BYTES) {
|
||||
await upstream.body.cancel();
|
||||
return NextResponse.redirect(file.url, 302);
|
||||
}
|
||||
|
||||
const contentType =
|
||||
upstream.headers.get("content-type") ??
|
||||
CONTENT_TYPES[ext] ??
|
||||
"application/octet-stream";
|
||||
|
||||
// Build a readable filename: "<name>.<ext>" (avoid doubling the extension).
|
||||
let downloadName = file.name.trim();
|
||||
if (ext && !downloadName.toLowerCase().endsWith(`.${ext}`)) {
|
||||
downloadName += `.${ext}`;
|
||||
}
|
||||
const asciiName = downloadName.replace(/[^\x20-\x7E]+/g, "_").replace(/"/g, "");
|
||||
const disposition =
|
||||
`attachment; filename="${asciiName}"; ` +
|
||||
`filename*=UTF-8''${encodeURIComponent(downloadName)}`;
|
||||
|
||||
const respHeaders: Record<string, string> = {
|
||||
"Content-Type": contentType,
|
||||
"Content-Disposition": disposition,
|
||||
"Cache-Control": "private, no-store",
|
||||
};
|
||||
const len = upstream.headers.get("content-length");
|
||||
if (len) respHeaders["Content-Length"] = len;
|
||||
|
||||
return new NextResponse(upstream.body, { status: 200, headers: respHeaders });
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import {
|
||||
BURST_LIMIT, PDF_TOOL_ID, getBurstUsage, getMonthlyLimit, getMonthlyUsage, hasPaidAccess,
|
||||
} from "@/lib/clean-pdf/access";
|
||||
import { BlockedUrlError } from "@/lib/clean-pdf/ssrf";
|
||||
import { EmptyContentError, RenderError, generateCleanPdf } from "@/lib/clean-pdf/generate";
|
||||
import { safePdfFilename } from "@/lib/clean-pdf/template";
|
||||
import { PDF_KEY_PREFIX } from "@/lib/clean-pdf/api-key";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const querySchema = z.object({
|
||||
url: z.string().min(1).max(2000),
|
||||
format: z.enum(["A4", "Letter"]).default("A4"),
|
||||
theme: z.enum(["light", "dark"]).default("light"),
|
||||
});
|
||||
|
||||
function jsonError(status: number, error: string, extra?: Record<string, string>) {
|
||||
return NextResponse.json({ error }, { status, headers: extra });
|
||||
}
|
||||
|
||||
async function resolveUserId(req: NextRequest): Promise<string | null> {
|
||||
const authHeader = req.headers.get("authorization");
|
||||
if (authHeader?.startsWith("Bearer ")) {
|
||||
const key = authHeader.slice("Bearer ".length).trim();
|
||||
if (!key.startsWith(PDF_KEY_PREFIX)) return null;
|
||||
const record = await prisma.pdfApiKey.findUnique({ where: { key }, select: { userId: true } });
|
||||
return record?.userId ?? null;
|
||||
}
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
return session?.user.id ?? null;
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const userId = await resolveUserId(req);
|
||||
if (!userId) return jsonError(401, "Нужен API-ключ или вход в аккаунт школы");
|
||||
|
||||
if (!(await hasPaidAccess(userId))) {
|
||||
return jsonError(403, "Инструмент доступен студентам платных курсов школы");
|
||||
}
|
||||
|
||||
const parsed = querySchema.safeParse(Object.fromEntries(req.nextUrl.searchParams));
|
||||
if (!parsed.success) return jsonError(422, "Проверьте параметры: url, format (A4|Letter), theme (light|dark)");
|
||||
|
||||
if ((await getBurstUsage(userId)) >= BURST_LIMIT) {
|
||||
return jsonError(429, "Слишком часто: подождите минуту", { "X-Max-Uses": String(getMonthlyLimit()) });
|
||||
}
|
||||
const limit = getMonthlyLimit();
|
||||
const used = await getMonthlyUsage(userId);
|
||||
if (used >= limit) {
|
||||
return jsonError(429, `Лимит ${limit} PDF в месяц исчерпан`, {
|
||||
"X-Uses-Count": String(used), "X-Max-Uses": String(limit),
|
||||
});
|
||||
}
|
||||
|
||||
const { pdf, title } = await generateCleanPdf(parsed.data);
|
||||
await prisma.toolUsage.create({ data: { userId, tool: PDF_TOOL_ID } });
|
||||
|
||||
const filename = safePdfFilename(title);
|
||||
return new NextResponse(new Uint8Array(pdf), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": `attachment; filename="document.pdf"; filename*=UTF-8''${encodeURIComponent(filename)}.pdf`,
|
||||
"X-Uses-Count": String(used + 1),
|
||||
"X-Max-Uses": String(limit),
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof BlockedUrlError || e instanceof EmptyContentError) {
|
||||
return jsonError(422, e.message);
|
||||
}
|
||||
if (e instanceof RenderError) {
|
||||
console.error("[clean-pdf] render", e);
|
||||
return jsonError(504, "Не получилось отрендерить страницу, попробуйте позже");
|
||||
}
|
||||
console.error("[clean-pdf]", e);
|
||||
return jsonError(504, "Не получилось сгенерировать PDF, попробуйте позже");
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { linkify } from "@/lib/linkify";
|
||||
import { FeedbackForm } from "./feedback-form";
|
||||
import { ContentTabs } from "./content-tabs";
|
||||
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"
|
||||
style={{ border: "2px solid var(--border)" }}
|
||||
>
|
||||
{submission.text}
|
||||
{linkify(submission.text)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm italic" style={{ color: "var(--muted-foreground)" }}>
|
||||
|
||||
+2
-1
@@ -1,12 +1,13 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { headers } from "next/headers";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { WelcomePage } from "@/components/welcome/welcome-page";
|
||||
|
||||
export default async function HomePage() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
|
||||
if (!session) {
|
||||
redirect("/login");
|
||||
return <WelcomePage />;
|
||||
}
|
||||
|
||||
const role = session.user.role;
|
||||
|
||||
@@ -274,6 +274,11 @@ export function SettingsForm({ initial }: { initial: Settings }) {
|
||||
checked={bool("notifyOnHomework")}
|
||||
onChange={(v) => set("notifyOnHomework", v ? "true" : "false")}
|
||||
/>
|
||||
<Toggle
|
||||
label="Уведомлять о новом комментарии под уроком"
|
||||
checked={bool("notifyOnComment")}
|
||||
onChange={(v) => set("notifyOnComment", v ? "true" : "false")}
|
||||
/>
|
||||
<Toggle
|
||||
label="Уведомлять о новой регистрации ученика"
|
||||
checked={bool("notifyOnRegistration")}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useTransition } from "react";
|
||||
import { submitHomework } from "@/lib/actions/student-actions";
|
||||
import { AudioRecorder } from "@/components/curator/audio-recorder";
|
||||
import { linkify } from "@/lib/linkify";
|
||||
|
||||
interface HWFile { name: string; url: string; size: number }
|
||||
|
||||
@@ -99,7 +100,7 @@ export function HomeworkSection({ homework, submission, slug, lessonId, allowAud
|
||||
<div className="space-y-2">
|
||||
<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)" }}>
|
||||
{submission!.text || "—"}
|
||||
{submission!.text ? linkify(submission!.text) : "—"}
|
||||
</div>
|
||||
{submission!.audioUrl && (
|
||||
<div className="px-4 py-2" style={{ border: "1px solid var(--border)" }}>
|
||||
@@ -118,7 +119,7 @@ export function HomeworkSection({ homework, submission, slug, lessonId, allowAud
|
||||
{fb.curator.name} · {new Date(fb.createdAt).toLocaleDateString("ru-RU")}
|
||||
</span>
|
||||
</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 && (
|
||||
<div className="space-y-1">
|
||||
{fbFiles.map((f) => (
|
||||
@@ -155,7 +156,7 @@ export function HomeworkSection({ homework, submission, slug, lessonId, allowAud
|
||||
</div>
|
||||
{submission.text && (
|
||||
<div className="px-4 py-3 text-sm whitespace-pre-wrap opacity-70" style={{ border: "2px solid var(--border)" }}>
|
||||
{submission.text}
|
||||
{linkify(submission.text)}
|
||||
</div>
|
||||
)}
|
||||
{submission.audioUrl && (
|
||||
@@ -197,7 +198,7 @@ export function HomeworkSection({ homework, submission, slug, lessonId, allowAud
|
||||
</div>
|
||||
{submission.text && (
|
||||
<div className="px-4 py-3 text-sm whitespace-pre-wrap" style={{ border: "2px solid var(--border)" }}>
|
||||
{submission.text}
|
||||
{linkify(submission.text)}
|
||||
</div>
|
||||
)}
|
||||
{submission.audioUrl && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { MessageSquareQuote, FileCode2, Palette, SlidersHorizontal, Table2, Database, Wrench, type LucideIcon } from "lucide-react";
|
||||
import { MessageSquareQuote, FileCode2, Palette, SlidersHorizontal, Table2, Database, FileText, Wrench, type LucideIcon } from "lucide-react";
|
||||
import type { ToolMeta } from "@/lib/tools/_shared/types";
|
||||
|
||||
// Явная карта (не `import * as Icons`) — иначе весь набор lucide попадает в бандл.
|
||||
@@ -10,6 +10,7 @@ const ICONS: Record<string, LucideIcon> = {
|
||||
SlidersHorizontal,
|
||||
Table2,
|
||||
Database,
|
||||
FileText,
|
||||
};
|
||||
|
||||
export function ToolCard({ tool }: { tool: ToolMeta }) {
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
"use server";
|
||||
import { headers } from "next/headers";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { hasPaidAccess } from "@/lib/clean-pdf/access";
|
||||
import { regenerateKey } from "@/lib/clean-pdf/keys";
|
||||
|
||||
export async function regeneratePdfApiKey(): Promise<{ ok: boolean; key?: string }> {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) return { ok: false };
|
||||
if (!(await hasPaidAccess(session.user.id))) return { ok: false };
|
||||
const key = await regenerateKey(session.user.id);
|
||||
return { ok: true, key };
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { prisma } from "@/lib/prisma";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { sendHomeworkSubmittedEmail } from "@/lib/email";
|
||||
import { sendHomeworkSubmittedEmail, sendCommentNotificationEmail } from "@/lib/email";
|
||||
import { getSettings, parseNotificationEmails, asBool } from "@/lib/settings";
|
||||
|
||||
// ── Lesson Progress ───────────────────────────────────────────────────────────
|
||||
@@ -156,7 +156,7 @@ export async function addComment(lessonId: string, slug: string, text: string, p
|
||||
|
||||
const lesson = await prisma.lesson.findUnique({
|
||||
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");
|
||||
|
||||
@@ -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 } : {}) },
|
||||
});
|
||||
|
||||
// Уведомление админам/кураторам о новом комментарии студента (не о своих ответах)
|
||||
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}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { decidePaidAccess, monthStartUtc } from "@/lib/clean-pdf/access";
|
||||
|
||||
const now = new Date("2026-07-06T10:00:00Z");
|
||||
const freeSlug = "obsidian-start";
|
||||
|
||||
describe("decidePaidAccess", () => {
|
||||
it("платный enrollment без срока — да", () => {
|
||||
expect(decidePaidAccess({ role: "student", banned: false, freeSlug, now,
|
||||
enrollments: [{ courseSlug: "zotero", expiresAt: null }] })).toBe(true);
|
||||
});
|
||||
|
||||
it("только бесплатный курс — нет", () => {
|
||||
expect(decidePaidAccess({ role: "student", banned: false, freeSlug, now,
|
||||
enrollments: [{ courseSlug: "obsidian-start", expiresAt: null }] })).toBe(false);
|
||||
});
|
||||
|
||||
it("без enrollments — нет", () => {
|
||||
expect(decidePaidAccess({ role: "student", banned: false, freeSlug, now, enrollments: [] })).toBe(false);
|
||||
});
|
||||
|
||||
it("истёкший платный — нет, живой срок — да", () => {
|
||||
expect(decidePaidAccess({ role: "student", banned: false, freeSlug, now,
|
||||
enrollments: [{ courseSlug: "zotero", expiresAt: new Date("2026-01-01") }] })).toBe(false);
|
||||
expect(decidePaidAccess({ role: "student", banned: false, freeSlug, now,
|
||||
enrollments: [{ courseSlug: "zotero", expiresAt: new Date("2027-01-01") }] })).toBe(true);
|
||||
});
|
||||
|
||||
it("admin и curator — да даже без курсов", () => {
|
||||
expect(decidePaidAccess({ role: "admin", banned: false, freeSlug, now, enrollments: [] })).toBe(true);
|
||||
expect(decidePaidAccess({ role: "curator", banned: false, freeSlug, now, enrollments: [] })).toBe(true);
|
||||
});
|
||||
|
||||
it("бан всё перекрывает", () => {
|
||||
expect(decidePaidAccess({ role: "admin", banned: true, freeSlug, now, enrollments: [] })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("monthStartUtc", () => {
|
||||
it("возвращает первое число месяца 00:00 UTC", () => {
|
||||
expect(monthStartUtc(new Date("2026-07-06T23:59:59Z")).toISOString()).toBe("2026-07-01T00:00:00.000Z");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PDF_KEY_PREFIX, generatePdfKey } from "@/lib/clean-pdf/api-key";
|
||||
|
||||
describe("generatePdfKey", () => {
|
||||
it("формат sbpdf_<base64url>, достаточная длина", () => {
|
||||
const key = generatePdfKey();
|
||||
expect(key.startsWith(PDF_KEY_PREFIX)).toBe(true);
|
||||
expect(key.length).toBeGreaterThanOrEqual(PDF_KEY_PREFIX.length + 32);
|
||||
expect(key.slice(PDF_KEY_PREFIX.length)).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
});
|
||||
|
||||
it("две генерации различаются", () => {
|
||||
expect(generatePdfKey()).not.toBe(generatePdfKey());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { generateCleanPdf } from "@/lib/clean-pdf/generate";
|
||||
|
||||
const enabled = process.env.RUN_PDF_INTEGRATION === "1";
|
||||
|
||||
describe.runIf(enabled)("generateCleanPdf (integration, нужен browserless)", () => {
|
||||
it("генерирует PDF реальной статьи", async () => {
|
||||
const { pdf, title } = await generateCleanPdf({
|
||||
url: "https://habr.com/ru/articles/942236/",
|
||||
format: "A4",
|
||||
theme: "light",
|
||||
});
|
||||
expect(pdf.length).toBeGreaterThan(20_000);
|
||||
expect(pdf.subarray(0, 5).toString()).toBe("%PDF-");
|
||||
expect(title.length).toBeGreaterThan(3);
|
||||
}, 120_000);
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BlockedUrlError, assertPublicUrl, isPrivateAddress } from "@/lib/clean-pdf/ssrf";
|
||||
|
||||
describe("isPrivateAddress", () => {
|
||||
const privateIps = [
|
||||
"127.0.0.1", "0.0.0.0", "10.1.2.3", "100.64.0.1", "169.254.169.254",
|
||||
"172.16.0.1", "172.31.255.255", "192.168.1.1", "192.0.0.8", "198.18.0.1",
|
||||
"224.0.0.1", "255.255.255.255",
|
||||
"::1", "::", "fc00::1", "fd12:3456::1", "fe80::1", "::ffff:10.0.0.1", "::ffff:127.0.0.1",
|
||||
"::ffff:7f00:1", "::ffff:a00:1", "::ffff:c0a8:101", "::ffff:a9fe:a9fe",
|
||||
];
|
||||
const publicIps = ["93.184.216.34", "8.8.8.8", "172.32.0.1", "2606:2800:220:1::1", "::ffff:8.8.8.8", "::ffff:808:808"];
|
||||
|
||||
it.each(privateIps)("блокирует %s", (ip) => expect(isPrivateAddress(ip)).toBe(true));
|
||||
it.each(publicIps)("пропускает %s", (ip) => expect(isPrivateAddress(ip)).toBe(false));
|
||||
});
|
||||
|
||||
describe("assertPublicUrl", () => {
|
||||
const publicResolve = async () => [{ address: "93.184.216.34", family: 4 }];
|
||||
const privateResolve = async () => [{ address: "172.18.0.2", family: 4 }];
|
||||
const mixedResolve = async () => [
|
||||
{ address: "93.184.216.34", family: 4 },
|
||||
{ address: "10.0.0.5", family: 4 },
|
||||
];
|
||||
|
||||
it("пропускает публичный https-URL", async () => {
|
||||
const url = await assertPublicUrl("https://example.com/article", publicResolve);
|
||||
expect(url.hostname).toBe("example.com");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"file:///etc/passwd",
|
||||
"ftp://example.com/x",
|
||||
"chrome://settings",
|
||||
"not a url",
|
||||
])("блокирует %s", async (raw) => {
|
||||
await expect(assertPublicUrl(raw, publicResolve)).rejects.toThrow(BlockedUrlError);
|
||||
});
|
||||
|
||||
it("блокирует localhost и .local без резолва", async () => {
|
||||
await expect(assertPublicUrl("http://localhost:3000/x", publicResolve)).rejects.toThrow(BlockedUrlError);
|
||||
await expect(assertPublicUrl("http://printer.local/x", publicResolve)).rejects.toThrow(BlockedUrlError);
|
||||
});
|
||||
|
||||
it("блокирует литеральный приватный IP", async () => {
|
||||
await expect(assertPublicUrl("http://192.168.1.1/admin", publicResolve)).rejects.toThrow(BlockedUrlError);
|
||||
await expect(assertPublicUrl("http://[::1]:8080/", publicResolve)).rejects.toThrow(BlockedUrlError);
|
||||
});
|
||||
|
||||
it("блокирует хост, резолвящийся в приватный IP (включая частично)", async () => {
|
||||
await expect(assertPublicUrl("https://evil.example/x", privateResolve)).rejects.toThrow(BlockedUrlError);
|
||||
await expect(assertPublicUrl("https://evil.example/x", mixedResolve)).rejects.toThrow(BlockedUrlError);
|
||||
});
|
||||
|
||||
it("блокирует хост, который не резолвится", async () => {
|
||||
const failResolve = async () => { throw new Error("ENOTFOUND"); };
|
||||
await expect(assertPublicUrl("https://nope.example/x", failResolve)).rejects.toThrow(BlockedUrlError);
|
||||
});
|
||||
|
||||
it("блокирует v4-mapped IPv6 в bracket-нотации (SSRF-обход)", async () => {
|
||||
for (const raw of [
|
||||
"http://[::ffff:127.0.0.1]/",
|
||||
"http://[::ffff:10.0.0.1]/",
|
||||
"http://[::ffff:169.254.169.254]/",
|
||||
"http://[::ffff:192.168.1.1]/",
|
||||
]) {
|
||||
await expect(assertPublicUrl(raw, publicResolve)).rejects.toThrow(BlockedUrlError);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildCleanHtml, safePdfFilename } from "@/lib/clean-pdf/template";
|
||||
|
||||
const base = {
|
||||
title: "Заголовок статьи",
|
||||
url: "https://example.com/article",
|
||||
theme: "light" as const,
|
||||
};
|
||||
|
||||
describe("buildCleanHtml", () => {
|
||||
it("экранирует HTML в метаполях", () => {
|
||||
const html = buildCleanHtml({ ...base, title: `<script>alert(1)</script>`, contentHtml: "<p>ок</p>" });
|
||||
expect(html).not.toContain("<script>alert(1)</script>");
|
||||
expect(html).toContain("<script>");
|
||||
});
|
||||
|
||||
it("вставляет контент и шапку", () => {
|
||||
const html = buildCleanHtml({ ...base, author: "Автор", site: "Example", contentHtml: "<p>Текст статьи</p>" });
|
||||
expect(html).toContain("<p>Текст статьи</p>");
|
||||
expect(html).toContain("Заголовок статьи");
|
||||
expect(html).toContain("Автор");
|
||||
expect(html).toContain("https://example.com/article");
|
||||
});
|
||||
|
||||
it("строит оглавление при 3+ заголовках и проставляет якоря", () => {
|
||||
const content = "<h2>Один</h2><p>a</p><h2>Два</h2><p>b</p><h3>Три</h3><p>c</p>";
|
||||
const html = buildCleanHtml({ ...base, contentHtml: content });
|
||||
expect(html).toContain("Содержание");
|
||||
expect(html).toMatch(/<h2 id="[^"]+">Один<\/h2>/);
|
||||
expect((html.match(/class="toc-item/g) ?? []).length).toBe(3);
|
||||
});
|
||||
|
||||
it("не строит оглавление при <3 заголовках", () => {
|
||||
const html = buildCleanHtml({ ...base, contentHtml: "<h2>Один</h2><p>a</p>" });
|
||||
expect(html).not.toContain("Содержание");
|
||||
});
|
||||
|
||||
it("уникализирует одинаковые якоря", () => {
|
||||
const content = "<h2>Раздел</h2><h2>Раздел</h2><h2>Раздел</h2>";
|
||||
const html = buildCleanHtml({ ...base, contentHtml: content });
|
||||
const ids = [...html.matchAll(/<h2 id="([^"]+)"/g)].map((m) => m[1]);
|
||||
expect(new Set(ids).size).toBe(3);
|
||||
});
|
||||
|
||||
it("вырезает script и inline-обработчики из контента", () => {
|
||||
const html = buildCleanHtml({ ...base, contentHtml: `<p onclick="alert(1)">t</p><script>alert(2)</script><a href="javascript:alert(3)">x</a>` });
|
||||
expect(html).not.toContain("<script>alert(2)");
|
||||
expect(html).not.toContain("onclick");
|
||||
expect(html).not.toContain("javascript:alert(3)");
|
||||
expect(html).toContain(">t<");
|
||||
});
|
||||
|
||||
it("переключает тёмную тему", () => {
|
||||
const light = buildCleanHtml({ ...base, contentHtml: "<p>x</p>" });
|
||||
const dark = buildCleanHtml({ ...base, theme: "dark", contentHtml: "<p>x</p>" });
|
||||
expect(light).toContain('data-theme="light"');
|
||||
expect(dark).toContain('data-theme="dark"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("safePdfFilename", () => {
|
||||
it("убирает опасные символы и ограничивает длину", () => {
|
||||
expect(safePdfFilename('Статья: как/не\\надо "делать"?')).toBe("Статья_ как_не_надо _делать_");
|
||||
expect(safePdfFilename("")).toBe("document");
|
||||
expect(safePdfFilename("a".repeat(300)).length).toBeLessThanOrEqual(140);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildZoteroScript } from "@/lib/clean-pdf/zotero-script";
|
||||
|
||||
describe("buildZoteroScript", () => {
|
||||
it("подставляет ключ и адрес школы", () => {
|
||||
const s = buildZoteroScript({ apiKey: "sbpdf_test123", baseUrl: "https://school.second-brain.ru" });
|
||||
expect(s).toContain("'sbpdf_test123'");
|
||||
expect(s).toContain("'https://school.second-brain.ru'");
|
||||
expect(s).toContain("/api/pdf?url=");
|
||||
expect(s).not.toContain("YOUR_KEY");
|
||||
});
|
||||
|
||||
it("генерирует синтаксически валидный JS", () => {
|
||||
const s = buildZoteroScript({ apiKey: "sbpdf_test123", baseUrl: "https://school.second-brain.ru" });
|
||||
expect(() => new Function(s)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
// НЕ "clean-pdf": так CopyButton логирует копирования — они не должны тратить лимит
|
||||
export const PDF_TOOL_ID = "clean-pdf-generate";
|
||||
export const BURST_LIMIT = 5; // успешных генераций в минуту
|
||||
const BURST_WINDOW_MS = 60_000;
|
||||
|
||||
export function decidePaidAccess(input: {
|
||||
role: string;
|
||||
banned: boolean;
|
||||
enrollments: { courseSlug: string; expiresAt: Date | null }[];
|
||||
freeSlug: string;
|
||||
now?: Date;
|
||||
}): boolean {
|
||||
const now = input.now ?? new Date();
|
||||
if (input.banned) return false;
|
||||
if (input.role === "admin" || input.role === "curator") return true;
|
||||
return input.enrollments.some(
|
||||
(e) => e.courseSlug !== input.freeSlug && (e.expiresAt === null || e.expiresAt > now),
|
||||
);
|
||||
}
|
||||
|
||||
export async function hasPaidAccess(userId: string): Promise<boolean> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
role: true,
|
||||
banned: true,
|
||||
enrollments: { select: { expiresAt: true, course: { select: { slug: true } } } },
|
||||
},
|
||||
});
|
||||
if (!user) return false;
|
||||
return decidePaidAccess({
|
||||
role: user.role,
|
||||
banned: user.banned ?? false,
|
||||
freeSlug: process.env.FREE_COURSE_SLUG ?? "obsidian-start",
|
||||
enrollments: user.enrollments.map((e) => ({ courseSlug: e.course.slug, expiresAt: e.expiresAt })),
|
||||
});
|
||||
}
|
||||
|
||||
export function monthStartUtc(now: Date): Date {
|
||||
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
||||
}
|
||||
|
||||
export function getMonthlyLimit(): number {
|
||||
const n = Number(process.env.PDF_MONTHLY_LIMIT);
|
||||
return Number.isFinite(n) && n > 0 ? n : 100;
|
||||
}
|
||||
|
||||
export async function getMonthlyUsage(userId: string): Promise<number> {
|
||||
return prisma.toolUsage.count({
|
||||
where: { userId, tool: PDF_TOOL_ID, createdAt: { gte: monthStartUtc(new Date()) } },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getBurstUsage(userId: string): Promise<number> {
|
||||
return prisma.toolUsage.count({
|
||||
where: { userId, tool: PDF_TOOL_ID, createdAt: { gte: new Date(Date.now() - BURST_WINDOW_MS) } },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
export const PDF_KEY_PREFIX = "sbpdf_";
|
||||
|
||||
export function generatePdfKey(): string {
|
||||
return PDF_KEY_PREFIX + randomBytes(24).toString("base64url");
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { isIP } from "node:net";
|
||||
import { chromium } from "playwright-core";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { Defuddle } from "defuddle/node";
|
||||
import { assertPublicUrl, isPrivateAddress } from "@/lib/clean-pdf/ssrf";
|
||||
import { buildCleanHtml } from "@/lib/clean-pdf/template";
|
||||
|
||||
export class EmptyContentError extends Error {}
|
||||
export class RenderError extends Error {}
|
||||
|
||||
/**
|
||||
* Оборачивает промис в app-level таймаут: если `p` не завершится за `ms`,
|
||||
* гонка завершится отказом с RenderError, и внешний `finally` (browser.close())
|
||||
* освободит слот browserless вместо того, чтобы держать его до зависшего вызова.
|
||||
*/
|
||||
function withTimeout<T>(p: Promise<T>, ms: number, message: string): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const guard = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new RenderError(message)), ms);
|
||||
timer.unref?.();
|
||||
});
|
||||
return Promise.race([p, guard]).finally(() => clearTimeout(timer)) as Promise<T>;
|
||||
}
|
||||
|
||||
const GOTO_TIMEOUT_MS = 30_000;
|
||||
const SETTLE_TIMEOUT_MS = 10_000;
|
||||
const PDF_TIMEOUT_MS = 30_000;
|
||||
const UA =
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36";
|
||||
|
||||
function browserWsUrl(): string {
|
||||
const base = process.env.BROWSER_WS_URL;
|
||||
if (!base) throw new RenderError("BROWSER_WS_URL не задан");
|
||||
const token = process.env.BROWSERLESS_TOKEN;
|
||||
return token ? `${base}${base.includes("?") ? "&" : "?"}token=${token}` : base;
|
||||
}
|
||||
|
||||
export async function generateCleanPdf(opts: {
|
||||
url: string;
|
||||
format: "A4" | "Letter";
|
||||
theme: "light" | "dark";
|
||||
}): Promise<{ pdf: Buffer; title: string }> {
|
||||
const target = await assertPublicUrl(opts.url);
|
||||
|
||||
const browser = await chromium.connectOverCDP(browserWsUrl()).catch((e) => {
|
||||
throw new RenderError(`Браузер недоступен: ${e.message}`);
|
||||
});
|
||||
|
||||
try {
|
||||
const context = await browser.newContext({ userAgent: UA, viewport: { width: 1280, height: 800 } });
|
||||
// Вторая линия SSRF-обороны: страница не может дёргать приватные адреса
|
||||
// (литеральные IP и localhost; hostnames уже проверены до goto).
|
||||
await context.route("**/*", (route) => {
|
||||
try {
|
||||
const u = new URL(route.request().url());
|
||||
const host = u.hostname.replace(/^\[|\]$/g, "").replace(/\.$/, "");
|
||||
if (
|
||||
(u.protocol !== "http:" && u.protocol !== "https:") ||
|
||||
host === "localhost" || host.endsWith(".local") || host.endsWith(".internal") ||
|
||||
(isIP(host) !== 0 && isPrivateAddress(host))
|
||||
) {
|
||||
return route.abort();
|
||||
}
|
||||
} catch {
|
||||
return route.abort();
|
||||
}
|
||||
return route.continue();
|
||||
});
|
||||
|
||||
// context.route НЕ перехватывает WebSocket — отдельная защита от SSRF через ws://
|
||||
await context.routeWebSocket("**/*", (ws) => {
|
||||
try {
|
||||
const u = new URL(ws.url());
|
||||
const host = u.hostname.replace(/^\[|\]$/g, "").replace(/\.$/, "");
|
||||
if (
|
||||
(u.protocol !== "ws:" && u.protocol !== "wss:") ||
|
||||
host === "localhost" || host.endsWith(".local") || host.endsWith(".internal") ||
|
||||
(isIP(host) !== 0 && isPrivateAddress(host))
|
||||
) {
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
ws.connectToServer();
|
||||
});
|
||||
|
||||
const page = await context.newPage();
|
||||
await page.goto(target.href, { waitUntil: "domcontentloaded", timeout: GOTO_TIMEOUT_MS }).catch((e) => {
|
||||
throw new RenderError(`Страница не открылась: ${e.message}`);
|
||||
});
|
||||
await page.waitForLoadState("networkidle", { timeout: SETTLE_TIMEOUT_MS }).catch(() => {});
|
||||
|
||||
const rawHtml = await page.content();
|
||||
const pageTitle = await page.title().catch(() => "");
|
||||
|
||||
const dom = new JSDOM(rawHtml, { url: target.href });
|
||||
const article = await Defuddle(dom.window.document, target.href);
|
||||
// DefuddleResponse (node_modules/defuddle/dist/types.d.ts) типизирует
|
||||
// content/title/author/site/domain/wordCount как обязательные (не optional) поля —
|
||||
// сам объект тоже не nullable (Defuddle() не возвращает null/undefined).
|
||||
// Отсутствие контента выражается пустой строкой/нулевым wordCount, не отсутствием поля.
|
||||
if (!article.content || article.wordCount < 10) {
|
||||
throw new EmptyContentError("Не удалось выделить содержимое страницы");
|
||||
}
|
||||
|
||||
const title = article.title || pageTitle || target.hostname;
|
||||
const cleanHtml = buildCleanHtml({
|
||||
title,
|
||||
author: article.author || undefined,
|
||||
site: article.site || article.domain || undefined,
|
||||
url: target.href,
|
||||
contentHtml: article.content,
|
||||
theme: opts.theme,
|
||||
});
|
||||
|
||||
const pdfPage = await context.newPage();
|
||||
await pdfPage.setContent(cleanHtml, { waitUntil: "networkidle", timeout: SETTLE_TIMEOUT_MS }).catch(() => {});
|
||||
// page.pdf() в playwright-core не принимает опцию timeout (проверено по
|
||||
// node_modules/playwright-core/types/types.d.ts) и не имеет implicit-бага —
|
||||
// оборачиваем в app-level withTimeout, чтобы зависание всё же дошло до
|
||||
// finally (browser.close()) и не держало CONCURRENT-слот browserless.
|
||||
const pdf = await withTimeout(
|
||||
pdfPage.pdf({
|
||||
format: opts.format,
|
||||
printBackground: true,
|
||||
margin: { top: "15mm", bottom: "18mm", left: "15mm", right: "15mm" },
|
||||
}),
|
||||
PDF_TIMEOUT_MS,
|
||||
"Печать PDF не завершилась вовремя",
|
||||
);
|
||||
|
||||
return { pdf, title };
|
||||
} finally {
|
||||
await browser.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { generatePdfKey } from "@/lib/clean-pdf/api-key";
|
||||
|
||||
export async function getOrCreatePdfKey(userId: string): Promise<string> {
|
||||
const existing = await prisma.pdfApiKey.findUnique({ where: { userId } });
|
||||
if (existing) return existing.key;
|
||||
const created = await prisma.pdfApiKey.upsert({
|
||||
where: { userId },
|
||||
create: { userId, key: generatePdfKey() },
|
||||
update: {},
|
||||
});
|
||||
return created.key;
|
||||
}
|
||||
|
||||
export async function regenerateKey(userId: string): Promise<string> {
|
||||
const key = generatePdfKey();
|
||||
await prisma.pdfApiKey.upsert({
|
||||
where: { userId },
|
||||
create: { userId, key },
|
||||
update: { key },
|
||||
});
|
||||
return key;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { lookup } from "node:dns/promises";
|
||||
import { isIP } from "node:net";
|
||||
|
||||
export class BlockedUrlError extends Error {}
|
||||
|
||||
export type LookupFn = (
|
||||
hostname: string,
|
||||
opts: { all: true },
|
||||
) => Promise<{ address: string; family: number }[]>;
|
||||
|
||||
function v4ToInt(ip: string): number {
|
||||
return ip.split(".").reduce((acc, o) => acc * 256 + Number(o), 0);
|
||||
}
|
||||
|
||||
// [начало включительно, конец включительно] в виде 32-битных чисел
|
||||
const V4_PRIVATE: Array<[number, number]> = [
|
||||
["0.0.0.0", "0.255.255.255"], // "this network"
|
||||
["10.0.0.0", "10.255.255.255"], // RFC1918
|
||||
["100.64.0.0", "100.127.255.255"], // CGNAT
|
||||
["127.0.0.0", "127.255.255.255"], // loopback
|
||||
["169.254.0.0", "169.254.255.255"], // link-local / cloud metadata
|
||||
["172.16.0.0", "172.31.255.255"], // RFC1918 (docker-сети попадают сюда)
|
||||
["192.0.0.0", "192.0.0.255"], // IETF protocol assignments
|
||||
["192.168.0.0", "192.168.255.255"], // RFC1918
|
||||
["198.18.0.0", "198.19.255.255"], // benchmarking
|
||||
["224.0.0.0", "255.255.255.255"], // multicast + reserved + broadcast
|
||||
].map(([a, b]) => [v4ToInt(a), v4ToInt(b)] as [number, number]);
|
||||
|
||||
function isPrivateV4(ip: string): boolean {
|
||||
const n = v4ToInt(ip);
|
||||
return V4_PRIVATE.some(([lo, hi]) => n >= lo && n <= hi);
|
||||
}
|
||||
|
||||
/** Разворачивает IPv6 (в т.ч. сжатый `::` и v4-mapped/embedded dotted) в 8 групп по 16 бит. */
|
||||
function expandV6(ip: string): number[] | null {
|
||||
let s = ip.toLowerCase();
|
||||
|
||||
// Встроенный dotted-хвост (::ffff:127.0.0.1, ::127.0.0.1) → в hex-группы
|
||||
const dotted = s.match(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
|
||||
if (dotted) {
|
||||
if (isIP(dotted[1]) !== 4) return null;
|
||||
const n = v4ToInt(dotted[1]);
|
||||
s = s.slice(0, dotted.index) + ((n >>> 16) & 0xffff).toString(16) + ":" + (n & 0xffff).toString(16);
|
||||
}
|
||||
|
||||
const halves = s.split("::");
|
||||
if (halves.length > 2) return null;
|
||||
const head = halves[0] ? halves[0].split(":") : [];
|
||||
const tail = halves.length === 2 ? (halves[1] ? halves[1].split(":") : []) : [];
|
||||
|
||||
let groups: string[];
|
||||
if (halves.length === 2) {
|
||||
const missing = 8 - head.length - tail.length;
|
||||
if (missing < 0) return null;
|
||||
groups = [...head, ...Array(missing).fill("0"), ...tail];
|
||||
} else {
|
||||
groups = head;
|
||||
}
|
||||
if (groups.length !== 8) return null;
|
||||
|
||||
const nums = groups.map((g) => (g === "" ? 0 : parseInt(g, 16)));
|
||||
if (nums.some((x) => Number.isNaN(x) || x < 0 || x > 0xffff)) return null;
|
||||
return nums;
|
||||
}
|
||||
|
||||
export function isPrivateAddress(ip: string): boolean {
|
||||
if (isIP(ip) === 4) return isPrivateV4(ip);
|
||||
if (isIP(ip) !== 6) return true; // не IP — не пропускаем
|
||||
|
||||
const groups = expandV6(ip);
|
||||
if (!groups) return true; // не смогли разобрать v6 — не пропускаем
|
||||
|
||||
// ::/96 (v4-compatible, вкл. :: и ::1) и ::ffff:0:0/96 (v4-mapped):
|
||||
// младшие 32 бита содержат IPv4 — проверяем его напрямую.
|
||||
const firstFiveZero = groups.slice(0, 5).every((g) => g === 0);
|
||||
if (firstFiveZero && (groups[5] === 0 || groups[5] === 0xffff)) {
|
||||
const v4num = groups[6] * 0x10000 + groups[7];
|
||||
const dottedV4 = `${(v4num >>> 24) & 255}.${(v4num >>> 16) & 255}.${(v4num >>> 8) & 255}.${v4num & 255}`;
|
||||
return isPrivateV4(dottedV4);
|
||||
}
|
||||
|
||||
const first = groups[0];
|
||||
if (first >= 0xfc00 && first <= 0xfdff) return true; // fc00::/7 (ULA)
|
||||
if (first >= 0xfe80 && first <= 0xfebf) return true; // fe80::/10 (link-local)
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function assertPublicUrl(raw: string, resolve: LookupFn = lookup): Promise<URL> {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
throw new BlockedUrlError("Некорректный URL");
|
||||
}
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
throw new BlockedUrlError("Поддерживаются только http и https");
|
||||
}
|
||||
|
||||
const hostname = url.hostname.replace(/^\[|\]$/g, "").replace(/\.$/, ""); // [::1] → ::1
|
||||
if (hostname === "localhost" || hostname.endsWith(".local") || hostname.endsWith(".internal")) {
|
||||
throw new BlockedUrlError("Адрес недоступен");
|
||||
}
|
||||
|
||||
if (isIP(hostname)) {
|
||||
if (isPrivateAddress(hostname)) throw new BlockedUrlError("Адрес недоступен");
|
||||
return url;
|
||||
}
|
||||
|
||||
let addresses: { address: string; family: number }[];
|
||||
try {
|
||||
addresses = await resolve(hostname, { all: true });
|
||||
} catch {
|
||||
throw new BlockedUrlError("Не удалось определить адрес сайта");
|
||||
}
|
||||
if (addresses.length === 0 || addresses.some((a) => isPrivateAddress(a.address))) {
|
||||
throw new BlockedUrlError("Адрес недоступен");
|
||||
}
|
||||
return url;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
export interface CleanArticle {
|
||||
title: string;
|
||||
author?: string;
|
||||
site?: string;
|
||||
url: string;
|
||||
contentHtml: string;
|
||||
theme: "light" | "dark";
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
export function safePdfFilename(title: string): string {
|
||||
const cleaned = title.replace(/[\\/:"*?<>|\n\r]+/g, "_").trim().slice(0, 140);
|
||||
return cleaned || "document";
|
||||
}
|
||||
|
||||
interface TocEntry { id: string; text: string; level: 2 | 3 }
|
||||
|
||||
/** Проставляет id заголовкам h2/h3 и возвращает контент + записи оглавления. */
|
||||
function prepareContent(contentHtml: string): { html: string; toc: TocEntry[] } {
|
||||
const dom = new JSDOM(`<body>${contentHtml}</body>`);
|
||||
const doc = dom.window.document;
|
||||
const used = new Set<string>();
|
||||
const toc: TocEntry[] = [];
|
||||
|
||||
doc.querySelectorAll("h2, h3").forEach((h) => {
|
||||
const text = (h.textContent ?? "").trim();
|
||||
if (!text) return;
|
||||
let id = text.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "") || "section";
|
||||
let i = 2;
|
||||
while (used.has(id)) id = `${id}-${i++}`;
|
||||
used.add(id);
|
||||
h.id = id;
|
||||
toc.push({ id, text, level: h.tagName === "H2" ? 2 : 3 });
|
||||
});
|
||||
|
||||
// Defuddle отдаёт очищенный, но не полностью доверенный HTML исходной
|
||||
// страницы — вырезаем активное содержимое перед печатью в реальном браузере.
|
||||
doc.querySelectorAll("script, style, iframe, object, embed").forEach((el) => el.remove());
|
||||
doc.querySelectorAll("*").forEach((el) => {
|
||||
for (const attr of [...el.attributes]) {
|
||||
if (attr.name.startsWith("on") || (attr.name === "href" && attr.value.trim().toLowerCase().startsWith("javascript:"))) {
|
||||
el.removeAttribute(attr.name);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { html: doc.body.innerHTML, toc };
|
||||
}
|
||||
|
||||
const CSS = `
|
||||
:root[data-theme="light"] { --bg: #faf7f0; --fg: #1f1d1a; --muted: #6b6459; --line: #d8d2c4; --accent: #7a2e2e; }
|
||||
:root[data-theme="dark"] { --bg: #201e1b; --fg: #e8e4dc; --muted: #a39b8d; --line: #3d3a34; --accent: #d4a0a0; }
|
||||
* { box-sizing: border-box; }
|
||||
body { background: var(--bg); color: var(--fg); font-family: Georgia, "Times New Roman", serif;
|
||||
font-size: 12.5pt; line-height: 1.65; margin: 0; }
|
||||
main { max-width: 100%; }
|
||||
h1 { font-size: 22pt; line-height: 1.25; margin: 0 0 6pt; }
|
||||
h2 { font-size: 16pt; margin: 20pt 0 8pt; }
|
||||
h3 { font-size: 13.5pt; margin: 16pt 0 6pt; }
|
||||
p { margin: 0 0 9pt; }
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
img, video, iframe { max-width: 100%; height: auto; }
|
||||
pre { background: rgba(127,127,127,.08); border: 1px solid var(--line); padding: 8pt;
|
||||
overflow-x: hidden; white-space: pre-wrap; word-wrap: break-word;
|
||||
font-family: "SF Mono", Menlo, Consolas, monospace; font-size: 9.5pt; }
|
||||
code { font-family: "SF Mono", Menlo, Consolas, monospace; font-size: 0.9em; }
|
||||
blockquote { border-left: 3px solid var(--line); margin: 0 0 9pt; padding: 2pt 0 2pt 12pt; color: var(--muted); }
|
||||
table { border-collapse: collapse; width: 100%; font-size: 10.5pt; }
|
||||
th, td { border: 1px solid var(--line); padding: 4pt 6pt; text-align: left; }
|
||||
figure { margin: 0 0 9pt; } figcaption { color: var(--muted); font-size: 9.5pt; }
|
||||
.meta { color: var(--muted); font-size: 10pt; margin-bottom: 4pt; }
|
||||
.meta a { color: var(--muted); }
|
||||
.head-rule { border: 0; border-top: 1px solid var(--line); margin: 12pt 0 16pt; }
|
||||
.toc { border: 1px solid var(--line); padding: 10pt 14pt; margin: 0 0 16pt; }
|
||||
.toc-title { font-weight: bold; margin-bottom: 6pt; }
|
||||
.toc-item { display: block; margin: 2pt 0; }
|
||||
.toc-item.lvl3 { padding-left: 14pt; font-size: 0.92em; }
|
||||
h2, h3 { break-after: avoid; } pre, blockquote, figure, table { break-inside: avoid; }
|
||||
`;
|
||||
|
||||
export function buildCleanHtml(article: CleanArticle): string {
|
||||
const { html, toc } = prepareContent(article.contentHtml);
|
||||
const metaParts = [article.site, article.author].filter(Boolean).map((s) => escapeHtml(s!));
|
||||
|
||||
const tocHtml = toc.length >= 3
|
||||
? `<nav class="toc"><div class="toc-title">Содержание</div>${toc
|
||||
.map((t) => `<a class="toc-item lvl${t.level}" href="#${t.id}">${escapeHtml(t.text)}</a>`)
|
||||
.join("")}</nav>`
|
||||
: "";
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="ru" data-theme="${article.theme}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>${escapeHtml(article.title)}</title>
|
||||
<style>${CSS}</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>${escapeHtml(article.title)}</h1>
|
||||
${metaParts.length ? `<div class="meta">${metaParts.join(" · ")}</div>` : ""}
|
||||
<div class="meta"><a href="${escapeHtml(article.url)}">${escapeHtml(article.url)}</a></div>
|
||||
<hr class="head-rule">
|
||||
${tocHtml}
|
||||
${html}
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Адаптация скрипта Clean PDF (pdf.brainysnipe.ru/zotero-script.js) под школу.
|
||||
export function buildZoteroScript({ apiKey, baseUrl }: { apiKey: string; baseUrl: string }): string {
|
||||
return `// ── Настройки ──────────────────────────────────────────────────────────────
|
||||
const API_KEY = '${apiKey}'; // персональный ключ из ${baseUrl}/tools/clean-pdf
|
||||
const SERVICE_URL = '${baseUrl}';
|
||||
const FORMAT = 'A4'; // 'A4' или 'Letter'
|
||||
const THEME = 'light'; // 'light' или 'dark'
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
if (typeof item === "undefined" || !item) return;
|
||||
(async function () {
|
||||
let tempFilePath = null;
|
||||
try {
|
||||
function joinPath(dir, filename) {
|
||||
if (typeof OS !== "undefined" && OS.Path && OS.Path.join) return OS.Path.join(dir, filename);
|
||||
const separator = dir.includes("\\\\") ? "\\\\" : "/";
|
||||
return dir.replace(/[\\\\/]+$/, "") + separator + filename;
|
||||
}
|
||||
|
||||
let targetItem = item;
|
||||
if (!targetItem.isRegularItem()) {
|
||||
if (targetItem.isAttachment() || targetItem.isNote())
|
||||
targetItem = Zotero.Items.get(targetItem.parentID);
|
||||
}
|
||||
if (!targetItem?.isRegularItem()) {
|
||||
Zotero.alert(null, "Чистый PDF", "Выберите обычную запись Zotero.");
|
||||
return;
|
||||
}
|
||||
const url = targetItem.getField("url");
|
||||
if (!url) { Zotero.alert(null, "Чистый PDF", "У записи нет URL."); return; }
|
||||
|
||||
const pw = new Zotero.ProgressWindow();
|
||||
pw.changeHeadline("Чистый PDF");
|
||||
const progress = new pw.ItemProgress(targetItem.getImageSrc(), targetItem.getField("title"));
|
||||
pw.show();
|
||||
pw.addDescription("Генерируем PDF…");
|
||||
|
||||
const response = await Zotero.getMainWindow().fetch(
|
||||
SERVICE_URL + "/api/pdf?url=" + encodeURIComponent(url) + "&format=" + FORMAT + "&theme=" + THEME,
|
||||
{ headers: { 'Authorization': 'Bearer ' + API_KEY } }
|
||||
);
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
progress.setError();
|
||||
pw.addDescription("Ошибка: " + (body.error || ("HTTP " + response.status)));
|
||||
pw.startCloseTimer(8000);
|
||||
return;
|
||||
}
|
||||
|
||||
const uint8Array = new Uint8Array(await (await response.blob()).arrayBuffer());
|
||||
if (uint8Array.length < 1000) {
|
||||
progress.setError(); pw.addDescription("Пришёл пустой PDF."); pw.startCloseTimer(8000); return;
|
||||
}
|
||||
|
||||
const safeTitle = (targetItem.getField("title") || "Document").replace(/[\\\\/:"*?<>|]+/g, "_").slice(0, 140);
|
||||
tempFilePath = joinPath(Zotero.getTempDirectory().path, safeTitle + ".pdf");
|
||||
await Zotero.getMainWindow().IOUtils.write(tempFilePath, uint8Array);
|
||||
await Zotero.Attachments.importFromFile({ file: tempFilePath, parentItemID: targetItem.id, contentType: "application/pdf" });
|
||||
|
||||
for (const attId of targetItem.getAttachments()) {
|
||||
const att = Zotero.Items.get(attId);
|
||||
const ct = att?.attachmentContentType || "";
|
||||
if (ct === "text/html" || ct === "application/zip") await att.eraseTx();
|
||||
}
|
||||
|
||||
progress.setProgress(100);
|
||||
const usesCount = response.headers.get('X-Uses-Count');
|
||||
const maxUses = response.headers.get('X-Max-Uses');
|
||||
const usageInfo = (usesCount && maxUses) ? (" (" + usesCount + "/" + maxUses + " за месяц)") : "";
|
||||
pw.addDescription("PDF прикреплён." + usageInfo);
|
||||
pw.startCloseTimer(4000);
|
||||
} catch (e) {
|
||||
Zotero.alert(null, "Ошибка", e.toString());
|
||||
} finally {
|
||||
if (tempFilePath) {
|
||||
await Zotero.getMainWindow().IOUtils.remove(tempFilePath, { ignoreAbsent: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
})();`;
|
||||
}
|
||||
@@ -137,6 +137,31 @@ export async function sendHomeworkSubmittedEmail(
|
||||
}).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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
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(
|
||||
to: string,
|
||||
studentName: string,
|
||||
|
||||
@@ -14,6 +14,7 @@ export const SETTINGS_DEFAULTS = {
|
||||
// Notifications
|
||||
notificationEmails: "", // newline-separated list
|
||||
notifyOnHomework: "true",
|
||||
notifyOnComment: "true",
|
||||
notifyOnRegistration: "true",
|
||||
notifyStudentOnFeedback: "true",
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// Storefront catalog for the dashboard upsell modal.
|
||||
//
|
||||
// SKUs and prices DUPLICATE payment-router/products.yaml (and the landing
|
||||
// pages) — the server always charges the catalog price by SKU, so a stale
|
||||
// price here is a display bug, not a money bug. When a price changes:
|
||||
// products.yaml → landing → this file.
|
||||
|
||||
export interface StoreTariff {
|
||||
sku: string; // must match payment-router/products.yaml
|
||||
label: string;
|
||||
priceRub: number; // display only; the charge is resolved server-side
|
||||
includes?: string; // short "what's inside" caption under the label
|
||||
}
|
||||
|
||||
export interface StoreCatalogEntry {
|
||||
landingUrl: string;
|
||||
tariffs: StoreTariff[];
|
||||
}
|
||||
|
||||
export const PAY_ORDER_URL = "https://obsidian.second-brain.ru/pay/order";
|
||||
export const PAY_METHODS_URL = "https://obsidian.second-brain.ru/pay/methods";
|
||||
|
||||
// Keys = course slugs shown as locked cards on the student dashboard.
|
||||
// Only tariffs of the course's "native" landing are listed — cross-bundles
|
||||
// from other landings would confuse the choice.
|
||||
export const STORE_CATALOG: Record<string, StoreCatalogEntry> = {
|
||||
"second-brain-vault": {
|
||||
landingUrl: "https://second-brain.ru/sb-vault",
|
||||
tariffs: [{ sku: "sb-vault", label: "Second Brain Vault", priceRub: 2990 }],
|
||||
},
|
||||
"claude-obsidian": {
|
||||
landingUrl: "https://second-brain.ru/claude-obsidian",
|
||||
tariffs: [{ sku: "claude-obsidian", label: "Claude + Obsidian", priceRub: 4990 }],
|
||||
},
|
||||
"obsidian-full": {
|
||||
landingUrl: "https://obsidian.second-brain.ru/",
|
||||
tariffs: [
|
||||
{
|
||||
sku: "obsidian-pro",
|
||||
label: "Obsidian + AI",
|
||||
priceRub: 14990,
|
||||
includes: "Включает курс «Obsidian + AI» и Second Brain Vault",
|
||||
},
|
||||
{
|
||||
sku: "obsidian-architect",
|
||||
label: "Архитектор знаний",
|
||||
priceRub: 17990,
|
||||
includes: "Плюс курс «Zotero. Всё включено»",
|
||||
},
|
||||
],
|
||||
},
|
||||
"zotero-full": {
|
||||
landingUrl: "https://zotero.second-brain.ru/",
|
||||
tariffs: [
|
||||
{
|
||||
sku: "zotero-analyst",
|
||||
label: "Аналитик",
|
||||
priceRub: 10990,
|
||||
includes: "Включает курс «Obsidian. Всё включено»",
|
||||
},
|
||||
{
|
||||
sku: "zotero-max",
|
||||
label: "Второй мозг на максималках",
|
||||
priceRub: 17990,
|
||||
includes: "Плюс курс «Obsidian + AI» и Second Brain Vault",
|
||||
},
|
||||
],
|
||||
},
|
||||
"obsidian-ai": {
|
||||
landingUrl: "https://obsidianai.second-brain.ru/",
|
||||
tariffs: [
|
||||
{ sku: "obsidianai-base", label: "База", priceRub: 6990 },
|
||||
{
|
||||
sku: "obsidianai-expert",
|
||||
label: "AI Эксперт",
|
||||
priceRub: 14990,
|
||||
includes: "Включает курс «Obsidian. Всё включено» и Second Brain Vault",
|
||||
},
|
||||
{
|
||||
sku: "obsidianai-researcher",
|
||||
label: "AI Исследователь",
|
||||
priceRub: 17990,
|
||||
includes: "Плюс курс «Zotero. Всё включено»",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
export type ToolId = "callout" | "frontmatter" | "theme" | "style-settings" | "dataview" | "bases";
|
||||
export type ToolId = "callout" | "frontmatter" | "theme" | "style-settings" | "dataview" | "bases" | "clean-pdf";
|
||||
|
||||
export const TOOL_IDS: ToolId[] = [
|
||||
"callout",
|
||||
@@ -7,6 +7,7 @@ export const TOOL_IDS: ToolId[] = [
|
||||
"style-settings",
|
||||
"dataview",
|
||||
"bases",
|
||||
"clean-pdf",
|
||||
];
|
||||
|
||||
export interface ToolMeta {
|
||||
@@ -24,4 +25,5 @@ export const TOOLS: ToolMeta[] = [
|
||||
{ id: "style-settings", title: "Генератор Style Settings", description: "Комментарии-настройки для плагина Style Settings.", icon: "SlidersHorizontal" },
|
||||
{ id: "dataview", title: "Генератор Dataview", description: "Запросы DQL: таблицы, списки и задачи по заметкам.", icon: "Table2" },
|
||||
{ id: "bases", title: "Генератор Bases", description: "Базы Obsidian (.base): представления с фильтрами.", icon: "Database" },
|
||||
{ id: "clean-pdf", title: "Чистый PDF", description: "Любая веб-страница → аккуратный PDF без рекламы и мусора. Интеграция с Zotero.", icon: "FileText" },
|
||||
];
|
||||
|
||||
+4
-2
@@ -1,7 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getSessionCookie } from "better-auth/cookies";
|
||||
|
||||
const PUBLIC_ROUTES = ["/login", "/register", "/verify-email", "/forgot-password", "/reset-password", "/api/auth", "/api/register", "/api/internal", "/maintenance", "/share/wrapped"];
|
||||
const PUBLIC_ROUTES = ["/login", "/register", "/verify-email", "/forgot-password", "/reset-password", "/api/auth", "/api/register", "/api/internal", "/api/pdf", "/maintenance", "/share/wrapped"];
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
@@ -14,9 +14,11 @@ export function middleware(request: NextRequest) {
|
||||
}
|
||||
|
||||
if (
|
||||
pathname === "/" || // public welcome page; "/" must stay an EXACT match (startsWith would open everything)
|
||||
PUBLIC_ROUTES.some((route) => pathname.startsWith(route)) ||
|
||||
pathname.startsWith("/_next") ||
|
||||
pathname.startsWith("/favicon")
|
||||
pathname.startsWith("/favicon") ||
|
||||
pathname === "/logo.svg"
|
||||
) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
+1
-1
@@ -7,6 +7,6 @@ export default defineConfig({
|
||||
},
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["src/lib/tools/**/__tests__/**/*.test.ts"],
|
||||
include: ["src/lib/**/__tests__/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user