Compare commits
14 Commits
c50a295290
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 92fd88964e | |||
| 33c0d3cafd | |||
| 5d94949810 | |||
| 98cc023709 | |||
| 53caf54b03 | |||
| 4734b99bea | |||
| 118fa3961f | |||
| 1ccf994112 | |||
| 02b16e311b | |||
| f143f58cea | |||
| fd40b1e394 | |||
| 0cbea10e1a | |||
| 668dd29397 | |||
| 9ad4d762d7 |
@@ -48,4 +48,6 @@ 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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -82,11 +82,12 @@ model PdfApiKey {
|
||||
|
||||
- Только `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 — перехват сетевых запросов страницы с той же фильтрацией (защита от редиректов и подгрузок на внутренние адреса).
|
||||
- Внутри 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. Рассмотреть подсчёт попыток, а не только успехов, отдельной задачей.
|
||||
|
||||
## Деплой
|
||||
|
||||
@@ -96,6 +97,8 @@ model 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-шаблон на фикстурах.
|
||||
|
||||
@@ -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`.
|
||||
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.
|
||||
|
||||
> ⚠️ **Перед шагом 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), но анонсировать до включения тулбокса не нужно.
|
||||
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.
|
||||
7. Follow-up (не блокирует релиз): месячный/burst-лимит сейчас считает только **успешные** генерации — неудачные попытки (таймаут, 5xx, зависший рендер) лимит не расходуют, потенциальный ограниченный self-DoS повторными запросами. Рассмотреть подсчёт попыток, а не только успехов, отдельной задачей.
|
||||
|
||||
## Самопроверка при завершении
|
||||
|
||||
|
||||
@@ -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)" }}
|
||||
>
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
+22
-21
@@ -35,28 +35,28 @@ async function resolveUserId(req: NextRequest): Promise<string | null> {
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
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, "Слишком часто: подождите минуту");
|
||||
}
|
||||
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),
|
||||
});
|
||||
}
|
||||
|
||||
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 } });
|
||||
|
||||
@@ -75,6 +75,7 @@ export async function GET(req: NextRequest) {
|
||||
return jsonError(422, e.message);
|
||||
}
|
||||
if (e instanceof RenderError) {
|
||||
console.error("[clean-pdf] render", e);
|
||||
return jsonError(504, "Не получилось отрендерить страницу, попробуйте позже");
|
||||
}
|
||||
console.error("[clean-pdf]", e);
|
||||
|
||||
@@ -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,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 };
|
||||
}
|
||||
@@ -42,6 +42,14 @@ describe("buildCleanHtml", () => {
|
||||
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>" });
|
||||
|
||||
@@ -9,4 +9,9 @@ describe("buildZoteroScript", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,6 +42,17 @@ function prepareContent(contentHtml: string): { html: string; toc: TocEntry[] }
|
||||
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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Адаптация скрипта Clean PDF (pdf.brainysnipe.ru/zotero-script.js) под школу.
|
||||
export function buildZoteroScript({ apiKey, baseUrl }: { apiKey: string; baseUrl: string }): string {
|
||||
return `// ── Настройки ──────────────────────────────────────────────────────────────
|
||||
const API_KEY = '${apiKey}'; // персональный ключ из school.second-brain.ru/tools/clean-pdf
|
||||
const API_KEY = '${apiKey}'; // персональный ключ из ${baseUrl}/tools/clean-pdf
|
||||
const SERVICE_URL = '${baseUrl}';
|
||||
const FORMAT = 'A4'; // 'A4' или 'Letter'
|
||||
const THEME = 'light'; // 'light' или 'dark'
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user