10 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
14 changed files with 244 additions and 11 deletions
+2
View File
@@ -48,4 +48,6 @@ TOOLBOX_VISIBLE=""
# Чистый PDF (browserless на staging/prod; локально — SSH-туннель на staging) # Чистый PDF (browserless на staging/prod; локально — SSH-туннель на staging)
BROWSER_WS_URL="ws://localhost:3333" BROWSER_WS_URL="ws://localhost:3333"
# Секрет browserless (TOKEN в его env) — на прод/staging генерировать: openssl rand -hex 24
BROWSERLESS_TOKEN=""
PDF_MONTHLY_LIMIT="100" 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 повторными запросами. Рассмотреть подсчёт попыток, а не только успехов, отдельной задачей.
## Самопроверка при завершении ## Самопроверка при завершении
@@ -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)" }}
> >
@@ -8,6 +8,7 @@ export function ZoteroSection({ apiKey, baseUrl }: { apiKey: string; baseUrl: st
const [key, setKey] = useState(apiKey); const [key, setKey] = useState(apiKey);
const [revealed, setRevealed] = useState(false); const [revealed, setRevealed] = useState(false);
const [pending, startTransition] = useTransition(); const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const shownKey = revealed ? key : key.slice(0, 8) + "…" + key.slice(-4); 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 curlExample = `curl -H "Authorization: Bearer ${key}" \\\n "${baseUrl}/api/pdf?url=https://example.com/article&format=A4&theme=light" \\\n -o article.pdf`;
@@ -15,9 +16,19 @@ export function ZoteroSection({ apiKey, baseUrl }: { apiKey: string; baseUrl: st
function regenerate() { function regenerate() {
if (!confirm("Старый ключ перестанет работать (в том числе в Zotero). Продолжить?")) return; if (!confirm("Старый ключ перестанет работать (в том числе в Zotero). Продолжить?")) return;
setError(null);
startTransition(async () => { startTransition(async () => {
const res = await regeneratePdfApiKey(); try {
if (res.ok && res.key) { setKey(res.key); setRevealed(true); } const res = await regeneratePdfApiKey();
if (res.ok && res.key) {
setKey(res.key);
setRevealed(true);
} else {
setError("Не удалось перевыпустить ключ, попробуйте ещё раз");
}
} catch {
setError("Не удалось перевыпустить ключ, попробуйте ещё раз");
}
}); });
} }
@@ -40,6 +51,7 @@ export function ZoteroSection({ apiKey, baseUrl }: { apiKey: string; baseUrl: st
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}> <p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
Ключ персональный не публикуйте его. Если ключ утёк, перевыпустите: старый сразу отключится. Ключ персональный не публикуйте его. Если ключ утёк, перевыпустите: старый сразу отключится.
</p> </p>
{error && <p className="text-sm" style={{ color: "var(--destructive)" }}>{error}</p>}
</div> </div>
<div className="flex flex-col gap-2 text-sm" style={{ color: "var(--foreground)" }}> <div className="flex flex-col gap-2 text-sm" style={{ color: "var(--foreground)" }}>
@@ -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 });
}
@@ -42,6 +42,14 @@ describe("buildCleanHtml", () => {
expect(new Set(ids).size).toBe(3); 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("переключает тёмную тему", () => { it("переключает тёмную тему", () => {
const light = buildCleanHtml({ ...base, contentHtml: "<p>x</p>" }); const light = buildCleanHtml({ ...base, contentHtml: "<p>x</p>" });
const dark = buildCleanHtml({ ...base, theme: "dark", 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).toContain("/api/pdf?url=");
expect(s).not.toContain("YOUR_KEY"); 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();
});
}); });
+11
View File
@@ -42,6 +42,17 @@ function prepareContent(contentHtml: string): { html: string; toc: TocEntry[] }
toc.push({ id, text, level: h.tagName === "H2" ? 2 : 3 }); 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 }; return { html: doc.body.innerHTML, toc };
} }
+1 -1
View File
@@ -1,7 +1,7 @@
// Адаптация скрипта Clean PDF (pdf.brainysnipe.ru/zotero-script.js) под школу. // Адаптация скрипта Clean PDF (pdf.brainysnipe.ru/zotero-script.js) под школу.
export function buildZoteroScript({ apiKey, baseUrl }: { apiKey: string; baseUrl: string }): string { export function buildZoteroScript({ apiKey, baseUrl }: { apiKey: string; baseUrl: string }): string {
return `// ── Настройки ────────────────────────────────────────────────────────────── 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 SERVICE_URL = '${baseUrl}';
const FORMAT = 'A4'; // 'A4' или 'Letter' const FORMAT = 'A4'; // 'A4' или 'Letter'
const THEME = 'light'; // 'light' или 'dark' const THEME = 'light'; // 'light' или 'dark'