24 Commits

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

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

Прод-compose Hoster (/root/lms-sb/docker-compose.yml) в git не живёт
и выровнен на месте; бэкап — /root/backups/major-upgrade-20260709/.
2026-07-09 17:28:27 +05:00
admins 5d94949810 Redirect large/non-inline lesson files to CDN instead of proxying
ZIP/DOCX etc. download natively — no need to force attachment; and the
157 MB Second Brain Vault archive must stream from Caddy with Range,
not through the app. Proxy attachment stays for small inline types (PDF/images).
2026-07-09 11:35:45 +05:00
admins 98cc023709 Force lesson materials to download as attachment with readable filename
Direct CDN links opened PDFs inline (no Content-Disposition), which reads
as 'can't download' behind popup blockers / on mobile. New proxy route
streams the file with attachment + human name; lesson link points to it.
2026-07-08 19:28:26 +05:00
admins 53caf54b03 Merge feature/clean-pdf: Чистый PDF tool (URL→PDF, Zotero, API) 2026-07-06 14:23:06 +05:00
admins 4734b99bea Document DNS-rebind residual and prod-enablement egress hardening
The security docs claimed the in-browser SSRF filter (context.route/
routeWebSocket) re-applies "the same filtering" as the pre-fetch DNS
check. That's inaccurate for hostnames: the browser-level filter only
blocks literal private IPs and localhost/.local/.internal suffixes —
it never re-resolves hostnames, so a same-hostname DNS-rebind (public
IP on first resolve, private IP on a later request from inside
browserless) is not closed at that layer. Correct the wording in the
design spec and TECHNICAL.md, and add a prominent note to both the
spec's deploy section and the plan's deploy notes: before flipping
TOOLBOX_VISIBLE on prod, harden the browserless container's network
egress (block 169.254.0.0/16 and RFC1918 ranges via host firewall or
a dedicated internal docker network) to close the residual at the
network layer. Also note that per-user limits currently count only
successful generations — failed renders are uncapped, a bounded
self-DoS risk worth a follow-up.
2026-07-06 13:47:00 +05:00
admins 118fa3961f Surface regenerate errors in clean-pdf Zotero section
regenerate() silently did nothing on {ok:false} or a rejected server
action, risking an unhandled promise rejection and leaving the student
staring at a stuck "меняем…" button with no feedback. Wrap the action
call in try/catch and show an inline error message on failure.
2026-07-06 13:45:12 +05:00
admins 1ccf994112 Strip active content from extracted HTML before PDF print
Defuddle-extracted article content is injected raw into the PDF HTML
and rendered by a real browser (browserless). As defense-in-depth
against a compromised or malicious source page, remove script/style/
iframe/object/embed elements and on* event-handler / javascript: href
attributes from the parsed DOM before serializing it into the template.
2026-07-06 13:44:41 +05:00
admins 02b16e311b Make Zotero script comment dynamic and assert valid JS
The generated script's setup comment hardcoded school.second-brain.ru
even though baseUrl is already interpolated elsewhere in the template,
so the comment would lie on any other host. Also add a regression test
that the generated script parses as valid JavaScript, to catch escaping
mistakes in the template literal.
2026-07-06 13:44:13 +05:00
admins f143f58cea Add browserless service to prod compose and document clean-pdf 2026-07-06 13:30:24 +05:00
admins fd40b1e394 Recompute Zotero script reactively on key regenerate 2026-07-06 13:13:19 +05:00
admins 0cbea10e1a Add clean-pdf tool page with web form and Zotero section 2026-07-06 12:49:06 +05:00
admins 668dd29397 Add regenerate action for clean-pdf API key 2026-07-06 12:44:29 +05:00
admins 9ad4d762d7 Wrap /api/pdf handler in try/catch for JSON error contract
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-06 12:39:01 +05:00
admins c50a295290 Add /api/pdf route with key/session auth and limits
Adds GET /api/pdf: resolves the caller via a Bearer sbpdf_ API key
(PdfApiKey table) or a Better Auth session cookie, then gates on paid
course access, burst/monthly usage limits, generates the PDF via
generateCleanPdf, records a ToolUsage row, and streams the file with
X-Uses-Count/X-Max-Uses headers. Errors map to 401/403/422/429/504
JSON responses. Whitelists /api/pdf in middleware PUBLIC_ROUTES so the
route can perform its own auth instead of being redirected to /login.
2026-07-06 12:24:23 +05:00
admins 0c56b0b809 Bound PDF print timeout and block WebSocket SSRF in clean-pdf 2026-07-06 12:21:24 +05:00
admins 89383fab1d Add PDF generation pipeline via browserless and Defuddle
Connects to browserless over CDP (playwright-core), extracts article
content with Defuddle/JSDOM, renders it through buildCleanHtml, and
prints a PDF on a second page. Adds an in-browser request filter as a
second line of SSRF defense against redirects to private/localhost
hosts, on top of assertPublicUrl's DNS check.

Integration test is gated by RUN_PDF_INTEGRATION=1 (describe.runIf) so
it is skipped in a normal npm run test and only runs against a real
browserless instance.
2026-07-06 12:09:28 +05:00
admins ef46cdbe29 Add paid-access and usage limit helpers for clean-pdf 2026-07-06 12:00:07 +05:00
admins b49680c118 Add PdfApiKey model and key storage helpers
Adds a Prisma model for per-student Clean PDF API keys plus a
hand-written migration (no local Postgres to run `migrate dev`
against). getOrCreatePdfKey/regenerateKey wrap the model with
lazy-creation and rotation logic on top of generatePdfKey().
2026-07-06 11:53:31 +05:00
admins 1d5b4f9251 Add API key generator and Zotero script builder 2026-07-06 11:46:31 +05:00
admins 6316c09993 Add PDF HTML template with typography, themes and TOC 2026-07-06 11:40:24 +05:00
admins 9a74339087 Block v4-mapped IPv6 literals in SSRF validator 2026-07-06 11:37:05 +05:00
admins 2c619be043 Add SSRF URL validator for clean-pdf 2026-07-06 11:28:11 +05:00
admins 52073fd914 Document clean-pdf env vars in .env.example 2026-07-06 11:25:31 +05:00
32 changed files with 1308 additions and 11 deletions
+6
View File
@@ -45,3 +45,9 @@ WOW_MOMENT_LESSON_ID="obs-start-l2-006"
# Obsidian Toolbox (/tools): "true" — показывать точки входа и роуты (staging); # Obsidian Toolbox (/tools): "true" — показывать точки входа и роуты (staging);
# не задано/иное — скрыто, /tools редиректит на /dashboard (prod, пока дорабатываем) # не задано/иное — скрыто, /tools редиректит на /dashboard (prod, пока дорабатываем)
TOOLBOX_VISIBLE="" TOOLBOX_VISIBLE=""
# Чистый PDF (browserless на staging/prod; локально — SSH-туннель на staging)
BROWSER_WS_URL="ws://localhost:3333"
# Секрет browserless (TOKEN в его env) — на прод/staging генерировать: openssl rand -hex 24
BROWSERLESS_TOKEN=""
PDF_MONTHLY_LIMIT="100"
+33
View File
@@ -171,6 +171,39 @@ CSS-классы: `.card-aubade`, `.btn-aubade`, `.btn-aubade-accent`, `.tag-aub
|---|---|---|---| |---|---|---|---|
| `POST` | `/api/auth/[...all]` | Better Auth handler | Все | | `POST` | `/api/auth/[...all]` | Better Auth handler | Все |
| `POST` | `/api/admin/upload` | Загрузка файла в S3, возвращает `{ url, key }` | admin | | `POST` | `/api/admin/upload` | Загрузка файла в S3, возвращает `{ url, key }` | admin |
| `GET` | `/api/pdf` | Чистый PDF из URL (Bearer-ключ или сессия) — см. раздел ниже | платный студент, admin, curator |
---
## Чистый PDF (`/tools/clean-pdf`, `/api/pdf`)
Инструмент Obsidian Toolbox: превращает произвольный URL в чистый PDF (без рекламы и меню, с типографикой, оглавлением, A4/Letter, light/dark). Полный дизайн-документ: [`docs/specs/20260706-clean-pdf-design.md`](docs/specs/20260706-clean-pdf-design.md).
**Пайплайн генерации:**
```
URL студента
→ browserless (Chromium по WebSocket, playwright-core) загружает страницу
→ Next.js забирает итоговый HTML
→ Defuddle (JSDOM) выделяет основной контент
→ HTML-шаблон (типографика, A4/Letter, light/dark, оглавление)
→ Playwright page.pdf() в том же browserless
→ application/pdf в ответе
```
Доступ: любой платный студент (есть `CourseEnrollment` вне `FREE_COURSE_SLUG`), admin/curator — без ограничений. Видимость страницы гейтится флагом `TOOLBOX_VISIBLE`, но сам `/api/pdf` работает независимо от него (доступ проверяется отдельно). Ключ для внешнего API — модель `PdfApiKey` (`sbpdf_<random>`, ленивая генерация, регенерация инвалидирует старый).
**Env-переменные:**
| Переменная | Назначение |
|---|---|
| `BROWSER_WS_URL` | WebSocket-адрес browserless (`ws://browserless:3000` в compose, `ws://localhost:3333` при туннеле локально) |
| `BROWSERLESS_TOKEN` | Секрет browserless (`TOKEN` в его env) — общий и для сервиса, и для клиента в LMS |
| `PDF_MONTHLY_LIMIT` | Лимит генераций в месяц на студента (по умолчанию `100`), без пересборки |
Контейнер `browserless` (`ghcr.io/browserless/chromium`) — внутренний, порт наружу не публикуется ни на staging, ни на проде.
**SSRF-защита — честно про пределы:** до рендера URL проверяется резолвом DNS (блок приватных/зарезервированных диапазонов), плюс внутри browserless страница перехватывается фильтром буквальных приватных IP и `localhost`/`*.local`/`*.internal`. Этот browser-level фильтр **не** переразрешает хостнеймы — same-hostname DNS-rebind (публичный IP на первом резолве, приватный на повторном запросе изнутри browserless) он не закрывает. Полный разбор и требование захардить сетевой egress `browserless` перед включением `TOOLBOX_VISIBLE` на проде — см. «Безопасность» и «Деплой» в [`docs/specs/20260706-clean-pdf-design.md`](docs/specs/20260706-clean-pdf-design.md).
--- ---
+21 -2
View File
@@ -13,24 +13,43 @@ services:
NEXT_PUBLIC_APP_URL: "https://school.second-brain.ru" NEXT_PUBLIC_APP_URL: "https://school.second-brain.ru"
RESEND_API_KEY: "${RESEND_API_KEY}" RESEND_API_KEY: "${RESEND_API_KEY}"
EMAIL_FROM: "${EMAIL_FROM}" EMAIL_FROM: "${EMAIL_FROM}"
BROWSER_WS_URL: "ws://browserless:3000"
BROWSERLESS_TOKEN: "${BROWSERLESS_TOKEN}"
PDF_MONTHLY_LIMIT: "${PDF_MONTHLY_LIMIT:-100}"
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
browserless:
condition: service_started
db: db:
image: postgres:16-alpine # ⚠️ PG18 сменил раскладку: том монтируется в /var/lib/postgresql (НЕ .../data),
# кластер лежит в подкаталоге 18/docker. Мажор прода сделан 09.07.2026.
# Не откатывать тег и mount по отдельности — иначе initdb на непустом томе → рестарт-луп.
# В этом каталоге на Hetzner том lms-sb_postgres_data = hot-standby (тоже PG18).
image: postgres:18-alpine
restart: unless-stopped restart: unless-stopped
environment: environment:
POSTGRES_USER: lms_user POSTGRES_USER: lms_user
POSTGRES_PASSWORD: "${DB_PASSWORD}" POSTGRES_PASSWORD: "${DB_PASSWORD}"
POSTGRES_DB: lms_db POSTGRES_DB: lms_db
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U lms_user -d lms_db"] test: ["CMD-SHELL", "pg_isready -U lms_user -d lms_db"]
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 10 retries: 10
browserless:
image: ghcr.io/browserless/chromium
restart: unless-stopped
environment:
TOKEN: "${BROWSERLESS_TOKEN}"
CONCURRENT: "2"
QUEUED: "10"
TIMEOUT: "120000"
mem_limit: 1g
volumes: volumes:
postgres_data: postgres_data:
+4 -1
View File
@@ -82,11 +82,12 @@ model PdfApiKey {
- Только `http://` и `https://`. - Только `http://` и `https://`.
- Перед загрузкой — резолв DNS и блокировка приватных/зарезервированных диапазонов: localhost/127.x, 10.x, 172.1631.x, 192.168.x, 169.254.x (метаданные облаков), ::1, fc00::/7, плюс docker-хостнеймы стенда (`db`, `app`, `browserless`). - Перед загрузкой — резолв DNS и блокировка приватных/зарезервированных диапазонов: localhost/127.x, 10.x, 172.1631.x, 192.168.x, 169.254.x (метаданные облаков), ::1, fc00::/7, плюс docker-хостнеймы стенда (`db`, `app`, `browserless`).
- Внутри browserless — перехват сетевых запросов страницы с той же фильтрацией (защита от редиректов и подгрузок на внутренние адреса). - Внутри browserless — перехват сетевых запросов страницы (`context.route`/`context.routeWebSocket`): блокирует буквальные приватные/зарезервированные IP и хосты `localhost`/`*.local`/`*.internal`. **Честно про предел этой защиты:** она не резолвит DNS заново — обычный хостнейм (не IP-литерал) проходит проверку без разрешения адреса. Значит, DNS-rebind на тот же хостнейм (первый резолв на этапе `assertPublicUrl` — публичный IP; повторный запрос со страницы внутри browserless — уже приватный IP того же имени) **не блокируется** этим browser-level фильтром. Остаточный риск закрывается на сетевом уровне — см. «Деплой».
- Порт browserless наружу не публикуется, доступен только приложению по внутренней сети compose. - Порт browserless наружу не публикуется, доступен только приложению по внутренней сети compose.
- Куки/учётные данные пользователя на целевую страницу не передаются. - Куки/учётные данные пользователя на целевую страницу не передаются.
- Санитайз имени файла в `Content-Disposition`. - Санитайз имени файла в `Content-Disposition`.
- Лимиты: 100/мес (env) + burst 5/мин на пользователя; сверху — очередь browserless (2 конкурентных рендера). - Лимиты: 100/мес (env) + burst 5/мин на пользователя; сверху — очередь browserless (2 конкурентных рендера).
- ⚠️ Follow-up (не блокирует релиз): оба счётчика лимита считают только **успешные** генерации (`ToolUsage` пишется после успешного рендера) — неудачные попытки (таймаут, 5xx с целевого сайта, зависший рендер) лимит не расходуют. Потенциальный ограниченный self-DoS повторными запросами к тяжёлым/неотвечающим URL. Рассмотреть подсчёт попыток, а не только успехов, отдельной задачей.
## Деплой ## Деплой
@@ -96,6 +97,8 @@ model PdfApiKey {
- Стандартная схема деплоя LMS: сборка на Hetzner → `docker save | ssh | docker load` на Hoster.kz; browserless на Hoster.kz — обычный `docker pull`. - Стандартная схема деплоя LMS: сборка на Hetzner → `docker save | ssh | docker load` на Hoster.kz; browserless на Hoster.kz — обычный `docker pull`.
- Hot-standby на Hetzner получает тот же compose (репликация БД уже покрывает `PdfApiKey` и `ToolUsage`). - Hot-standby на Hetzner получает тот же compose (репликация БД уже покрывает `PdfApiKey` и `ToolUsage`).
> ⚠️ **Перед включением `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-шаблон на фикстурах. - Юнит: SSRF-валидатор (таблица адресов → допуск/блок), пайплайн Defuddle → HTML-шаблон на фикстурах.
@@ -1654,9 +1654,13 @@ git commit -m "Add browserless service to prod compose and document clean-pdf"
1. В `.env` на Hoster.kz добавить `BROWSERLESS_TOKEN=<random>` (например `openssl rand -hex 24`) и при желании `PDF_MONTHLY_LIMIT`. 1. В `.env` на Hoster.kz добавить `BROWSERLESS_TOKEN=<random>` (например `openssl rand -hex 24`) и при желании `PDF_MONTHLY_LIMIT`.
2. Миграция на проде: выполняется как обычно при деплое (`prisma migrate deploy` в entrypoint — проверить, что `add_pdf_api_key` применилась: `docker exec -i lms-sb-db-1 psql -U lms_user -d lms_db -c '\d "PdfApiKey"'`). 2. Миграция на проде: выполняется как обычно при деплое (`prisma migrate deploy` в entrypoint — проверить, что `add_pdf_api_key` применилась: `docker exec -i lms-sb-db-1 psql -U lms_user -d lms_db -c '\d "PdfApiKey"'`).
3. `docker pull ghcr.io/browserless/chromium` на Hoster.kz (интернет с сервера есть) + обновлённый compose. 3. `docker pull ghcr.io/browserless/chromium` на Hoster.kz (интернет с сервера есть) + обновлённый compose.
> ⚠️ **Перед шагом 4 (включением `TOOLBOX_VISIBLE=true`) обязательно захардить сетевой egress контейнера `browserless` на проде** — заблокировать `169.254.0.0/16` (cloud-metadata) и RFC1918-диапазоны (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`) на уровне хост-файрвола или выделенной internal-only docker-сети. Browser-level SSRF-фильтр (`context.route`/`context.routeWebSocket`) не резолвит DNS заново и не закрывает same-hostname DNS-rebind (публичный IP на первом резолве, приватный — на повторном запросе изнутри browserless); сетевой egress-блок — единственный слой, который закрывает этот остаточный риск. Подробности — «Безопасность»/«Деплой» в `docs/specs/20260706-clean-pdf-design.md`.
4. Инструмент останется невидимым, пока на проде не выставлен `TOOLBOX_VISIBLE=true` — это отдельное продуктовое решение. `/api/pdf` при этом уже будет работать — это ок (доступ гейтится платным enrollment), но анонсировать до включения тулбокса не нужно. 4. Инструмент останется невидимым, пока на проде не выставлен `TOOLBOX_VISIBLE=true` — это отдельное продуктовое решение. `/api/pdf` при этом уже будет работать — это ок (доступ гейтится платным enrollment), но анонсировать до включения тулбокса не нужно.
5. Hot-standby Hetzner: подтянуть тот же compose (`/root/digital-household/lms-sb/docker-compose.prod.yml` обновится через git pull). 5. Hot-standby Hetzner: подтянуть тот же compose (`/root/digital-household/lms-sb/docker-compose.prod.yml` обновится через git pull).
6. После релиза: обновить SBT-карточку `SBT/00-Стек/Сервисы/lms.md` (раздел про тулбокс + новые env) через capture-knowledge. 6. После релиза: обновить SBT-карточку `SBT/00-Стек/Сервисы/lms.md` (раздел про тулбокс + новые env) через capture-knowledge.
7. Follow-up (не блокирует релиз): месячный/burst-лимит сейчас считает только **успешные** генерации — неудачные попытки (таймаут, 5xx, зависший рендер) лимит не расходуют, потенциальный ограниченный self-DoS повторными запросами. Рассмотреть подсчёт попыток, а не только успехов, отдельной задачей.
## Самопроверка при завершении ## Самопроверка при завершении
@@ -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;
+11
View File
@@ -47,6 +47,7 @@ model User {
questionMessages StudentQuestionMessage[] questionMessages StudentQuestionMessage[]
wrappedRuns WrappedRun[] wrappedRuns WrappedRun[]
toolUsages ToolUsage[] toolUsages ToolUsage[]
pdfApiKey PdfApiKey?
} }
// Obsidian Wrapped — агрегаты прогона (БЕЗ имён/текстов заметок; обработка волта 100% client-side) // Obsidian Wrapped — агрегаты прогона (БЕЗ имён/текстов заметок; обработка волта 100% client-side)
@@ -82,6 +83,16 @@ model ToolUsage {
@@index([createdAt]) @@index([createdAt])
} }
// Чистый PDF — персональный API-ключ студента (Zotero/curl)
model PdfApiKey {
id String @id @default(cuid())
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
key String @unique // sbpdf_<random>, показывается студенту на /tools/clean-pdf
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Session { model Session {
id String @id @default(cuid()) id String @id @default(cuid())
userId String userId String
@@ -149,9 +149,8 @@ export default async function LessonPage({ params }: Props) {
{lesson.files.map((file) => ( {lesson.files.map((file) => (
<a <a
key={file.id} key={file.id}
href={file.url} href={`/api/lesson-files/${file.id}/download`}
target="_blank" download={file.name}
rel="noopener noreferrer"
className="flex items-center gap-3 px-4 py-3 text-sm transition-colors hover:[border-color:var(--foreground)]" className="flex items-center gap-3 px-4 py-3 text-sm transition-colors hover:[border-color:var(--foreground)]"
style={{ border: "2px solid var(--border)" }} style={{ border: "2px solid var(--border)" }}
> >
@@ -0,0 +1,76 @@
"use client";
import { useState } from "react";
const field = "w-full p-2 text-sm";
const fieldStyle = { border: "1px solid var(--border)", borderRadius: "2px", backgroundColor: "var(--background)", color: "var(--foreground)" } as const;
export function CleanPdfForm({ initialUsed, limit }: { initialUsed: number; limit: number }) {
const [url, setUrl] = useState("");
const [format, setFormat] = useState<"A4" | "Letter">("A4");
const [theme, setTheme] = useState<"light" | "dark">("light");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [used, setUsed] = useState(initialUsed);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!url.trim() || busy) return;
setBusy(true);
setError(null);
try {
const params = new URLSearchParams({ url: url.trim(), format, theme });
const res = await fetch(`/api/pdf?${params}`);
if (!res.ok) {
const body = await res.json().catch(() => ({ error: `Ошибка ${res.status}` }));
setError(body.error ?? `Ошибка ${res.status}`);
return;
}
const usesCount = res.headers.get("X-Uses-Count");
if (usesCount) setUsed(Number(usesCount));
const blob = await res.blob();
const disposition = res.headers.get("Content-Disposition") ?? "";
const match = disposition.match(/filename\*=UTF-8''([^;]+)/);
const filename = match ? decodeURIComponent(match[1]) : "document.pdf";
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = filename;
a.click();
URL.revokeObjectURL(a.href);
} catch {
setError("Сеть недоступна, попробуйте ещё раз");
} finally {
setBusy(false);
}
}
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
<label className="text-sm">Адрес статьи
<input className={field} style={fieldStyle} type="url" required placeholder="https://…"
value={url} onChange={(e) => setUrl(e.target.value)} />
</label>
<div className="grid grid-cols-2 gap-3">
<label className="text-sm">Формат
<select className={field} style={fieldStyle} value={format} onChange={(e) => setFormat(e.target.value as "A4" | "Letter")}>
<option value="A4">A4</option>
<option value="Letter">Letter</option>
</select>
</label>
<label className="text-sm">Тема
<select className={field} style={fieldStyle} value={theme} onChange={(e) => setTheme(e.target.value as "light" | "dark")}>
<option value="light">Светлая</option>
<option value="dark">Тёмная</option>
</select>
</label>
</div>
<div className="flex items-center gap-4">
<button type="submit" disabled={busy} className="btn-aubade px-4 py-2 text-sm font-bold disabled:opacity-50">
{busy ? "Генерируем… (до минуты)" : "Скачать PDF"}
</button>
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>Использовано {used} из {limit} в этом месяце</span>
</div>
{error && <p className="text-sm" style={{ color: "var(--destructive)" }}>{error}</p>}
</form>
);
}
@@ -0,0 +1,72 @@
"use client";
import { useState, useTransition } from "react";
import { CodeOutput } from "@/components/tools/CodeOutput";
import { regeneratePdfApiKey } from "@/lib/actions/pdf-key-actions";
import { buildZoteroScript } from "@/lib/clean-pdf/zotero-script";
export function ZoteroSection({ apiKey, baseUrl }: { apiKey: string; baseUrl: string }) {
const [key, setKey] = useState(apiKey);
const [revealed, setRevealed] = useState(false);
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const shownKey = revealed ? key : key.slice(0, 8) + "…" + key.slice(-4);
const curlExample = `curl -H "Authorization: Bearer ${key}" \\\n "${baseUrl}/api/pdf?url=https://example.com/article&format=A4&theme=light" \\\n -o article.pdf`;
const script = buildZoteroScript({ apiKey: key, baseUrl });
function regenerate() {
if (!confirm("Старый ключ перестанет работать (в том числе в Zotero). Продолжить?")) return;
setError(null);
startTransition(async () => {
try {
const res = await regeneratePdfApiKey();
if (res.ok && res.key) {
setKey(res.key);
setRevealed(true);
} else {
setError("Не удалось перевыпустить ключ, попробуйте ещё раз");
}
} catch {
setError("Не удалось перевыпустить ключ, попробуйте ещё раз");
}
});
}
return (
<div className="flex flex-col gap-4">
<h2 className="text-lg font-bold" style={{ color: "var(--foreground)" }}>Zotero и API</h2>
<div className="flex flex-col gap-2">
<div className="text-xs font-bold tracking-wide" style={{ color: "var(--muted-foreground)" }}>ВАШ КЛЮЧ</div>
<div className="flex items-center gap-2">
<code className="p-2 text-sm" style={{ border: "1px solid var(--border)", borderRadius: "2px" }}>{shownKey}</code>
<button type="button" className="text-xs underline" onClick={() => setRevealed((v) => !v)}>
{revealed ? "скрыть" : "показать"}
</button>
<button type="button" className="text-xs underline" onClick={() => navigator.clipboard.writeText(key)}>скопировать</button>
<button type="button" className="text-xs underline" disabled={pending} onClick={regenerate}>
{pending ? "меняем…" : "перевыпустить"}
</button>
</div>
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
Ключ персональный не публикуйте его. Если ключ утёк, перевыпустите: старый сразу отключится.
</p>
{error && <p className="text-sm" style={{ color: "var(--destructive)" }}>{error}</p>}
</div>
<div className="flex flex-col gap-2 text-sm" style={{ color: "var(--foreground)" }}>
<div className="text-xs font-bold tracking-wide" style={{ color: "var(--muted-foreground)" }}>НАСТРОЙКА ZOTERO</div>
<ol className="list-decimal pl-5 flex flex-col gap-1">
<li>Установите плагин <a className="underline" href="https://github.com/windingwind/zotero-actions-tags/releases" target="_blank" rel="noreferrer">Actions &amp; Tags</a>.</li>
<li>В настройках плагина нажмите «+» и создайте действие: Name <b>Чистый PDF</b>, Operation <b>Script</b>.</li>
<li>В поле Data вставьте скрипт ниже (ключ уже подставлен).</li>
<li>Menu Label <b>_чистый PDF</b>, поставьте галочку <b>In item Menu</b>, нажмите Save.</li>
<li>Готово: правый клик на записи с URL «_чистый PDF» файл прикрепится к записи.</li>
</ol>
</div>
<CodeOutput code={script} tool="clean-pdf" label="СКРИПТ ДЛЯ ACTIONS & TAGS" />
<CodeOutput code={curlExample} tool="clean-pdf" label="ПРИМЕР ДЛЯ API (CURL)" />
</div>
);
}
@@ -0,0 +1,48 @@
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { getMonthlyLimit, getMonthlyUsage, hasPaidAccess } from "@/lib/clean-pdf/access";
import { getOrCreatePdfKey } from "@/lib/clean-pdf/keys";
import { CleanPdfForm } from "./CleanPdfForm";
import { ZoteroSection } from "./ZoteroSection";
export const metadata = { title: "Чистый PDF — Obsidian Toolbox" };
export default async function CleanPdfToolPage() {
const session = await auth.api.getSession({ headers: await headers() });
const userId = session?.user.id;
const paid = userId ? await hasPaidAccess(userId) : false;
if (!userId || !paid) {
return (
<main className="mx-auto w-full max-w-5xl px-6 py-8">
<h1 className="text-2xl font-bold tracking-wide" style={{ color: "var(--foreground)" }}>Чистый PDF</h1>
<div className="mt-6 card-aubade p-4">
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>
Инструмент доступен студентам платных курсов школы. Если у вас есть купленный курс проверьте, что вы вошли в нужный аккаунт.
</p>
</div>
</main>
);
}
const [key, used] = await Promise.all([getOrCreatePdfKey(userId), getMonthlyUsage(userId)]);
const limit = getMonthlyLimit();
const baseUrl = process.env.NEXT_PUBLIC_APP_URL ?? "https://school.second-brain.ru";
return (
<main className="mx-auto w-full max-w-5xl px-6 py-8">
<h1 className="text-2xl font-bold tracking-wide" style={{ color: "var(--foreground)" }}>Чистый PDF</h1>
<p className="mt-1 text-sm" style={{ color: "var(--muted-foreground)" }}>
Превращает любую веб-страницу в аккуратный PDF: без рекламы, меню и мусора только текст, картинки и оглавление.
</p>
<div className="mt-6 card-aubade p-4">
<CleanPdfForm initialUsed={used} limit={limit} />
</div>
<div className="mt-6 card-aubade p-4">
<ZoteroSection apiKey={key} baseUrl={baseUrl} />
</div>
</main>
);
}
@@ -200,6 +200,7 @@ export async function applyImport(
data: { data: {
name: row.name, name: row.name,
email: row.email, email: row.email,
phone: row.phone || null,
emailVerified: options.autoVerifyEmail, emailVerified: options.autoVerifyEmail,
role: "student", role: "student",
}, },
@@ -231,10 +232,16 @@ export async function applyImport(
created++; created++;
} else if (row.status === "update" && row.existingId) { } else if (row.status === "update" && row.existingId) {
const existing = await prisma.user.findUnique({
where: { id: row.existingId },
select: { phone: true },
});
await prisma.user.update({ await prisma.user.update({
where: { id: row.existingId }, where: { id: row.existingId },
data: { data: {
name: row.name || undefined, name: row.name || undefined,
// don't overwrite an already-filled phone
phone: existing?.phone ? undefined : row.phone || undefined,
}, },
}); });
+15 -2
View File
@@ -19,6 +19,7 @@ export async function POST(request: NextRequest) {
let body: { let body: {
email?: string; email?: string;
name?: string; name?: string;
phone?: string;
course_slugs?: string[]; course_slugs?: string[];
order_id?: string; order_id?: string;
}; };
@@ -30,6 +31,7 @@ export async function POST(request: NextRequest) {
const email = (body.email ?? "").trim().toLowerCase(); const email = (body.email ?? "").trim().toLowerCase();
const name = (body.name ?? "").trim(); const name = (body.name ?? "").trim();
const phone = (body.phone ?? "").trim().slice(0, 30);
const slugs = Array.isArray(body.course_slugs) const slugs = Array.isArray(body.course_slugs)
? body.course_slugs.filter((s) => typeof s === "string" && s.trim()) ? body.course_slugs.filter((s) => typeof s === "string" && s.trim())
: []; : [];
@@ -66,7 +68,7 @@ export async function POST(request: NextRequest) {
// Find or create user // Find or create user
let user = await prisma.user.findFirst({ let user = await prisma.user.findFirst({
where: { email: { equals: email, mode: "insensitive" } }, where: { email: { equals: email, mode: "insensitive" } },
select: { id: true, name: true }, select: { id: true, name: true, email: true, phone: true },
}); });
let tempPassword: string | null = null; let tempPassword: string | null = null;
if (!user) { if (!user) {
@@ -76,14 +78,25 @@ export async function POST(request: NextRequest) {
data: { data: {
email, email,
name: name || email, name: name || email,
phone: phone || null,
emailVerified: true, emailVerified: true,
mustChangePassword: true, mustChangePassword: true,
accounts: { accounts: {
create: { accountId: email, providerId: "credential", password: hash }, create: { accountId: email, providerId: "credential", password: hash },
}, },
}, },
select: { id: true, name: true }, select: { id: true, name: true, email: true, phone: true },
}); });
} else {
// Enrich existing user from the order, never overwriting meaningful data:
// name only if the current one is empty or an email placeholder, phone only if empty.
const enrich: { name?: string; phone?: string } = {};
if (name && (!user.name || user.name === user.email)) enrich.name = name;
if (phone && !user.phone) enrich.phone = phone;
if (Object.keys(enrich).length > 0) {
await prisma.user.update({ where: { id: user.id }, data: enrich });
if (enrich.name) user.name = enrich.name;
}
} }
// Enroll + audit log // Enroll + audit log
@@ -0,0 +1,117 @@
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
// Streams a lesson material from the CDN with a forced attachment header and a
// human-readable filename. Direct CDN links open the PDF inline in a new tab
// (no Content-Disposition), which reads as "can't download" for users with a
// popup blocker or on mobile. This route makes the browser save the file.
const CONTENT_TYPES: Record<string, string> = {
pdf: "application/pdf",
zip: "application/zip",
doc: "application/msword",
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
ppt: "application/vnd.ms-powerpoint",
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
xls: "application/vnd.ms-excel",
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
webp: "image/webp",
};
function extFromUrl(url: string): string {
const clean = url.split("?")[0];
const dot = clean.lastIndexOf(".");
return dot >= 0 ? clean.slice(dot + 1).toLowerCase() : "";
}
// Types a browser renders inline (so the direct link "opens in a tab" instead of
// downloading). Only these need proxying with a forced attachment header. ZIP,
// DOCX, etc. already download natively → redirect straight to the CDN.
const INLINE_EXT = new Set([
"pdf", "png", "jpg", "jpeg", "webp", "gif", "svg", "avif",
"txt", "htm", "html", "mp4", "mov", "webm", "mp3", "wav",
]);
// Above this, don't proxy through the app — redirect so the file streams from
// the CDN/Caddy with Range support (e.g. the 157 MB Second Brain Vault archive).
const PROXY_MAX_BYTES = 50 * 1024 * 1024;
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
const { id } = await params;
const file = await prisma.lessonFile.findUnique({
where: { id },
include: { lesson: { include: { module: { select: { courseId: true } } } } },
});
if (!file) {
return NextResponse.json({ error: "not_found" }, { status: 404 });
}
// Access: staff always; students must be enrolled in the file's course.
const role = session.user.role;
if (role !== "admin" && role !== "curator") {
const enrollment = await prisma.courseEnrollment.findFirst({
where: { userId: session.user.id, courseId: file.lesson.module.courseId },
select: { userId: true },
});
if (!enrollment) {
return NextResponse.json({ error: "forbidden" }, { status: 403 });
}
}
const ext = extFromUrl(file.url);
// Non-inline types download natively — send the browser straight to the CDN
// (native download, Range/resume, no app load). Auth is already enforced above.
if (!INLINE_EXT.has(ext)) {
return NextResponse.redirect(file.url, 302);
}
const upstream = await fetch(file.url);
if (!upstream.ok || !upstream.body) {
return NextResponse.json({ error: "file_unavailable" }, { status: 502 });
}
// Guard: a very large inline file still shouldn't stream through the app.
const upstreamLen = Number(upstream.headers.get("content-length") ?? "0");
if (upstreamLen > PROXY_MAX_BYTES) {
await upstream.body.cancel();
return NextResponse.redirect(file.url, 302);
}
const contentType =
upstream.headers.get("content-type") ??
CONTENT_TYPES[ext] ??
"application/octet-stream";
// Build a readable filename: "<name>.<ext>" (avoid doubling the extension).
let downloadName = file.name.trim();
if (ext && !downloadName.toLowerCase().endsWith(`.${ext}`)) {
downloadName += `.${ext}`;
}
const asciiName = downloadName.replace(/[^\x20-\x7E]+/g, "_").replace(/"/g, "");
const disposition =
`attachment; filename="${asciiName}"; ` +
`filename*=UTF-8''${encodeURIComponent(downloadName)}`;
const respHeaders: Record<string, string> = {
"Content-Type": contentType,
"Content-Disposition": disposition,
"Cache-Control": "private, no-store",
};
const len = upstream.headers.get("content-length");
if (len) respHeaders["Content-Length"] = len;
return new NextResponse(upstream.body, { status: 200, headers: respHeaders });
}
+84
View File
@@ -0,0 +1,84 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import {
BURST_LIMIT, PDF_TOOL_ID, getBurstUsage, getMonthlyLimit, getMonthlyUsage, hasPaidAccess,
} from "@/lib/clean-pdf/access";
import { BlockedUrlError } from "@/lib/clean-pdf/ssrf";
import { EmptyContentError, RenderError, generateCleanPdf } from "@/lib/clean-pdf/generate";
import { safePdfFilename } from "@/lib/clean-pdf/template";
import { PDF_KEY_PREFIX } from "@/lib/clean-pdf/api-key";
export const dynamic = "force-dynamic";
const querySchema = z.object({
url: z.string().min(1).max(2000),
format: z.enum(["A4", "Letter"]).default("A4"),
theme: z.enum(["light", "dark"]).default("light"),
});
function jsonError(status: number, error: string, extra?: Record<string, string>) {
return NextResponse.json({ error }, { status, headers: extra });
}
async function resolveUserId(req: NextRequest): Promise<string | null> {
const authHeader = req.headers.get("authorization");
if (authHeader?.startsWith("Bearer ")) {
const key = authHeader.slice("Bearer ".length).trim();
if (!key.startsWith(PDF_KEY_PREFIX)) return null;
const record = await prisma.pdfApiKey.findUnique({ where: { key }, select: { userId: true } });
return record?.userId ?? null;
}
const session = await auth.api.getSession({ headers: req.headers });
return session?.user.id ?? null;
}
export async function GET(req: NextRequest) {
try {
const userId = await resolveUserId(req);
if (!userId) return jsonError(401, "Нужен API-ключ или вход в аккаунт школы");
if (!(await hasPaidAccess(userId))) {
return jsonError(403, "Инструмент доступен студентам платных курсов школы");
}
const parsed = querySchema.safeParse(Object.fromEntries(req.nextUrl.searchParams));
if (!parsed.success) return jsonError(422, "Проверьте параметры: url, format (A4|Letter), theme (light|dark)");
if ((await getBurstUsage(userId)) >= BURST_LIMIT) {
return jsonError(429, "Слишком часто: подождите минуту", { "X-Max-Uses": String(getMonthlyLimit()) });
}
const limit = getMonthlyLimit();
const used = await getMonthlyUsage(userId);
if (used >= limit) {
return jsonError(429, `Лимит ${limit} PDF в месяц исчерпан`, {
"X-Uses-Count": String(used), "X-Max-Uses": String(limit),
});
}
const { pdf, title } = await generateCleanPdf(parsed.data);
await prisma.toolUsage.create({ data: { userId, tool: PDF_TOOL_ID } });
const filename = safePdfFilename(title);
return new NextResponse(new Uint8Array(pdf), {
status: 200,
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="document.pdf"; filename*=UTF-8''${encodeURIComponent(filename)}.pdf`,
"X-Uses-Count": String(used + 1),
"X-Max-Uses": String(limit),
},
});
} catch (e) {
if (e instanceof BlockedUrlError || e instanceof EmptyContentError) {
return jsonError(422, e.message);
}
if (e instanceof RenderError) {
console.error("[clean-pdf] render", e);
return jsonError(504, "Не получилось отрендерить страницу, попробуйте позже");
}
console.error("[clean-pdf]", e);
return jsonError(504, "Не получилось сгенерировать PDF, попробуйте позже");
}
}
+2 -1
View File
@@ -1,5 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { MessageSquareQuote, FileCode2, Palette, SlidersHorizontal, Table2, Database, Wrench, type LucideIcon } from "lucide-react"; import { MessageSquareQuote, FileCode2, Palette, SlidersHorizontal, Table2, Database, FileText, Wrench, type LucideIcon } from "lucide-react";
import type { ToolMeta } from "@/lib/tools/_shared/types"; import type { ToolMeta } from "@/lib/tools/_shared/types";
// Явная карта (не `import * as Icons`) — иначе весь набор lucide попадает в бандл. // Явная карта (не `import * as Icons`) — иначе весь набор lucide попадает в бандл.
@@ -10,6 +10,7 @@ const ICONS: Record<string, LucideIcon> = {
SlidersHorizontal, SlidersHorizontal,
Table2, Table2,
Database, Database,
FileText,
}; };
export function ToolCard({ tool }: { tool: ToolMeta }) { export function ToolCard({ tool }: { tool: ToolMeta }) {
+13
View File
@@ -0,0 +1,13 @@
"use server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { hasPaidAccess } from "@/lib/clean-pdf/access";
import { regenerateKey } from "@/lib/clean-pdf/keys";
export async function regeneratePdfApiKey(): Promise<{ ok: boolean; key?: string }> {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return { ok: false };
if (!(await hasPaidAccess(session.user.id))) return { ok: false };
const key = await regenerateKey(session.user.id);
return { ok: true, key };
}
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { decidePaidAccess, monthStartUtc } from "@/lib/clean-pdf/access";
const now = new Date("2026-07-06T10:00:00Z");
const freeSlug = "obsidian-start";
describe("decidePaidAccess", () => {
it("платный enrollment без срока — да", () => {
expect(decidePaidAccess({ role: "student", banned: false, freeSlug, now,
enrollments: [{ courseSlug: "zotero", expiresAt: null }] })).toBe(true);
});
it("только бесплатный курс — нет", () => {
expect(decidePaidAccess({ role: "student", banned: false, freeSlug, now,
enrollments: [{ courseSlug: "obsidian-start", expiresAt: null }] })).toBe(false);
});
it("без enrollments — нет", () => {
expect(decidePaidAccess({ role: "student", banned: false, freeSlug, now, enrollments: [] })).toBe(false);
});
it("истёкший платный — нет, живой срок — да", () => {
expect(decidePaidAccess({ role: "student", banned: false, freeSlug, now,
enrollments: [{ courseSlug: "zotero", expiresAt: new Date("2026-01-01") }] })).toBe(false);
expect(decidePaidAccess({ role: "student", banned: false, freeSlug, now,
enrollments: [{ courseSlug: "zotero", expiresAt: new Date("2027-01-01") }] })).toBe(true);
});
it("admin и curator — да даже без курсов", () => {
expect(decidePaidAccess({ role: "admin", banned: false, freeSlug, now, enrollments: [] })).toBe(true);
expect(decidePaidAccess({ role: "curator", banned: false, freeSlug, now, enrollments: [] })).toBe(true);
});
it("бан всё перекрывает", () => {
expect(decidePaidAccess({ role: "admin", banned: true, freeSlug, now, enrollments: [] })).toBe(false);
});
});
describe("monthStartUtc", () => {
it("возвращает первое число месяца 00:00 UTC", () => {
expect(monthStartUtc(new Date("2026-07-06T23:59:59Z")).toISOString()).toBe("2026-07-01T00:00:00.000Z");
});
});
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { PDF_KEY_PREFIX, generatePdfKey } from "@/lib/clean-pdf/api-key";
describe("generatePdfKey", () => {
it("формат sbpdf_<base64url>, достаточная длина", () => {
const key = generatePdfKey();
expect(key.startsWith(PDF_KEY_PREFIX)).toBe(true);
expect(key.length).toBeGreaterThanOrEqual(PDF_KEY_PREFIX.length + 32);
expect(key.slice(PDF_KEY_PREFIX.length)).toMatch(/^[A-Za-z0-9_-]+$/);
});
it("две генерации различаются", () => {
expect(generatePdfKey()).not.toBe(generatePdfKey());
});
});
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { generateCleanPdf } from "@/lib/clean-pdf/generate";
const enabled = process.env.RUN_PDF_INTEGRATION === "1";
describe.runIf(enabled)("generateCleanPdf (integration, нужен browserless)", () => {
it("генерирует PDF реальной статьи", async () => {
const { pdf, title } = await generateCleanPdf({
url: "https://habr.com/ru/articles/942236/",
format: "A4",
theme: "light",
});
expect(pdf.length).toBeGreaterThan(20_000);
expect(pdf.subarray(0, 5).toString()).toBe("%PDF-");
expect(title.length).toBeGreaterThan(3);
}, 120_000);
});
+70
View File
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import { BlockedUrlError, assertPublicUrl, isPrivateAddress } from "@/lib/clean-pdf/ssrf";
describe("isPrivateAddress", () => {
const privateIps = [
"127.0.0.1", "0.0.0.0", "10.1.2.3", "100.64.0.1", "169.254.169.254",
"172.16.0.1", "172.31.255.255", "192.168.1.1", "192.0.0.8", "198.18.0.1",
"224.0.0.1", "255.255.255.255",
"::1", "::", "fc00::1", "fd12:3456::1", "fe80::1", "::ffff:10.0.0.1", "::ffff:127.0.0.1",
"::ffff:7f00:1", "::ffff:a00:1", "::ffff:c0a8:101", "::ffff:a9fe:a9fe",
];
const publicIps = ["93.184.216.34", "8.8.8.8", "172.32.0.1", "2606:2800:220:1::1", "::ffff:8.8.8.8", "::ffff:808:808"];
it.each(privateIps)("блокирует %s", (ip) => expect(isPrivateAddress(ip)).toBe(true));
it.each(publicIps)("пропускает %s", (ip) => expect(isPrivateAddress(ip)).toBe(false));
});
describe("assertPublicUrl", () => {
const publicResolve = async () => [{ address: "93.184.216.34", family: 4 }];
const privateResolve = async () => [{ address: "172.18.0.2", family: 4 }];
const mixedResolve = async () => [
{ address: "93.184.216.34", family: 4 },
{ address: "10.0.0.5", family: 4 },
];
it("пропускает публичный https-URL", async () => {
const url = await assertPublicUrl("https://example.com/article", publicResolve);
expect(url.hostname).toBe("example.com");
});
it.each([
"file:///etc/passwd",
"ftp://example.com/x",
"chrome://settings",
"not a url",
])("блокирует %s", async (raw) => {
await expect(assertPublicUrl(raw, publicResolve)).rejects.toThrow(BlockedUrlError);
});
it("блокирует localhost и .local без резолва", async () => {
await expect(assertPublicUrl("http://localhost:3000/x", publicResolve)).rejects.toThrow(BlockedUrlError);
await expect(assertPublicUrl("http://printer.local/x", publicResolve)).rejects.toThrow(BlockedUrlError);
});
it("блокирует литеральный приватный IP", async () => {
await expect(assertPublicUrl("http://192.168.1.1/admin", publicResolve)).rejects.toThrow(BlockedUrlError);
await expect(assertPublicUrl("http://[::1]:8080/", publicResolve)).rejects.toThrow(BlockedUrlError);
});
it("блокирует хост, резолвящийся в приватный IP (включая частично)", async () => {
await expect(assertPublicUrl("https://evil.example/x", privateResolve)).rejects.toThrow(BlockedUrlError);
await expect(assertPublicUrl("https://evil.example/x", mixedResolve)).rejects.toThrow(BlockedUrlError);
});
it("блокирует хост, который не резолвится", async () => {
const failResolve = async () => { throw new Error("ENOTFOUND"); };
await expect(assertPublicUrl("https://nope.example/x", failResolve)).rejects.toThrow(BlockedUrlError);
});
it("блокирует v4-mapped IPv6 в bracket-нотации (SSRF-обход)", async () => {
for (const raw of [
"http://[::ffff:127.0.0.1]/",
"http://[::ffff:10.0.0.1]/",
"http://[::ffff:169.254.169.254]/",
"http://[::ffff:192.168.1.1]/",
]) {
await expect(assertPublicUrl(raw, publicResolve)).rejects.toThrow(BlockedUrlError);
}
});
});
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import { buildCleanHtml, safePdfFilename } from "@/lib/clean-pdf/template";
const base = {
title: "Заголовок статьи",
url: "https://example.com/article",
theme: "light" as const,
};
describe("buildCleanHtml", () => {
it("экранирует HTML в метаполях", () => {
const html = buildCleanHtml({ ...base, title: `<script>alert(1)</script>`, contentHtml: "<p>ок</p>" });
expect(html).not.toContain("<script>alert(1)</script>");
expect(html).toContain("&lt;script&gt;");
});
it("вставляет контент и шапку", () => {
const html = buildCleanHtml({ ...base, author: "Автор", site: "Example", contentHtml: "<p>Текст статьи</p>" });
expect(html).toContain("<p>Текст статьи</p>");
expect(html).toContain("Заголовок статьи");
expect(html).toContain("Автор");
expect(html).toContain("https://example.com/article");
});
it("строит оглавление при 3+ заголовках и проставляет якоря", () => {
const content = "<h2>Один</h2><p>a</p><h2>Два</h2><p>b</p><h3>Три</h3><p>c</p>";
const html = buildCleanHtml({ ...base, contentHtml: content });
expect(html).toContain("Содержание");
expect(html).toMatch(/<h2 id="[^"]+">Один<\/h2>/);
expect((html.match(/class="toc-item/g) ?? []).length).toBe(3);
});
it("не строит оглавление при <3 заголовках", () => {
const html = buildCleanHtml({ ...base, contentHtml: "<h2>Один</h2><p>a</p>" });
expect(html).not.toContain("Содержание");
});
it("уникализирует одинаковые якоря", () => {
const content = "<h2>Раздел</h2><h2>Раздел</h2><h2>Раздел</h2>";
const html = buildCleanHtml({ ...base, contentHtml: content });
const ids = [...html.matchAll(/<h2 id="([^"]+)"/g)].map((m) => m[1]);
expect(new Set(ids).size).toBe(3);
});
it("вырезает script и inline-обработчики из контента", () => {
const html = buildCleanHtml({ ...base, contentHtml: `<p onclick="alert(1)">t</p><script>alert(2)</script><a href="javascript:alert(3)">x</a>` });
expect(html).not.toContain("<script>alert(2)");
expect(html).not.toContain("onclick");
expect(html).not.toContain("javascript:alert(3)");
expect(html).toContain(">t<");
});
it("переключает тёмную тему", () => {
const light = buildCleanHtml({ ...base, contentHtml: "<p>x</p>" });
const dark = buildCleanHtml({ ...base, theme: "dark", contentHtml: "<p>x</p>" });
expect(light).toContain('data-theme="light"');
expect(dark).toContain('data-theme="dark"');
});
});
describe("safePdfFilename", () => {
it("убирает опасные символы и ограничивает длину", () => {
expect(safePdfFilename('Статья: как/не\\надо "делать"?')).toBe("Статья_ как_не_надо _делать_");
expect(safePdfFilename("")).toBe("document");
expect(safePdfFilename("a".repeat(300)).length).toBeLessThanOrEqual(140);
});
});
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { buildZoteroScript } from "@/lib/clean-pdf/zotero-script";
describe("buildZoteroScript", () => {
it("подставляет ключ и адрес школы", () => {
const s = buildZoteroScript({ apiKey: "sbpdf_test123", baseUrl: "https://school.second-brain.ru" });
expect(s).toContain("'sbpdf_test123'");
expect(s).toContain("'https://school.second-brain.ru'");
expect(s).toContain("/api/pdf?url=");
expect(s).not.toContain("YOUR_KEY");
});
it("генерирует синтаксически валидный JS", () => {
const s = buildZoteroScript({ apiKey: "sbpdf_test123", baseUrl: "https://school.second-brain.ru" });
expect(() => new Function(s)).not.toThrow();
});
});
+60
View File
@@ -0,0 +1,60 @@
import { prisma } from "@/lib/prisma";
// НЕ "clean-pdf": так CopyButton логирует копирования — они не должны тратить лимит
export const PDF_TOOL_ID = "clean-pdf-generate";
export const BURST_LIMIT = 5; // успешных генераций в минуту
const BURST_WINDOW_MS = 60_000;
export function decidePaidAccess(input: {
role: string;
banned: boolean;
enrollments: { courseSlug: string; expiresAt: Date | null }[];
freeSlug: string;
now?: Date;
}): boolean {
const now = input.now ?? new Date();
if (input.banned) return false;
if (input.role === "admin" || input.role === "curator") return true;
return input.enrollments.some(
(e) => e.courseSlug !== input.freeSlug && (e.expiresAt === null || e.expiresAt > now),
);
}
export async function hasPaidAccess(userId: string): Promise<boolean> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
role: true,
banned: true,
enrollments: { select: { expiresAt: true, course: { select: { slug: true } } } },
},
});
if (!user) return false;
return decidePaidAccess({
role: user.role,
banned: user.banned ?? false,
freeSlug: process.env.FREE_COURSE_SLUG ?? "obsidian-start",
enrollments: user.enrollments.map((e) => ({ courseSlug: e.course.slug, expiresAt: e.expiresAt })),
});
}
export function monthStartUtc(now: Date): Date {
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
}
export function getMonthlyLimit(): number {
const n = Number(process.env.PDF_MONTHLY_LIMIT);
return Number.isFinite(n) && n > 0 ? n : 100;
}
export async function getMonthlyUsage(userId: string): Promise<number> {
return prisma.toolUsage.count({
where: { userId, tool: PDF_TOOL_ID, createdAt: { gte: monthStartUtc(new Date()) } },
});
}
export async function getBurstUsage(userId: string): Promise<number> {
return prisma.toolUsage.count({
where: { userId, tool: PDF_TOOL_ID, createdAt: { gte: new Date(Date.now() - BURST_WINDOW_MS) } },
});
}
+7
View File
@@ -0,0 +1,7 @@
import { randomBytes } from "node:crypto";
export const PDF_KEY_PREFIX = "sbpdf_";
export function generatePdfKey(): string {
return PDF_KEY_PREFIX + randomBytes(24).toString("base64url");
}
+139
View File
@@ -0,0 +1,139 @@
import { isIP } from "node:net";
import { chromium } from "playwright-core";
import { JSDOM } from "jsdom";
import { Defuddle } from "defuddle/node";
import { assertPublicUrl, isPrivateAddress } from "@/lib/clean-pdf/ssrf";
import { buildCleanHtml } from "@/lib/clean-pdf/template";
export class EmptyContentError extends Error {}
export class RenderError extends Error {}
/**
* Оборачивает промис в app-level таймаут: если `p` не завершится за `ms`,
* гонка завершится отказом с RenderError, и внешний `finally` (browser.close())
* освободит слот browserless вместо того, чтобы держать его до зависшего вызова.
*/
function withTimeout<T>(p: Promise<T>, ms: number, message: string): Promise<T> {
let timer: ReturnType<typeof setTimeout>;
const guard = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new RenderError(message)), ms);
timer.unref?.();
});
return Promise.race([p, guard]).finally(() => clearTimeout(timer)) as Promise<T>;
}
const GOTO_TIMEOUT_MS = 30_000;
const SETTLE_TIMEOUT_MS = 10_000;
const PDF_TIMEOUT_MS = 30_000;
const UA =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36";
function browserWsUrl(): string {
const base = process.env.BROWSER_WS_URL;
if (!base) throw new RenderError("BROWSER_WS_URL не задан");
const token = process.env.BROWSERLESS_TOKEN;
return token ? `${base}${base.includes("?") ? "&" : "?"}token=${token}` : base;
}
export async function generateCleanPdf(opts: {
url: string;
format: "A4" | "Letter";
theme: "light" | "dark";
}): Promise<{ pdf: Buffer; title: string }> {
const target = await assertPublicUrl(opts.url);
const browser = await chromium.connectOverCDP(browserWsUrl()).catch((e) => {
throw new RenderError(`Браузер недоступен: ${e.message}`);
});
try {
const context = await browser.newContext({ userAgent: UA, viewport: { width: 1280, height: 800 } });
// Вторая линия SSRF-обороны: страница не может дёргать приватные адреса
// (литеральные IP и localhost; hostnames уже проверены до goto).
await context.route("**/*", (route) => {
try {
const u = new URL(route.request().url());
const host = u.hostname.replace(/^\[|\]$/g, "").replace(/\.$/, "");
if (
(u.protocol !== "http:" && u.protocol !== "https:") ||
host === "localhost" || host.endsWith(".local") || host.endsWith(".internal") ||
(isIP(host) !== 0 && isPrivateAddress(host))
) {
return route.abort();
}
} catch {
return route.abort();
}
return route.continue();
});
// context.route НЕ перехватывает WebSocket — отдельная защита от SSRF через ws://
await context.routeWebSocket("**/*", (ws) => {
try {
const u = new URL(ws.url());
const host = u.hostname.replace(/^\[|\]$/g, "").replace(/\.$/, "");
if (
(u.protocol !== "ws:" && u.protocol !== "wss:") ||
host === "localhost" || host.endsWith(".local") || host.endsWith(".internal") ||
(isIP(host) !== 0 && isPrivateAddress(host))
) {
ws.close();
return;
}
} catch {
ws.close();
return;
}
ws.connectToServer();
});
const page = await context.newPage();
await page.goto(target.href, { waitUntil: "domcontentloaded", timeout: GOTO_TIMEOUT_MS }).catch((e) => {
throw new RenderError(`Страница не открылась: ${e.message}`);
});
await page.waitForLoadState("networkidle", { timeout: SETTLE_TIMEOUT_MS }).catch(() => {});
const rawHtml = await page.content();
const pageTitle = await page.title().catch(() => "");
const dom = new JSDOM(rawHtml, { url: target.href });
const article = await Defuddle(dom.window.document, target.href);
// DefuddleResponse (node_modules/defuddle/dist/types.d.ts) типизирует
// content/title/author/site/domain/wordCount как обязательные (не optional) поля —
// сам объект тоже не nullable (Defuddle() не возвращает null/undefined).
// Отсутствие контента выражается пустой строкой/нулевым wordCount, не отсутствием поля.
if (!article.content || article.wordCount < 10) {
throw new EmptyContentError("Не удалось выделить содержимое страницы");
}
const title = article.title || pageTitle || target.hostname;
const cleanHtml = buildCleanHtml({
title,
author: article.author || undefined,
site: article.site || article.domain || undefined,
url: target.href,
contentHtml: article.content,
theme: opts.theme,
});
const pdfPage = await context.newPage();
await pdfPage.setContent(cleanHtml, { waitUntil: "networkidle", timeout: SETTLE_TIMEOUT_MS }).catch(() => {});
// page.pdf() в playwright-core не принимает опцию timeout (проверено по
// node_modules/playwright-core/types/types.d.ts) и не имеет implicit-бага —
// оборачиваем в app-level withTimeout, чтобы зависание всё же дошло до
// finally (browser.close()) и не держало CONCURRENT-слот browserless.
const pdf = await withTimeout(
pdfPage.pdf({
format: opts.format,
printBackground: true,
margin: { top: "15mm", bottom: "18mm", left: "15mm", right: "15mm" },
}),
PDF_TIMEOUT_MS,
"Печать PDF не завершилась вовремя",
);
return { pdf, title };
} finally {
await browser.close().catch(() => {});
}
}
+23
View File
@@ -0,0 +1,23 @@
import { prisma } from "@/lib/prisma";
import { generatePdfKey } from "@/lib/clean-pdf/api-key";
export async function getOrCreatePdfKey(userId: string): Promise<string> {
const existing = await prisma.pdfApiKey.findUnique({ where: { userId } });
if (existing) return existing.key;
const created = await prisma.pdfApiKey.upsert({
where: { userId },
create: { userId, key: generatePdfKey() },
update: {},
});
return created.key;
}
export async function regenerateKey(userId: string): Promise<string> {
const key = generatePdfKey();
await prisma.pdfApiKey.upsert({
where: { userId },
create: { userId, key },
update: { key },
});
return key;
}
+119
View File
@@ -0,0 +1,119 @@
import { lookup } from "node:dns/promises";
import { isIP } from "node:net";
export class BlockedUrlError extends Error {}
export type LookupFn = (
hostname: string,
opts: { all: true },
) => Promise<{ address: string; family: number }[]>;
function v4ToInt(ip: string): number {
return ip.split(".").reduce((acc, o) => acc * 256 + Number(o), 0);
}
// [начало включительно, конец включительно] в виде 32-битных чисел
const V4_PRIVATE: Array<[number, number]> = [
["0.0.0.0", "0.255.255.255"], // "this network"
["10.0.0.0", "10.255.255.255"], // RFC1918
["100.64.0.0", "100.127.255.255"], // CGNAT
["127.0.0.0", "127.255.255.255"], // loopback
["169.254.0.0", "169.254.255.255"], // link-local / cloud metadata
["172.16.0.0", "172.31.255.255"], // RFC1918 (docker-сети попадают сюда)
["192.0.0.0", "192.0.0.255"], // IETF protocol assignments
["192.168.0.0", "192.168.255.255"], // RFC1918
["198.18.0.0", "198.19.255.255"], // benchmarking
["224.0.0.0", "255.255.255.255"], // multicast + reserved + broadcast
].map(([a, b]) => [v4ToInt(a), v4ToInt(b)] as [number, number]);
function isPrivateV4(ip: string): boolean {
const n = v4ToInt(ip);
return V4_PRIVATE.some(([lo, hi]) => n >= lo && n <= hi);
}
/** Разворачивает IPv6 (в т.ч. сжатый `::` и v4-mapped/embedded dotted) в 8 групп по 16 бит. */
function expandV6(ip: string): number[] | null {
let s = ip.toLowerCase();
// Встроенный dotted-хвост (::ffff:127.0.0.1, ::127.0.0.1) → в hex-группы
const dotted = s.match(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
if (dotted) {
if (isIP(dotted[1]) !== 4) return null;
const n = v4ToInt(dotted[1]);
s = s.slice(0, dotted.index) + ((n >>> 16) & 0xffff).toString(16) + ":" + (n & 0xffff).toString(16);
}
const halves = s.split("::");
if (halves.length > 2) return null;
const head = halves[0] ? halves[0].split(":") : [];
const tail = halves.length === 2 ? (halves[1] ? halves[1].split(":") : []) : [];
let groups: string[];
if (halves.length === 2) {
const missing = 8 - head.length - tail.length;
if (missing < 0) return null;
groups = [...head, ...Array(missing).fill("0"), ...tail];
} else {
groups = head;
}
if (groups.length !== 8) return null;
const nums = groups.map((g) => (g === "" ? 0 : parseInt(g, 16)));
if (nums.some((x) => Number.isNaN(x) || x < 0 || x > 0xffff)) return null;
return nums;
}
export function isPrivateAddress(ip: string): boolean {
if (isIP(ip) === 4) return isPrivateV4(ip);
if (isIP(ip) !== 6) return true; // не IP — не пропускаем
const groups = expandV6(ip);
if (!groups) return true; // не смогли разобрать v6 — не пропускаем
// ::/96 (v4-compatible, вкл. :: и ::1) и ::ffff:0:0/96 (v4-mapped):
// младшие 32 бита содержат IPv4 — проверяем его напрямую.
const firstFiveZero = groups.slice(0, 5).every((g) => g === 0);
if (firstFiveZero && (groups[5] === 0 || groups[5] === 0xffff)) {
const v4num = groups[6] * 0x10000 + groups[7];
const dottedV4 = `${(v4num >>> 24) & 255}.${(v4num >>> 16) & 255}.${(v4num >>> 8) & 255}.${v4num & 255}`;
return isPrivateV4(dottedV4);
}
const first = groups[0];
if (first >= 0xfc00 && first <= 0xfdff) return true; // fc00::/7 (ULA)
if (first >= 0xfe80 && first <= 0xfebf) return true; // fe80::/10 (link-local)
return false;
}
export async function assertPublicUrl(raw: string, resolve: LookupFn = lookup): Promise<URL> {
let url: URL;
try {
url = new URL(raw);
} catch {
throw new BlockedUrlError("Некорректный URL");
}
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new BlockedUrlError("Поддерживаются только http и https");
}
const hostname = url.hostname.replace(/^\[|\]$/g, "").replace(/\.$/, ""); // [::1] → ::1
if (hostname === "localhost" || hostname.endsWith(".local") || hostname.endsWith(".internal")) {
throw new BlockedUrlError("Адрес недоступен");
}
if (isIP(hostname)) {
if (isPrivateAddress(hostname)) throw new BlockedUrlError("Адрес недоступен");
return url;
}
let addresses: { address: string; family: number }[];
try {
addresses = await resolve(hostname, { all: true });
} catch {
throw new BlockedUrlError("Не удалось определить адрес сайта");
}
if (addresses.length === 0 || addresses.some((a) => isPrivateAddress(a.address))) {
throw new BlockedUrlError("Адрес недоступен");
}
return url;
}
+118
View File
@@ -0,0 +1,118 @@
import { JSDOM } from "jsdom";
export interface CleanArticle {
title: string;
author?: string;
site?: string;
url: string;
contentHtml: string;
theme: "light" | "dark";
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
export function safePdfFilename(title: string): string {
const cleaned = title.replace(/[\\/:"*?<>|\n\r]+/g, "_").trim().slice(0, 140);
return cleaned || "document";
}
interface TocEntry { id: string; text: string; level: 2 | 3 }
/** Проставляет id заголовкам h2/h3 и возвращает контент + записи оглавления. */
function prepareContent(contentHtml: string): { html: string; toc: TocEntry[] } {
const dom = new JSDOM(`<body>${contentHtml}</body>`);
const doc = dom.window.document;
const used = new Set<string>();
const toc: TocEntry[] = [];
doc.querySelectorAll("h2, h3").forEach((h) => {
const text = (h.textContent ?? "").trim();
if (!text) return;
let id = text.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "") || "section";
let i = 2;
while (used.has(id)) id = `${id}-${i++}`;
used.add(id);
h.id = id;
toc.push({ id, text, level: h.tagName === "H2" ? 2 : 3 });
});
// Defuddle отдаёт очищенный, но не полностью доверенный HTML исходной
// страницы — вырезаем активное содержимое перед печатью в реальном браузере.
doc.querySelectorAll("script, style, iframe, object, embed").forEach((el) => el.remove());
doc.querySelectorAll("*").forEach((el) => {
for (const attr of [...el.attributes]) {
if (attr.name.startsWith("on") || (attr.name === "href" && attr.value.trim().toLowerCase().startsWith("javascript:"))) {
el.removeAttribute(attr.name);
}
}
});
return { html: doc.body.innerHTML, toc };
}
const CSS = `
:root[data-theme="light"] { --bg: #faf7f0; --fg: #1f1d1a; --muted: #6b6459; --line: #d8d2c4; --accent: #7a2e2e; }
:root[data-theme="dark"] { --bg: #201e1b; --fg: #e8e4dc; --muted: #a39b8d; --line: #3d3a34; --accent: #d4a0a0; }
* { box-sizing: border-box; }
body { background: var(--bg); color: var(--fg); font-family: Georgia, "Times New Roman", serif;
font-size: 12.5pt; line-height: 1.65; margin: 0; }
main { max-width: 100%; }
h1 { font-size: 22pt; line-height: 1.25; margin: 0 0 6pt; }
h2 { font-size: 16pt; margin: 20pt 0 8pt; }
h3 { font-size: 13.5pt; margin: 16pt 0 6pt; }
p { margin: 0 0 9pt; }
a { color: var(--accent); text-decoration: none; }
img, video, iframe { max-width: 100%; height: auto; }
pre { background: rgba(127,127,127,.08); border: 1px solid var(--line); padding: 8pt;
overflow-x: hidden; white-space: pre-wrap; word-wrap: break-word;
font-family: "SF Mono", Menlo, Consolas, monospace; font-size: 9.5pt; }
code { font-family: "SF Mono", Menlo, Consolas, monospace; font-size: 0.9em; }
blockquote { border-left: 3px solid var(--line); margin: 0 0 9pt; padding: 2pt 0 2pt 12pt; color: var(--muted); }
table { border-collapse: collapse; width: 100%; font-size: 10.5pt; }
th, td { border: 1px solid var(--line); padding: 4pt 6pt; text-align: left; }
figure { margin: 0 0 9pt; } figcaption { color: var(--muted); font-size: 9.5pt; }
.meta { color: var(--muted); font-size: 10pt; margin-bottom: 4pt; }
.meta a { color: var(--muted); }
.head-rule { border: 0; border-top: 1px solid var(--line); margin: 12pt 0 16pt; }
.toc { border: 1px solid var(--line); padding: 10pt 14pt; margin: 0 0 16pt; }
.toc-title { font-weight: bold; margin-bottom: 6pt; }
.toc-item { display: block; margin: 2pt 0; }
.toc-item.lvl3 { padding-left: 14pt; font-size: 0.92em; }
h2, h3 { break-after: avoid; } pre, blockquote, figure, table { break-inside: avoid; }
`;
export function buildCleanHtml(article: CleanArticle): string {
const { html, toc } = prepareContent(article.contentHtml);
const metaParts = [article.site, article.author].filter(Boolean).map((s) => escapeHtml(s!));
const tocHtml = toc.length >= 3
? `<nav class="toc"><div class="toc-title">Содержание</div>${toc
.map((t) => `<a class="toc-item lvl${t.level}" href="#${t.id}">${escapeHtml(t.text)}</a>`)
.join("")}</nav>`
: "";
return `<!DOCTYPE html>
<html lang="ru" data-theme="${article.theme}">
<head>
<meta charset="utf-8">
<title>${escapeHtml(article.title)}</title>
<style>${CSS}</style>
</head>
<body>
<main>
<h1>${escapeHtml(article.title)}</h1>
${metaParts.length ? `<div class="meta">${metaParts.join(" · ")}</div>` : ""}
<div class="meta"><a href="${escapeHtml(article.url)}">${escapeHtml(article.url)}</a></div>
<hr class="head-rule">
${tocHtml}
${html}
</main>
</body>
</html>`;
}
+80
View File
@@ -0,0 +1,80 @@
// Адаптация скрипта Clean PDF (pdf.brainysnipe.ru/zotero-script.js) под школу.
export function buildZoteroScript({ apiKey, baseUrl }: { apiKey: string; baseUrl: string }): string {
return `// ── Настройки ──────────────────────────────────────────────────────────────
const API_KEY = '${apiKey}'; // персональный ключ из ${baseUrl}/tools/clean-pdf
const SERVICE_URL = '${baseUrl}';
const FORMAT = 'A4'; // 'A4' или 'Letter'
const THEME = 'light'; // 'light' или 'dark'
// ───────────────────────────────────────────────────────────────────────────
if (typeof item === "undefined" || !item) return;
(async function () {
let tempFilePath = null;
try {
function joinPath(dir, filename) {
if (typeof OS !== "undefined" && OS.Path && OS.Path.join) return OS.Path.join(dir, filename);
const separator = dir.includes("\\\\") ? "\\\\" : "/";
return dir.replace(/[\\\\/]+$/, "") + separator + filename;
}
let targetItem = item;
if (!targetItem.isRegularItem()) {
if (targetItem.isAttachment() || targetItem.isNote())
targetItem = Zotero.Items.get(targetItem.parentID);
}
if (!targetItem?.isRegularItem()) {
Zotero.alert(null, "Чистый PDF", "Выберите обычную запись Zotero.");
return;
}
const url = targetItem.getField("url");
if (!url) { Zotero.alert(null, "Чистый PDF", "У записи нет URL."); return; }
const pw = new Zotero.ProgressWindow();
pw.changeHeadline("Чистый PDF");
const progress = new pw.ItemProgress(targetItem.getImageSrc(), targetItem.getField("title"));
pw.show();
pw.addDescription("Генерируем PDF…");
const response = await Zotero.getMainWindow().fetch(
SERVICE_URL + "/api/pdf?url=" + encodeURIComponent(url) + "&format=" + FORMAT + "&theme=" + THEME,
{ headers: { 'Authorization': 'Bearer ' + API_KEY } }
);
if (!response.ok) {
const body = await response.json().catch(() => ({}));
progress.setError();
pw.addDescription("Ошибка: " + (body.error || ("HTTP " + response.status)));
pw.startCloseTimer(8000);
return;
}
const uint8Array = new Uint8Array(await (await response.blob()).arrayBuffer());
if (uint8Array.length < 1000) {
progress.setError(); pw.addDescription("Пришёл пустой PDF."); pw.startCloseTimer(8000); return;
}
const safeTitle = (targetItem.getField("title") || "Document").replace(/[\\\\/:"*?<>|]+/g, "_").slice(0, 140);
tempFilePath = joinPath(Zotero.getTempDirectory().path, safeTitle + ".pdf");
await Zotero.getMainWindow().IOUtils.write(tempFilePath, uint8Array);
await Zotero.Attachments.importFromFile({ file: tempFilePath, parentItemID: targetItem.id, contentType: "application/pdf" });
for (const attId of targetItem.getAttachments()) {
const att = Zotero.Items.get(attId);
const ct = att?.attachmentContentType || "";
if (ct === "text/html" || ct === "application/zip") await att.eraseTx();
}
progress.setProgress(100);
const usesCount = response.headers.get('X-Uses-Count');
const maxUses = response.headers.get('X-Max-Uses');
const usageInfo = (usesCount && maxUses) ? (" (" + usesCount + "/" + maxUses + " за месяц)") : "";
pw.addDescription("PDF прикреплён." + usageInfo);
pw.startCloseTimer(4000);
} catch (e) {
Zotero.alert(null, "Ошибка", e.toString());
} finally {
if (tempFilePath) {
await Zotero.getMainWindow().IOUtils.remove(tempFilePath, { ignoreAbsent: true }).catch(() => {});
}
}
})();`;
}
+3 -1
View File
@@ -1,4 +1,4 @@
export type ToolId = "callout" | "frontmatter" | "theme" | "style-settings" | "dataview" | "bases"; export type ToolId = "callout" | "frontmatter" | "theme" | "style-settings" | "dataview" | "bases" | "clean-pdf";
export const TOOL_IDS: ToolId[] = [ export const TOOL_IDS: ToolId[] = [
"callout", "callout",
@@ -7,6 +7,7 @@ export const TOOL_IDS: ToolId[] = [
"style-settings", "style-settings",
"dataview", "dataview",
"bases", "bases",
"clean-pdf",
]; ];
export interface ToolMeta { export interface ToolMeta {
@@ -24,4 +25,5 @@ export const TOOLS: ToolMeta[] = [
{ id: "style-settings", title: "Генератор Style Settings", description: "Комментарии-настройки для плагина Style Settings.", icon: "SlidersHorizontal" }, { id: "style-settings", title: "Генератор Style Settings", description: "Комментарии-настройки для плагина Style Settings.", icon: "SlidersHorizontal" },
{ id: "dataview", title: "Генератор Dataview", description: "Запросы DQL: таблицы, списки и задачи по заметкам.", icon: "Table2" }, { id: "dataview", title: "Генератор Dataview", description: "Запросы DQL: таблицы, списки и задачи по заметкам.", icon: "Table2" },
{ id: "bases", title: "Генератор Bases", description: "Базы Obsidian (.base): представления с фильтрами.", icon: "Database" }, { id: "bases", title: "Генератор Bases", description: "Базы Obsidian (.base): представления с фильтрами.", icon: "Database" },
{ id: "clean-pdf", title: "Чистый PDF", description: "Любая веб-страница → аккуратный PDF без рекламы и мусора. Интеграция с Zotero.", icon: "FileText" },
]; ];
+1 -1
View File
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getSessionCookie } from "better-auth/cookies"; import { getSessionCookie } from "better-auth/cookies";
const PUBLIC_ROUTES = ["/login", "/register", "/verify-email", "/forgot-password", "/reset-password", "/api/auth", "/api/register", "/api/internal", "/maintenance", "/share/wrapped"]; const PUBLIC_ROUTES = ["/login", "/register", "/verify-email", "/forgot-password", "/reset-password", "/api/auth", "/api/register", "/api/internal", "/api/pdf", "/maintenance", "/share/wrapped"];
export function middleware(request: NextRequest) { export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl; const { pathname } = request.nextUrl;