Adapt clean-pdf plan to staging-based verification (no local Docker)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 11:13:22 +05:00
parent aaf84ea931
commit e795c4d655
+74 -32
View File
@@ -22,6 +22,7 @@
- Лимиты: `PDF_MONTHLY_LIMIT` (default 100), burst 5/мин, browserless `CONCURRENT=2`. - Лимиты: `PDF_MONTHLY_LIMIT` (default 100), burst 5/мин, browserless `CONCURRENT=2`.
- Проверки перед каждым коммитом: `npm run lint && npm run type-check && npm run test`. - Проверки перед каждым коммитом: `npm run lint && npm run type-check && npm run test`.
- Работать в ветке `feature/clean-pdf`. - Работать в ветке `feature/clean-pdf`.
- **Окружение:** на Mac НЕТ Docker. Контейнеры (postgres, browserless) живут на staging-сервере Hetzner: `root@178.104.27.196`, стек `/root/digital-household/lms-staging/` (docker-compose: `lms-staging-app-1` порт 3011 → https://staging.school.second-brain.ru, `lms-staging-db-1`). Сборочный клон репо на сервере: `/root/lms-staging-build/`. Деплой staging: `bash ~/Documents/Claude/scripts/deploy-staging.sh` (перед этим в `/root/lms-staging-build` должна быть выкачана нужная ветка). Миграции применяет entrypoint контейнера (`prisma migrate deploy`) при старте. Юнит-тесты, lint, type-check, build — локально на Mac; доступ к browserless с Mac — через SSH-туннель `ssh -f -N -L 3333:localhost:3333 root@178.104.27.196`. Все шаги плана с `docker compose ...` локально НЕ выполняются — их staging-эквиваленты прописаны в задачах.
--- ---
@@ -51,7 +52,7 @@ npm install defuddle jsdom playwright-core && npm install -D @types/jsdom
Expected: package.json/package-lock.json обновлены, без ошибок peer-deps. Expected: package.json/package-lock.json обновлены, без ошибок peer-deps.
- [ ] **Step 3: browserless в dev-compose** - [ ] **Step 3: browserless в dev-compose (на будущее, локально не проверяется)**
В `docker-compose.yml` добавить сервис (после `db`, до `volumes`): В `docker-compose.yml` добавить сервис (после `db`, до `volumes`):
@@ -67,13 +68,24 @@ Expected: package.json/package-lock.json обновлены, без ошибок
TIMEOUT: "120000" TIMEOUT: "120000"
``` ```
- [ ] **Step 4: Поднять и проверить browserless** На Mac Docker нет — этот файл правим для будущей локальной разработки, НЕ поднимаем.
```bash - [ ] **Step 4: browserless в staging-стек на Hetzner**
docker compose up -d browserless && sleep 5 && curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3333/docs
На сервере в `/root/digital-household/lms-staging/docker-compose.yml` добавить сервис `browserless` (тот же блок, что в Step 3 — с `ports: "127.0.0.1:3333:3000"`, порт нужен для SSH-туннеля с Mac), а в сервис `app``depends_on: browserless: condition: service_started` не добавлять (staging app перезапускается деплой-скриптом, зависимость не обязательна). В `/root/digital-household/lms-staging/.env` добавить:
```
BROWSER_WS_URL=ws://browserless:3000
PDF_MONTHLY_LIMIT=100
``` ```
Expected: `200`. Если имена env-переменных не подхватились (проверка: `docker compose logs browserless | head -30` — не должно быть предупреждений о неизвестных переменных), свериться с `http://localhost:3333/docs` и поправить. Правку compose на сервере делать так: скачать файл (`scp`), поправить локально, залить обратно — НЕ heredoc в ssh. Затем:
```bash
ssh root@178.104.27.196 "cd /root/digital-household/lms-staging && docker compose up -d browserless && sleep 5 && curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3333/docs"
```
Expected: `200`. Проверить логи: `ssh root@178.104.27.196 "docker logs lms-staging-browserless-1 --tail 20"` — без ошибок конфигурации.
- [ ] **Step 5: serverExternalPackages в next.config.ts** - [ ] **Step 5: serverExternalPackages в next.config.ts**
@@ -91,7 +103,7 @@ include: ["src/lib/**/__tests__/**/*.test.ts"],
- [ ] **Step 7: env для dev** - [ ] **Step 7: env для dev**
В `.env.local` добавить: В `.env.local` добавить (browserless доступен с Mac через SSH-туннель: `ssh -f -N -L 3333:localhost:3333 root@178.104.27.196`):
``` ```
BROWSER_WS_URL=ws://localhost:3333 BROWSER_WS_URL=ws://localhost:3333
@@ -711,13 +723,36 @@ model PdfApiKey {
pdfApiKey PdfApiKey? pdfApiKey PdfApiKey?
``` ```
- [ ] **Step 2: Прогнать миграцию на dev-базе** - [ ] **Step 2: Написать миграцию вручную и перегенерировать клиент**
```bash Локальной БД нет (`prisma migrate dev` невозможен) — миграцию пишем руками по образцу существующих (`prisma/migrations/20260623120000_add_tool_usage/migration.sql`), применит её `prisma migrate deploy` в entrypoint при деплое staging/prod.
docker compose up -d db && npx prisma migrate dev --name add_pdf_api_key
Создать `prisma/migrations/20260706120000_add_pdf_api_key/migration.sql`:
```sql
-- Чистый 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;
``` ```
Expected: миграция создана и применена, Prisma Client перегенерирован (`src/generated/prisma/`). Затем перегенерировать клиент (БД не нужна):
```bash
npx prisma generate
```
Expected: клиент в `src/generated/prisma/` обновился, `prisma.pdfApiKey` доступен в типах.
- [ ] **Step 3: Написать keys.ts** - [ ] **Step 3: Написать keys.ts**
@@ -1060,14 +1095,14 @@ describe.runIf(enabled)("generateCleanPdf (integration, нужен browserless)"
}); });
``` ```
- [ ] **Step 3: Run integration test** - [ ] **Step 3: Run integration test (browserless на staging через туннель)**
```bash ```bash
docker compose up -d browserless ssh -f -N -L 3333:localhost:3333 root@178.104.27.196 2>/dev/null || true
RUN_PDF_INTEGRATION=1 BROWSER_WS_URL=ws://localhost:3333 npx vitest run src/lib/clean-pdf/__tests__/generate.int.test.ts RUN_PDF_INTEGRATION=1 BROWSER_WS_URL=ws://localhost:3333 npx vitest run src/lib/clean-pdf/__tests__/generate.int.test.ts
``` ```
Expected: PASS (~1030 сек). Если URL статьи умер — заменить на любую живую статью с habr.com в тесте. Expected: PASS (~1040 сек с учётом туннеля). Если URL статьи умер — заменить на любую живую статью с habr.com в тесте. Если туннель не поднялся (`connection refused`) — проверить, что browserless на staging запущен (Task 1 Step 4).
- [ ] **Step 4: Сохранить образец глазами** - [ ] **Step 4: Сохранить образец глазами**
@@ -1204,35 +1239,42 @@ npm run lint && npm run type-check
Expected: PASS. Expected: PASS.
- [ ] **Step 4: Живая проверка через dev-сервер** - [ ] **Step 4: Живая проверка на staging**
Запушить ветку и задеплоить её на staging:
```bash ```bash
docker compose up -d db browserless git push -u origin feature/clean-pdf
npm run dev & ssh root@178.104.27.196 "cd /root/lms-staging-build && git fetch && git checkout feature/clean-pdf && git pull origin feature/clean-pdf"
sleep 8 bash ~/Documents/Claude/scripts/deploy-staging.sh
```
Expected: деплой зелёный (`✅ Staging OK`); в логах старта `Running database migrations...` без ошибок — миграция `add_pdf_api_key` применилась.
```bash
BASE=https://staging.school.second-brain.ru
# 1) Без авторизации → 401 # 1) Без авторизации → 401
curl -s -o /dev/null -w "%{http_code}\n" "http://localhost:3000/api/pdf?url=https://example.com" curl -s -o /dev/null -w "%{http_code}\n" "$BASE/api/pdf?url=https://example.com"
# 2) С мусорным ключом → 401 # 2) С мусорным ключом → 401
curl -s -H "Authorization: Bearer sbpdf_wrong" -o /dev/null -w "%{http_code}\n" "http://localhost:3000/api/pdf?url=https://example.com" curl -s -H "Authorization: Bearer sbpdf_wrong" -o /dev/null -w "%{http_code}\n" "$BASE/api/pdf?url=https://example.com"
``` ```
Expected: `401` и `401`, оба JSON. Затем взять реальный ключ (после Task 5 создать вручную: Expected: `401` и `401`, оба JSON. Затем вставить тестовый ключ админу в staging-БД:
```bash ```bash
docker compose exec db psql -U lms_user -d lms_db -c \ ssh root@178.104.27.196 "docker exec -i lms-staging-db-1 psql -U lms_staging_user -d lms_staging_db -c \"INSERT INTO \\\"PdfApiKey\\\" (id, \\\"userId\\\", key, \\\"createdAt\\\", \\\"updatedAt\\\") SELECT 'pdfkey-test-1', id, 'sbpdf_devtest123456789012345678901234', now(), now() FROM \\\"User\\\" WHERE role='admin' LIMIT 1;\""
"INSERT INTO \"PdfApiKey\" (id, \"userId\", key, \"createdAt\", \"updatedAt\") SELECT 'pdfkey-test-1', id, 'sbpdf_devtest123456789012345678901234', now(), now() FROM \"User\" WHERE role='admin' LIMIT 1;"
curl -s -D - -H "Authorization: Bearer sbpdf_devtest123456789012345678901234" \ curl -s -D - -H "Authorization: Bearer sbpdf_devtest123456789012345678901234" \
"http://localhost:3000/api/pdf?url=https://habr.com/ru/articles/942236/" -o /tmp/api-test.pdf | head -15 "$BASE/api/pdf?url=https://habr.com/ru/articles/942236/" -o /tmp/api-test.pdf | head -15
``` ```
Expected: `200`, `content-type: application/pdf`, заголовки `X-Uses-Count: 1`, `X-Max-Uses: 100`; `/tmp/api-test.pdf` открывается. Проверить SSRF: Expected: `200`, `content-type: application/pdf`, заголовки `X-Uses-Count: 1`, `X-Max-Uses: 100`; `/tmp/api-test.pdf` открывается (`file /tmp/api-test.pdf` → PDF). Если экранирование psql через ssh мешает — положить SQL в файл и передать через stdin: `ssh root@178.104.27.196 "docker exec -i lms-staging-db-1 psql -U lms_staging_user -d lms_staging_db" < /tmp/insert-key.sql`. Проверить SSRF:
```bash ```bash
curl -s -H "Authorization: Bearer sbpdf_devtest123456789012345678901234" "http://localhost:3000/api/pdf?url=http://192.168.1.1/" ; echo curl -s -H "Authorization: Bearer sbpdf_devtest123456789012345678901234" "$BASE/api/pdf?url=http://192.168.1.1/" ; echo
curl -s -H "Authorization: Bearer sbpdf_devtest123456789012345678901234" "http://localhost:3000/api/pdf?url=http://localhost:5432/"; echo curl -s -H "Authorization: Bearer sbpdf_devtest123456789012345678901234" "$BASE/api/pdf?url=http://localhost:5432/"; echo
``` ```
Expected: оба `{"error":"Адрес недоступен"}` (422). Остановить dev-сервер. Expected: оба `{"error":"Адрес недоступен"}` (422).
- [ ] **Step 5: Commit** - [ ] **Step 5: Commit**
@@ -1530,13 +1572,11 @@ npm run lint && npm run type-check && npm run test
Expected: PASS. Expected: PASS.
- [ ] **Step 6: Живая проверка в браузере** - [ ] **Step 6: Живая проверка в браузере (staging)**
```bash Задеплоить текущее состояние ветки на staging (как в Task 8 Step 4: push → checkout ветки в `/root/lms-staging-build``deploy-staging.sh`).
docker compose up -d db browserless && npm run dev
```
Вручную (или через agent-browser в `--headed`): войти студентом с платным курсом → /tools → карточка «Чистый PDF» видна → открыть → сгенерировать PDF реальной статьи → файл скачался, счётчик увеличился → ключ показать/скопировать/перевыпустить (после перевыпуска старый ключ по curl даёт 401) → у аккаунта только с бесплатным курсом страница показывает заглушку, а /api/pdf отдаёт 403. Через agent-browser в `--headed` (или вручную) на `https://staging.school.second-brain.ru`: войти админом (креды — memory `reference_lms_admin_credentials.md`; staging-БД содержит копию пользователей) → /tools → карточка «Чистый PDF» видна → открыть → сгенерировать PDF реальной статьи → файл скачался, счётчик увеличился → ключ показать/скопировать/перевыпустить (после перевыпуска старый ключ по curl даёт 401). Проверка заглушки для бесплатного аккаунта: найти в staging-БД пользователя без платных enrollment (или временно создать) и убедиться, что страница показывает заглушку, а /api/pdf с его сессией отдаёт 403.
- [ ] **Step 7: Commit** - [ ] **Step 7: Commit**
@@ -1609,6 +1649,8 @@ git commit -m "Add browserless service to prod compose and document clean-pdf"
## Деплой-заметки (выполняются при релизе, не частью этого плана) ## Деплой-заметки (выполняются при релизе, не частью этого плана)
0. Staging уже получил browserless и env в Task 1 — на релизе речь только о проде (Hoster.kz).
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.