diff --git a/AGENTS.md b/AGENTS.md index 7ff4a793..51ff7679 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ These are product/architecture constraints — not a ban on editing existing cod - Single Next.js app, three route groups: `(frontend)` public site, `(payload)` admin, `(campaign)` internal campaign tool. No separate Rust service or frontend app for `/campanha`; it is modeled with Payload collections. The campaign roles are `coordinator` (label "Coordenador Geral"), `advisor` ("Assessor"), `leader` ("Liderança"), and `candidate` ("Candidato", unrestricted visibility), all on the separate auth collection `campaignUser` (see "Campaign auth" below). An advisor sees and manages ONLY the municipalities they administer; a leader is locked down to the supporter contact tool. - **2026-07-23 remodel (DEPLOYED to production 2026-07-23): "Município" is the operational unit** (it replaced "Praça", which replaced "Núcleo Eleitoral"). Municípios are PREDEFINED (seeded, geography read-only): 435 rows — one per Bahia municipality (416), except Salvador (19 zone Municípios, ZE 1–19); Camaçari is a single whole municipality (ZE 170/171 aggregated); shared/leaky TSE zones are cut at the municipal boundary. Code identifier: `municipality`. Leaderships are one record per person (`contact` UNIQUE) linked to N municipalities and N organizations; vote pledges are per leadership×municipality with `declaredVotes` (staff-entered, leader-visible) vs `estimatedVotes` (three scenarios, staff-only, hidden from the leader). Campaign production data was reset by the remodel migrations (public-site data untouched). Master plan: `docs/plans/remodelagem-municipios.md` (supersedes `remodelagem-pracas.md`). Details: "Campaign Municípios model" below. -- Production is self-hosted on the homeserver (`jorgesolla1313.com.br`, behind the Cloudflare tunnel; container/database `teqo-1313`/`teqo_1313`). **There is no Vercel deploy anymore** — the Vercel deploy job, `vercel-promote.yml`, the Vercel scripts/`vercel.json` and the `VERCEL_*` repo secrets were removed in OPS50 (2026-08-17). The CI (`ci.yml` on `main`) is the verifier — full suite green — **plus the deploy step (OPS53, 2026-08-17)**: the `deploy` job (runs-on `host` on the workstation runner) streams `scripts/deploy-homeserver.sh` to the homeserver over SSH; the remote script builds the standalone image at the pushed SHA (BuildKit secrets from `~/stack/teqo-1313.env`), pushes it to the local registry (`localhost:5000`), applies pending migrations through the compose maintenance service `teqo-1313-migrate` **before** the rollout, and recreates the container. Merge em `main` verde == site atualizado. Runbook (rollback, falhas conhecidas): `docs/ops/teqo-1313-deploy.md`. **The production database lives on the homeserver Postgres (`teqo_1313`, migrated from Neon in OPS51, 2026-08-17)** — the full production content (contacts, signatures, subscriptions, campaign) was restored there via `pg_dump`/`pg_restore` (dump in `/srv/hdd/backups/teqo-neon-pre-migracao/`); Neon remains alive and untouched as the documented rollback (re-point `DATABASE_URL` in `~/stack/teqo-1313.env`). Media moved to the homeserver Garage S3 in OPS52 (2026-08-18): the `media` collection uploads via `@payloadcms/storage-s3` when the `S3_*` envs are set, and Payload proxies the files through `/api/media/file/...` (the bucket stays private). Production env lives in `~/stack/teqo-1313.env` (bucket `teqo-media`, endpoint tailnet `http://100.119.220.31:3900`, key `teqo-key`); a partial `S3_*` config aborts the boot, and no `S3_*` means local disk (dev/test). Render of the 40 pre-existing media rows is still broken (they reference local `/api/media/file/...` paths with no files anywhere) — recovery is OPS52-media scope; the URLs themselves are the same contract the proxy now serves. Never point dev/test databases at the homeserver prod DB either — the local guards (`guard-dev-db`, `assertTestDatabase`) still enforce `localhost`/`_test`-suffixed targets. +- Production is self-hosted on the homeserver (`jorgesolla1313.com.br`, behind the Cloudflare tunnel; container/database `teqo-1313`/`teqo_1313`). **There is no Vercel deploy anymore** — the Vercel deploy job, `vercel-promote.yml`, the Vercel scripts/`vercel.json` and the `VERCEL_*` repo secrets were removed in OPS50 (2026-08-17). The CI (`ci.yml` on `main`) is the verifier — full suite green — **plus the deploy step (OPS53, 2026-08-17)**: the `deploy` job (runs-on `host` on the workstation runner) streams `scripts/deploy-homeserver.sh` to the homeserver over SSH; the remote script builds the standalone image at the pushed SHA (BuildKit secrets from `~/stack/teqo-1313.env`), pushes it to the local registry (`localhost:5000`), applies pending migrations through the compose maintenance service `teqo-1313-migrate` **before** the rollout, and recreates the container. Merge em `main` verde == site atualizado. Runbook (rollback, falhas conhecidas): `docs/ops/teqo-1313-deploy.md`. **The production database lives on the homeserver Postgres (`teqo_1313`, migrated from Neon in OPS51, 2026-08-17)** — the full production content (contacts, signatures, subscriptions, campaign) was restored there via `pg_dump`/`pg_restore` (dump in `/srv/hdd/backups/teqo-neon-pre-migracao/`); Neon remains alive and untouched as the documented rollback (re-point `DATABASE_URL` in `~/stack/teqo-1313.env`). Media moved to the homeserver Garage S3 in OPS52 (2026-08-18): the `media` collection uploads via `@payloadcms/storage-s3` when the `S3_*` envs are set, and Payload proxies the files through `/api/media/file/...` (the bucket stays private). Production env lives in `~/stack/teqo-1313.env` (bucket `teqo-media`, endpoint tailnet `http://100.119.220.31:3900`, key `teqo-key`); a partial `S3_*` config aborts the boot, and no `S3_*` means local disk (dev/test). Render of the 40 pre-existing media rows is still broken (they reference local `/api/media/file/...` paths with no files anywhere) — recovery is `pnpm media:recover` (OPS52-media: restaura os objetos no bucket a partir do WordPress, sem tocar no DB; runbook em `docs/plans/ops52-media-recuperar-arquivos-impl.md`); the URLs themselves are the same contract the proxy now serves. Never point dev/test databases at the homeserver prod DB either — the local guards (`guard-dev-db`, `assertTestDatabase`) still enforce `localhost`/`_test`-suffixed targets. - Donations are NOT handled in this app — they run through QueroApoiar (`apoiar.me/jorgesolla`, TSE-homologated). The site only needs a "Doar" link/CTA out to that URL. - **Embasamento de produto/domínio:** `docs/research/` guarda o compêndio de literatura (campanha de DF na Bahia, geografia do voto, cartografia eleitoral) e o relatório de discovery aprovado (2026-07-21) com o playbook dado→decisão. Para métricas, análises territoriais, mapa e priorização em `/campanha`, consulte-o antes de inventar — o kernel: a disputa de DF é conta de quociente fragmentada; leitura útil é relativa e local (captura, LQ, quantis, cobertura de meta por pledges), nunca % estadual absoluto. diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index f9cfe43d..96de6280 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -12,6 +12,10 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente **Recently resolved (2026-08-18):** **OPS52** — a media saiu do Vercel Blob para o Garage S3 do homeserver (bucket `teqo-media` + key `teqo-key`, já existentes no Garage v2.1.0). O plugin `@payloadcms/storage-vercel-blob` (e o `@vercel/blob` do seed) foi trocado por `@payloadcms/storage-s3` na config, no padrão R2 da doc do Payload (`endpoint` + `forcePathStyle: true`, region `garage`). **Decisão de arquitetura (revisão A→B durante a execução, gate com o humano):** o Garage v2.1.0 **não suporta acesso anônimo na API S3** (sem `--public-read`; source: "when we support anonymous access in the future"; testado: `anonymous`/`anon` → `NoSuchAccessKey`), então o bucket fica **privado** e a app **serve a media por proxy** — sem `disablePayloadAccessControl`, o `staticHandler` do plugin streama o objeto do Garage em `/api/media/file/` (range + etag/304 nativos) — o **mesmo contrato de URL relativa que as 40 rows pré-existentes já têm** (a recuperação dos arquivos é a Issue #10/OPS52-media). **Fail-closed:** `resolveS3StorageEnv` (`src/utilities/mediaStorage.ts`, unit-testado) — envs `S3_*` parciais → throw no boot com a lista exata de faltantes; nenhuma → storage local (dev/test). Envs novas: `S3_BUCKET`, `S3_ENDPOINT`, `S3_REGION` (default `garage`), `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY` (`.env.example`, `.env.test`, worktree.mjs com cópia all-or-nothing). Seed de posts: pre-delete do Blob removido (o S3 sobrescreve o key determinístico nativamente — idempotência intacta). Prova de contrato real: PUT/GET/DELETE via SDK contra o Garage (tailnet `http://100.119.220.31:3900`), depois media criada pelo Payload local com S3 ativo → `media.url` relativa + `GET /api/media/file/...` → 200 image/png; bucket devolvido a 0 objects e `.env.local` do worktree limpo (sem credenciais de prod). Sem migration (config-only; importMap regenerado sem `VercelBlobClientUploadHandler`). Runbook de cutover registrado no impl plan: setar as 5 `S3_*` no `~/stack/teqo-1313.env` (endpoint tailnet) antes do próximo deploy (OPS53). Repo: `docs/plans/ops52-blob-vercel-para-garage-impl.md`; infra-solla `STATE.md` atualizado à parte. +**Recently resolved (2026-08-18):** **OPS52-media** — os objetos de media de produção (as ~40 capas dos posts, que existiam como rows com URL relativa `/api/media/file/` mas sem arquivo em lugar nenhum) são recuperáveis do WordPress de origem com `pnpm media:recover` (`scripts/recover-media.mjs`, novo): lê as rows de `media` + `post.coverImage` (mapeamento exato row→slug; fallback pelo filename determinístico `.`), resolve a capa com a MESMA lógica do seed (featured media do REST `_embed`, senão 1ª imagem inline do corpo — só 7/43 posts têm featured), baixa e faz `PutObject` no bucket das envs `S3_*` com key = `filename` da row, **sem nunca escrever no DB** (rows e URLs intocadas; o staticHandler do Payload passa a servir). Extraído para `scripts/lib/wpArticles.mjs` o fetch ao vivo do WordPress (seed-posts agora importa de lá — sem twin; unit-testado). Fail-closed: exige as 4 `S3_*` completas (`resolveS3StorageEnv` reusado; recuperação nunca vai pro storage local), `ALLOW_REMOTE_DB=true` explícito para DB remoto + echo do alvo (DB/bucket/endpoint), e timeouts curtos no SDK (`@smithy/node-http-handler`, 5s/15s) — endpoint inalcançável falha em segundos em vez de pendurar (a rota pública de media de prod pendurava >25s; evidência de possível falta de rota container→Garage, diagnosticada no runbook do impl plan). Modos: reconcile (padrão, upload idempotente por overwrite), `--dry-run` (plano + `headObject`), `--verify` (HEAD da URL pública por filename — o aceite da Issue, sem credencial). Runbook de execução no homeserver: `docs/plans/ops52-media-recuperar-arquivos-impl.md`. + +**Recently resolved (2026-08-18):** **OPS52-media** — os objetos de media de produção (as ~40 capas dos posts, que existiam como rows com URL relativa `/api/media/file/` mas sem arquivo em lugar nenhum) são recuperáveis do WordPress de origem com `pnpm media:recover` (`scripts/recover-media.mjs`, novo): lê as rows de `media` + `post.coverImage` (mapeamento exato row→slug; fallback pelo filename determinístico `.`), resolve a capa com a MESMA lógica do seed (featured media do REST `_embed`, senão 1ª imagem inline do corpo — só 7/43 posts têm featured), baixa e faz `PutObject` no bucket das envs `S3_*` com key = `filename` da row, **sem nunca escrever no DB** (rows e URLs intocadas; o staticHandler do Payload passa a servir). Extraído para `scripts/lib/wpArticles.mjs` o fetch ao vivo do WordPress (seed-posts agora importa de lá — sem twin; unit-testado). Fail-closed: exige as 4 `S3_*` completas (`resolveS3StorageEnv` reusado; recuperação nunca vai pro storage local; `--verify` dispensa S3 — só HEAD de URLs públicas), `ALLOW_REMOTE_DB=true` explícito para DB remoto + echo do alvo (DB/bucket/endpoint/origin), timeouts curtos no SDK (`@smithy/node-http-handler`, 5s/15s) e nos fetches (30s/60s) — endpoint inalcançável falha em segundos em vez de pendurar (a rota pública de media de prod pendurava >25s; evidência de possível falta de rota container→Garage, diagnosticada no runbook do impl plan), e covers constrangidas a http(s) no host do WordPress com só `image/*` armazenado (sem SSRF/objeto não-imagem no proxy). Modos: reconcile (padrão, upload idempotente por overwrite), `--dry-run` (plano + `headObject`; exit 1 se qualquer checagem falhar), `--verify` (HEAD da URL pública por filename — o aceite da Issue, sem credencial). Runbook de execução no homeserver: `docs/plans/ops52-media-recuperar-arquivos-impl.md`. + **Recently resolved (2026-08-17):** **OPS51** — o banco de produção saiu do Neon para o Postgres do homeserver (`teqo_1313`, container `teqo-1313`), sem mudança de config: o `~/stack/teqo-1313.env` já apontava para o banco preparado (criado 16/08 com role própria + cadeia completa de 56 migrations + zero conteúdo — verificado), então restaurar = a produção "apareceu" no container. **Divergência deliberada da intenção (questão A/B validada como B):** rejeitado criar `teqo_prod` novo — o argumento "o banco do 1313 tem vida própria" não se sustenta (o site 1313 É a produção deste repo) e o OPS53 já trata `teqo_1313` como produção. Execução 100% no homeserver (PII nunca tocou a workstation): URL do Neon puxada do Vercel (`vercel env pull`, projeto `jorgesolla`, `DATABASE_URL_UNPOOLED` → `NEON_DATABASE_URL` em `~/stack/.env` chmod 600), `pg_dump -Fc` (Neon PG 17.10) → `/srv/hdd/backups/teqo-neon-pre-migracao/teqo-neon-full-20260817-204800.dump` (5,8 MB, sha256 `f12196f2…`), `pg_restore --no-owner --no-privileges` como a role `teqo_1313` (drop schema + grant, padrão do `db-pull`), verificado: contagens por tabela idênticas ao Neon (1919 contatos, 39 posts, 1487 assinaturas, 1484 subscrições, 35 campaign_users, 392 lideranças, 2 pledges, 435 municípios, 6 atividades; supporter segue 0 — blocker LGPD ainda de pé), sequences no topo (`last_value == max(id)`), objetos com dono `teqo_1313`, `payload migrate` no-op (56 = 56, serviço de maintenance `teqo-1313-migrate`), 3 credenciais WebAuthn + 34 sessions + 5 chaves Consent restauradas (RP ID não mudou — biometria segue valendo). Smoke sem deploy: `/`, artigo com conteúdo restaurado, `/campanha/login`, `/admin`, `POST /api/revalidate` (`posts` + `global_privacy-policy`), WebAuthn `login-options`, barreira 307 sem sessão — verdes; smoke de browser (login, biometria, convites) validado manualmente. **Rollback documentado:** Neon nunca foi escrito (só lido); voltar = re-apontar `DATABASE_URL` do `teqo-1313.env` para o Neon + `compose up -d`. **Débito registrado (→ OPS52):** as 40 rows de `media` têm URLs relativas (`/api/media/file/…`) sem arquivos em lugar nenhum — capas dão 500 no homeserver e **404 no próprio Vercel/produção atual** (estado pré-existente; migração manteve paridade). Repo: plano da intenção concluído, `AGENTS.md` e help de `db:pull`/`check-push-chain` sem a referência ao Neon; infra-solla `STATE.md` atualizado à parte. **Recently resolved (2026-08-17):** **OPS53** — o deploy do site 1313 virou etapa do CI: mergir em `main` com a suite verde == site atualizado em `jorgesolla1313.com.br`, sem passo manual. O job `deploy` em `.forgejo/workflows/ci.yml` roda no host do runner (workstation, `runs-on: host`) e streama `scripts/deploy-homeserver.sh` para o homeserver via SSH (`ssh homeserver "bash -s -- "`); o script remoto (bash puro, homeserver sem dependências novas) faz: guard de HEAD (`git ls-remote` de `main` == SHA do job, senão skip "stale"), `flock` de serialização, workspace `~/teqo-deploy` (clone do Forgejo local), `docker login localhost:5000` (REGISTRY_USER/PASSWORD de `~/stack/.env`), build dos stages `runner`+`migrator` no homeserver (`--network stack_default` para a geração estática alcançar o `postgres:5432` do compose; secrets de `~/stack/teqo-1313.env` via BuildKit, sem camadas/logs), push no registry local, swap dos tags de imagem no compose (com backup `docker-compose.yml.pre-`), **migrate pelo serviço de maintenance `teqo-1313-migrate` antes do rollout**, `docker compose up -d`, espera do healthcheck e smoke (`/`, `/campanha/login`, `/admin`, barreira 307, WebAuthn `login-options` com body JSON, `POST /api/revalidate` com o secret real — rehearsal validado contra o site vivo). Falha pós-swap → rollback automático best-effort (restore do backup + `up -d` da imagem anterior) + job vermelho. **Divergência deliberada da intenção (fato novo):** a intenção assumia o runner no homeserver; verificado (`infra-solla/STATE.md` + ao vivo) que o runner é a `workstation-runner` na workstation e o stack de produção vive no homeserver — a abordagem A foi corrigida para "job `runs-on: host` na workstation + todo o deploy no homeserver via SSH", o que manteve **zero secrets novos no Forgejo** e zero mudança de infra no compose. Nenhum secret no repo; envs seguem chmod-600 no homeserver. Runbook (rollback manual, falhas conhecidas, decisão de segurança do host label): `docs/ops/teqo-1313-deploy.md` (novo). Guarda estrutural nova: `tests/unit/deployScript.unit.spec.ts` (bash -n + invariantes do pipeline: HEAD guard, BuildKit secrets, ordem migrate→rollout, smoke). AGENTS.md/AGENT-OPS.md atualizados. Primeiro deploy real = o merge deste próprio item (tracer bullet); infra-solla `STATE.md` anotado à parte. diff --git a/docs/changelog/2026-08-18-ops52-media.md b/docs/changelog/2026-08-18-ops52-media.md new file mode 100644 index 00000000..33907b30 --- /dev/null +++ b/docs/changelog/2026-08-18-ops52-media.md @@ -0,0 +1 @@ +**Recently resolved (2026-08-18):** **OPS52-media** — os objetos de media de produção (as ~40 capas dos posts, que existiam como rows com URL relativa `/api/media/file/` mas sem arquivo em lugar nenhum) são recuperáveis do WordPress de origem com `pnpm media:recover` (`scripts/recover-media.mjs`, novo): lê as rows de `media` + `post.coverImage` (mapeamento exato row→slug; fallback pelo filename determinístico `.`), resolve a capa com a MESMA lógica do seed (featured media do REST `_embed`, senão 1ª imagem inline do corpo — só 7/43 posts têm featured), baixa e faz `PutObject` no bucket das envs `S3_*` com key = `filename` da row, **sem nunca escrever no DB** (rows e URLs intocadas; o staticHandler do Payload passa a servir). Extraído para `scripts/lib/wpArticles.mjs` o fetch ao vivo do WordPress (seed-posts agora importa de lá — sem twin; unit-testado). Fail-closed: exige as 4 `S3_*` completas (`resolveS3StorageEnv` reusado; recuperação nunca vai pro storage local; `--verify` dispensa S3 — só HEAD de URLs públicas), `ALLOW_REMOTE_DB=true` explícito para DB remoto + echo do alvo (DB/bucket/endpoint/origin), timeouts curtos no SDK (`@smithy/node-http-handler`, 5s/15s) e nos fetches (30s/60s) — endpoint inalcançável falha em segundos em vez de pendurar (a rota pública de media de prod pendurava >25s; evidência de possível falta de rota container→Garage, diagnosticada no runbook do impl plan), e covers constrangidas a http(s) no host do WordPress com só `image/*` armazenado (sem SSRF/objeto não-imagem no proxy). Modos: reconcile (padrão, upload idempotente por overwrite), `--dry-run` (plano + `headObject`; exit 1 se qualquer checagem falhar), `--verify` (HEAD da URL pública por filename — o aceite da Issue, sem credencial). Runbook de execução no homeserver: `docs/plans/ops52-media-recuperar-arquivos-impl.md`. diff --git a/docs/plans/ops52-media-recuperar-arquivos-impl.md b/docs/plans/ops52-media-recuperar-arquivos-impl.md new file mode 100644 index 00000000..299ba572 --- /dev/null +++ b/docs/plans/ops52-media-recuperar-arquivos-impl.md @@ -0,0 +1,253 @@ +# Impl: OPS52-media — Recuperar os arquivos de media de produção (capas dos artigos) + +Status: aprovado (gate humano 2026-08-18) +Atualizado em: 2026-08-18 +Issue: #10 (OPS52-media) +Intenção: docs/plans/ops52-media-recuperar-arquivos.md +Appetite restante: herdado (~0,5–1 dia eng; um outcome verificável) + +## Leitura da intenção + +- **Outcome:** as ~40 capas de media de produção resolvem no site público: + `GET https://jorgesolla1313.com.br/api/media/file/` → **200 com a + imagem** para toda row; seção S1 da home e artigos exibem as capas; uploads + novos continuam servindo; fail-closed de storage mantido; **zero mudança de + schema/migration/Consent**. +- **O que NÃO negociar:** URLs das rows intocadas (contrato relativo via proxy); + sem reescrita em massa no banco; sem placeholder "em breve"; sem gravar em + storage errado; DB nunca escrito pela ferramenta de recuperação. +- **O que reavaliar:** a hipótese "só falta o arquivo no bucket" precisa de um + pre-flight — ver Evidência E3 (rota de media de prod hoje **pendura**, não 404). + +## Evidência coletada (2026-08-18, probes read-only) + +- **E1 — Fonte das capas no WordPress:** o REST `_embed` de jorgesolla.com.br + tem **7/43** posts com `wp:featuredmedia`; a maioria das capas do seed vem do + fallback **primeira imagem inline do corpo** (`processContent` em + `scripts/seed-posts.mjs` — resolução compartilhada via `resolveCoverSource`). + A recuperação replica essa resolução exata (featured → senão 1º `img[src]` do + HTML), senão cobre errado. Todas as 42 capas resolvidas de hoje estão no host + `jorgesolla.com.br` (probe) — o que permite o allowlist de host no download. +- **E2 — Contrato de URL:** aceite e AGENTS.md dizem `/api/media/file/`; + o frontend usa `cover.url` relativo como `src` (src/components/PostCard.tsx:26) + e o staticHandler do `@payloadcms/storage-s3` serve a rota streamando o objeto + (getObject com range/etag-304 nativos; `NoSuchKey` → 404 rápido, ver + `node_modules/@payloadcms/storage-s3/dist/staticHandler.js`). +- **E3 — Rota de media em prod HOJE:** `curl -I https://jorgesolla1313.com.br/api/media/file/` + → conexão TLS ok, **sem resposta em 25s+** (não é 404). O handler 404-rápido + para chave ausente ⇒ o `headObject` do container prod provavelmente **não + completa** (rota container→Garage tailnet suspeita; Garage está vivo e + alcançável pelo tailnet desta máquina — 403 no probe sem assinatura). O + pre-flight do runbook separa "arquivo faltando" (esta Issue) de + "container prod sem rota ao Garage" (infra — provável sucessor OPS se + confirmado; o aceite desta Issue exige a rota pública 200). +- **E4 — Postgres prod não exposto ao host** (5432 do homeserver recusou + conexão): o script de recuperação roda **no homeserver**, dentro da rede do + compose (padrão do serviço `teqo-1313-migrate`), com `~/stack/teqo-1313.env` + (DATABASE*URL resolve o serviço `postgres`; S3*\* presentes). +- **E5 — Rows:** 40 rows de `media` (39 capas do seed + 1 órfã provável), + `filename` único e indexado; o mapeamento row→post é exato via + `post.coverImage` (FOREIGN KEY confirmada no schema local); fallback + filename-derivado para órfãs. + +## Abordagem recomendada + +```mermaid +flowchart LR + A[rows media + posts.coverImage] -->|Payload find, DB prod| B[recover-media.mjs] + C[WordPress REST _embed
scripts/lib/wpArticles.mjs] -->|slug -> coverUrl
featured | inline 1o img| B + B -->|PutObject key = filename
SDK S3 SigV4, bucket teqo-media| G[Garage prod] + B -.->|--dry-run headObject
--verify HEAD URL pública| R[relatório por row] + P[browser] -->|GET /api/media/file/<filename>| X[Payload staticHandler] --> G +``` + +**Opções consideradas:** + +- **A — Script CLI commitado `scripts/recover-media.mjs`** (modo `reconcile` / + `--dry-run` / `--verify`), lendo rows do DB prod e escrevendo SÓ objetos no + bucket via `@aws-sdk/client-s3`, com guards fail-closed e runbook no + homeserver. **Recomendada.** +- B — One-off manual no homeserver (curl/SDK em scratch) sem código commitado — + sem evidência de aceite reproduzível, sem guard de alvo, contrário à própria + intenção ("ferramenta de reconciliação deve ser explícita sobre o alvo"). +- C — Estender `scripts/seed-posts.mjs` com `--restore` — acopla seed de + conteúdo (guard local-only, idempotência por slug) com recuperação de prod + (bucket + DB remoto): semântica confusa, risco de rodar contra DB errado. + +**Recomendação: A** — porque o problema é um débito operacional de prod com +aceite verificável (200 por filename): um CLI commitado com o mesmo padrão de +guard dos seeds (all-or-nothing S3, `ALLOW_REMOTE_DB=true` explícito, echo do +alvo) é a forma reproduzível e segura; os modos `--dry-run`/`--verify` dão o +pre-flight e o aceite sem depender de credencial. + +**Rejeitadas:** B (não reproduzível, sem guard de alvo); C (acopla dois +contratos de guard diferentes no mesmo script; o seed é local-first por design). + +### Componentes / mudanças + +- **`scripts/lib/wpArticles.mjs`** (novo — extração do dono, sem twin): + move de `scripts/seed-posts.mjs` o que é fetch puro do WordPress + (`BASE_URL`, `USER_AGENT`, `stripHtml`, `fetchViaRestApi`, o typedef + `Article`) e adiciona `inlineCoverUrl(html)` (1ª `img[src]` — a peça que o + `processContent` do seed usa como fallback; idêntica semântica). O seed + importa daqui (comportamento inalterado; `fetchViaHtmlArchive` e + `processContent` permanecem no seed). Helpers puros adicionais: + `slugFromFilename(filename)` (remove a última extensão) e a resolução + `resolveCoverSource(article)` (`article.coverUrl || inlineCoverUrl(...)`). + Unit-testados (padrão dos módulos de `scripts/lib`). +- **`scripts/recover-media.mjs`** (novo): fluxo — + 1. `loadCliEnv` + `assertLocalDatabase('media:recover', hint do runbook)` + + `resolveS3StorageEnv(process.env)` (reuso de `src/utilities/mediaStorage.ts`; + aborta se `enabled: false` — recuperação é operação de prod, nunca storage + local) → **echo do alvo** (host do DB, endpoint, bucket) antes de qualquer + escrita. + 2. Leitura read-only: `payload.find` em `media` (todas as rows) e em `post` + com `coverImage` (depth 0) → mapa `mediaId → post.slug`; fallback + `slugFromFilename(row.filename)`. + 3. `fetchArticlesFromWordPress()` → mapa `slug → coverUrl` (featured → + inline 1º img, exatamente como o seed). + 4. Por row: resolve cover; `fetch` do cover (User-Agent do seed); + `PutObjectCommand` key **= `row.filename` verbatim**, `ContentType` = + content-type do download (os bytes reais mandam no decode — o nome do + arquivo é cosmético no contrato), sobrescreve (idempotente). + 5. Relatório por row (uploaded / sem-cover / erro) + resumo; **nunca** + `payload.create/update` — o script só lê o DB. +- **Modos:** default = reconcile (plano + upload); `--dry-run` = plano + + `headObject` por key (presente/ausente), zero escrita; `--verify` = `HEAD` + de cada filename contra a URL pública (`NEXT_PUBLIC_SITE_URL` ou + `https://jorgesolla1313.com.br`), o aceite da Issue, sem credencial de bucket. +- **Timeouts fail-fast:** `@aws-sdk/client-s3` + `@smithy/node-http-handler` + (devDependencies, versões já no lockfile) com `connectionTimeout: 5s` / + `requestTimeout: 15s` — endpoint inalcançável falha em segundos, não pendura + (mitiga o sintoma da E3 no runbook). +- **`package.json`:** `media:recover` (mesma fiação `--import=tsx/esm +--import=./scripts/seed-loader.mjs` dos seeds — os hooks de `post` chamam + `revalidateTag`). +- **Migration:** nenhuma. **Access/Consent:** intocados (`media.read` público; + a tool não autentica nada). +- **Runbook + docs:** seção no impl plan (ordem do homeserver) + nota curta no + AGENTS.md (seeds/media §) apontando o runbook; entrada + `docs/changelog/2026-08-18-ops52-media.md` + `pnpm changelog:build`. +- **UI:** N/A (Impeccable A — a seção S1 renderiza certo; problema é a media). + +## Revisão pós-simplify (2026-08-18 — 3 revisores, fixes absorvidos) + +- Download de capa com `AbortSignal.timeout(30s)`; fetch do REST com signal + opcional (60s no recover; o seed passa nenhum — semântica do fallback + intacta). +- Covers constrangidas a http(s) no host do WP (`resolveCoverDownloadUrl`; + relativas resolvem contra a origem) e só `image/*` é armazenado — sem SSRF + do homeserver, sem objeto não-imagem servido same-origin pelo proxy. +- `--verify` não resolve S3\_\* (config parcial não mata o aceite); origin do + verify ecoado no resumo de alvo. +- `--dry-run` sai com exit 1 quando QUALQUER checagem do bucket falha + (pre-flight honesto — all-erro ≠ 0). +- `resolveMediaCoverSources` extraído puro para a lib e unit-testado + (mapeamento row→post→cover, fallback e classificação de fonte); `fmt` e o + twin do `processContent` corrigidos. +- DevDeps com range `^` (convenção do repo); script `media:recover` fora do + bloco `db:seed:`; typedef `Article` via `@import` no seed. + +## Fases verificáveis + +1. **Lib + refactor do seed** — `scripts/lib/wpArticles.mjs` + seed importando + (fetch idêntico); unit tests de `slugFromFilename` / `inlineCoverUrl` / + `resolveCoverSource`; `pnpm gate:fast` + `pnpm format:check` + `knip` + + `check:cycles`; conferir `git diff` do seed = só import. +2. **Script de recuperação** — `recover-media.mjs` + npm script + devDeps; + `--dry-run` contra DB local (rows vazias — valida guards e caminho read-only); + unit dos helpers puros; gates da fase 1 repetidos. +3. **Docs + changelog** — runbook no impl plan, nota no AGENTS.md, + `docs/changelog/2026-08-18-ops52-media.md`, `pnpm changelog:build` + + `pnpm changelog:check`. +4. **Gates finais** — `pnpm gate:fast`, `pnpm format:check`, `pnpm exec knip`, + `pnpm check:cycles`, `pnpm test` (unit+int), `pnpm build` local. +5. **Execução em prod (pós-merge, runbook — humano):** + +## Runbook de execução em produção (pós-merge, humano) + +Ordem obrigatória — o pre-flight separa "arquivo faltando" (esta Issue) de +"container prod sem rota ao Garage" (infra, sucessor OPS): + +1. **Deploy do merge em `main`** (CI OPS53) — o script e a lib chegam ao + homeserver com a imagem do SHA. +2. **Pre-flight no homeserver** (rede do compose, env do stack + `~/stack/teqo-1313.env` — `DATABASE_URL` resolve o serviço `postgres` e as + `S3_*` já estão lá): + ```bash + cd ~/stack + set -a; source teqo-1313.env; set +a + ALLOW_REMOTE_DB=true pnpm media:recover --dry-run + ``` + Esperado: `headObject` de cada filename responde `presente`/`ausente` em + segundos. Se TODAS as rows derem `erro` (timeout/ECONNREFUSED), o container + não alcança o Garage — diagnosticar antes de recuperar: + ```bash + docker compose exec teqo-1313 node -e "fetch('http://100.119.220.31:3900/').catch(e=>{console.error('SEM ROTA ao Garage:', e.message); process.exit(1)}).then(()=>console.log('Garage alcançável'))" + ``` + (o `.env.local` do worktree NUNCA recebe as credenciais de prod — rodar + sempre no homeserver). +3. **Recuperar:** + ```bash + ALLOW_REMOTE_DB=true pnpm media:recover + ``` + Esperado: 40 uploads (`PutObject` key = filename; idempotente por overwrite), + exceções reais (sem cover / download 404) listadas no relatório — conferir + e registrar na Issue; exit 1 se houver falhas. +4. **Aceite:** + ```bash + ALLOW_REMOTE_DB=true pnpm media:recover --verify + ``` + Esperado: `40/40 filenames respondendo 200 em https://jorgesolla1313.com.br` + — é o aceite da Issue. Se persistir pendurando (erro/timeout) com objetos no + bucket → confirmar container→Garage (passo 2) e abrir sucessor OPS de infra. +5. **Check visual:** home de campanha (seção "Acompanhe de perto") e 1–2 + páginas de artigo com capa; console do navegador sem erro de imagem. +6. **Exceções:** o 1 artigo sem cover no WP (43→42 resolvem) e a 40ª row órfã + se não resolver → reportar na Issue #10; o card degrada para a banda cinza + existente (sem placeholder, sem placeholder "em breve"). + +## Rabbit holes / Não escopo (engenharia) + +- **Testar o script ponta a ponta desta máquina** — impossível por contrato: + sem S3\_\* (fail-closed) e sem DB prod; a prova E2E é o runbook do homeserver + (padrão OPS52 fase 3: prova de contrato manual, humana). +- **`--verify` exigir credencial** — não: URL pública é o aceite; sem bucket. +- **Consertar a rota/connectivity container→Garage** — se o pre-flight/verify + confirmar E3 como infra, é sucessor OPS (documentar diagnóstico no runbook), + não muda nada desta entrega. +- **Deletar a store do Vercel Blob / bucket público / CDN** — já fora de escopo + do OPS52/intenção. +- **Reescrever URLs das rows / recriar rows** — proibido pelo aceite; a tool + grava só objetos. + +## Riscos e mitigação + +- **R1 — Rota pública segue pendurando após recuperar objetos (E3 real).** + Mitigação: pre-flight no runbook (dry-run com timeout fail-fast já sinaliza + endpoint inalcançável; `--verify` pós-upload é o aceite). Se confirmar + infra container→Garage: reportar na Issue e abrir sucessor OPS — o aceite + desta Issue depende da rota 200, e o objeto restaurado é condição necessária + mas o caminho de rede é do ambiente. +- **R2 — Capa do WP mudou desde o seed (featured nova / imagem removida).** + Q1 da intenção aceita a capa atual do WP como fonte; exceções reais + (fetch 404/erro) são registradas e o card degrada para a banda cinza já + existente — sem placeholder. +- **R3 — Órfã (40ª row) não resolve por post nem por slug.** + Exceção no relatório; humano decide (registrar na Issue). Não inventar fonte. +- **R4 — Guard de alvo insuficiente (rodar contra bucket errado).** + `resolveS3StorageEnv` fail-closed (todas ou nenhuma) + echo do alvo + + `ALLOW_REMOTE_DB=true` obrigatório para DB remoto (mesmo padrão dos seeds). +- **R5 — Extensão ≠ conteúdo (row `.jpg` com webp no WP).** + `ContentType` = o do download (bytes reais decodificam); extensão é cosmética + no contrato de URL. Mencionado no runbook. + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção ainda coberto: 200 público por filename das + ~40 rows, DB intocado, uploads novos intocados, fail-closed mantido +- [ ] Invariantes AGENTS/engineering-standards: identificadores em inglês; + guard local de DB reusado; zero access/Consent/transação tocados +- [ ] Testes de domínio: unit dos helpers puros de `wpArticles.mjs` e da + resolução row→cover; gates da checklist do AGENTS rodados +- [ ] Docs: runbook de execução em prod + nota AGENTS.md + changelog diff --git a/package.json b/package.json index 73504084..2e965454 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "db:seed:tse": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-tse-results.mjs", "db:seed:consent": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-consent.mjs", "db:seed:projecao": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/import-projecao.mjs", + "media:recover": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/recover-media.mjs", "db:seed:minimal": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-minimal.mjs", "db:check:push-chain": "node scripts/check-push-chain.mjs", "agent:claim": "node scripts/agent-claim.mjs", @@ -135,8 +136,10 @@ "zod": "^4.3.6" }, "devDependencies": { + "@aws-sdk/client-s3": "^3.1112.0", "@eslint/eslintrc": "^3.3.6", "@playwright/test": "1.58.2", + "@smithy/node-http-handler": "^4.11.2", "@tailwindcss/postcss": "^4.1.18", "@testing-library/react": "16.3.0", "@types/geojson": "^7946.0.16", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 666daf56..c897bc8d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -159,12 +159,18 @@ importers: specifier: ^4.3.6 version: 4.3.6 devDependencies: + '@aws-sdk/client-s3': + specifier: ^3.1112.0 + version: 3.1112.0 '@eslint/eslintrc': specifier: ^3.3.6 version: 3.3.6 '@playwright/test': specifier: 1.58.2 version: 1.58.2 + '@smithy/node-http-handler': + specifier: ^4.11.2 + version: 4.11.2 '@tailwindcss/postcss': specifier: ^4.1.18 version: 4.1.18 diff --git a/scripts/lib/wpArticles.mjs b/scripts/lib/wpArticles.mjs new file mode 100644 index 00000000..2b3ad48e --- /dev/null +++ b/scripts/lib/wpArticles.mjs @@ -0,0 +1,150 @@ +/** + * Live-fetch helpers for the WordPress origin jorgesolla.com.br. + * + * Extracted from scripts/seed-posts.mjs (OPS52-media) so the seed and the + * media-recovery tool share ONE implementation of "fetch the live articles" + * and "resolve the cover image" — no twin fetch logic. + * + * Cover resolution mirrors the seed exactly: the WordPress REST featured media + * (`_embedded`), falling back to the first inline of the article body + * (most covers on this site come from that fallback — only 7/43 posts carry + * featured media). + */ +import { JSDOM } from 'jsdom' + +export const WP_BASE_URL = 'https://jorgesolla.com.br' +export const WP_USER_AGENT = 'teqo-content-migration-bot/1.0 (+https://jorgesolla.com.br)' + +export const stripHtml = (html) => + new JSDOM(`${html || ''}`).window.document.body.textContent + .replace(/\s+/g, ' ') + .trim() + +/** Normalized article shape returned by the REST fetch. */ +/** @typedef {{ slug: string, title: string, date: string|null, html: string, coverUrl: string|null, coverAlt: string|null }} Article */ + +/** + * Fetches the live articles from the WordPress REST API (`_embed`, paginated). + * @param {AbortSignal} [signal] Optional — the seed passes none (its HTML-crawl + * fallback depends on the current failure semantics); the media-recovery + * tool passes a timeout so a stalled origin fails fast. + * @returns {Promise} + */ +export async function fetchArticlesFromWordPress(signal) { + /** @type {Article[]} */ + const articles = [] + let page = 1 + let totalPages = 1 + + do { + const url = `${WP_BASE_URL}/wp-json/wp/v2/posts?per_page=100&page=${page}&_embed` + const res = await fetch(url, { headers: { 'User-Agent': WP_USER_AGENT }, signal }) + if (!res.ok) throw new Error(`REST API returned ${res.status} for page ${page}`) + + if (page === 1) { + totalPages = Number(res.headers.get('x-wp-totalpages') || '1') || 1 + } + + const data = await res.json() + if (!Array.isArray(data) || data.length === 0) break + + for (const item of data) { + const embeddedMedia = item?._embedded?.['wp:featuredmedia']?.[0] + articles.push({ + slug: item.slug, + title: stripHtml(item?.title?.rendered) || item.slug, + date: item.date || null, + html: item?.content?.rendered || '', + coverUrl: embeddedMedia?.source_url || null, + coverAlt: embeddedMedia?.alt_text || null, + }) + } + + page += 1 + } while (page <= totalPages) + + return articles +} + +/** + * First inline of an article's body HTML (absolute URL kept as-is). + * @param {string} html + * @returns {string|null} + */ +export function inlineCoverUrl(html) { + const doc = new JSDOM(`
${html || ''}
`).window + .document + return doc.getElementById('root').querySelector('img')?.getAttribute('src') || null +} + +/** + * Cover resolution shared with the seed: REST featured media, else the first + * inline image of the body. + * @param {Article} article + * @returns {string|null} + */ +export function resolveCoverSource(article) { + return article.coverUrl || inlineCoverUrl(article.html) +} + +/** + * Media filenames follow the seed's deterministic key `.`; the + * slug is the filename without its last extension. + * @param {string} filename + * @returns {string} + */ +export function slugFromFilename(filename) { + return filename.replace(/\.[a-z0-9]+$/i, '') +} + +/** + * Resolves a cover URL for downloading: relative sources become absolute + * against the WordPress origin, and only http(s) URLs on that host pass — + * the recovery tool runs with network reach, so covers must stay on the + * trusted origin (all resolved covers today are jorgesolla.com.br). + * @param {string} coverUrl + * @returns {string} + */ +export function resolveCoverDownloadUrl(coverUrl) { + let url + try { + url = new URL(coverUrl, WP_BASE_URL) + } catch { + throw new Error(`cover URL inválida: ${coverUrl}`) + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new Error(`cover URL com protocolo inesperado: ${url.protocol}`) + } + if (url.host !== new URL(WP_BASE_URL).host) { + throw new Error(`cover URL fora do origin do WordPress: ${url.host}`) + } + return url.href +} + +/** + * Pure row→cover mapping used by the media-recovery tool: for each media row, + * the post that references it as coverImage gives the exact slug; rows without + * a post fall back to the slug derived from the deterministic filename. + * @param {{ id: number|string, filename: string|null }[]} mediaRows + * @param {{ coverImage: number|string|null, slug: string }[]} posts + * @param {Map} articlesBySlug + * @returns {{ row: object, slug: string|null, coverUrl: string|null, source: 'post'|'filename'|null }[]} + */ +export function resolveMediaCoverSources(mediaRows, posts, articlesBySlug) { + const slugByMediaId = new Map() + for (const post of posts) { + if (post.coverImage != null) slugByMediaId.set(post.coverImage, post.slug) + } + + return mediaRows.map((row) => { + const slugFromPost = row.id != null ? slugByMediaId.get(row.id) : undefined + const slug = slugFromPost || slugFromFilename(row.filename || '') + const article = slug ? articlesBySlug.get(slug) : undefined + return { + row, + slug: slug || null, + coverUrl: article ? resolveCoverSource(article) : null, + source: slugFromPost ? 'post' : article ? 'filename' : null, + } + }) +} diff --git a/scripts/recover-media.mjs b/scripts/recover-media.mjs new file mode 100644 index 00000000..599557d5 --- /dev/null +++ b/scripts/recover-media.mjs @@ -0,0 +1,286 @@ +/** + * Recovers the production `media` objects (article covers) into the S3 bucket + * configured by the S3_* envs (OPS52: Garage bucket `teqo-media`). + * + * Background (Issue #10 / OPS52-media): the 40 pre-existing `media` rows hold + * relative URLs (`/api/media/file/`) and the files exist nowhere — + * the rows are correct, the objects are missing. This script restores the + * objects from the WordPress origin (jorgesolla.com.br — the same source the + * post seed uses) WITHOUT touching the database: rows keep their URLs and the + * Payload staticHandler starts streaming the objects through + * `/api/media/file/`. + * + * Safety model (fail-closed, mirrors scripts/seed-posts.mjs): + * - Reads the DB (media rows + posts.coverImage), writes ONLY objects in the + * bucket — no payload.create/update anywhere. + * - Requires the COMPLETE S3_* env set for reconcile/dry-run + * (resolveS3StorageEnv throws on a partial config) — recovery is a + * production-bucket operation, never local storage; --verify skips storage + * entirely (it only HEADs public URLs). + * - Refuses a non-local DATABASE_URL unless ALLOW_REMOTE_DB=true is set + * explicitly, and echoes the target (DB host, endpoint, bucket, verify + * origin) before any write. + * - Bucket calls fail fast: short connection/request timeouts, so an + * unreachable endpoint errors in seconds instead of hanging. + * - Covers are constrained to http(s) on the WordPress origin and only + * image/* responses are stored (no SSRF on the homeserver, no non-image + * object served same-origin through the proxy). + * + * Modes: + * pnpm media:recover reconcile — plan + upload every + * recoverable cover (PutObject overwrites, + * idempotent by filename) + * pnpm media:recover --dry-run plan only — resolve covers + headObject + * each key (present/missing), zero writes + * pnpm media:recover --verify acceptance check — HEAD every filename + * against the public URL (no bucket creds + * needed) + * + * Runbook (production): run on the homeserver inside the compose network with + * the stack env (see docs/plans/ops52-media-recuperar-arquivos-impl.md). + */ +import { HeadObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3' +import { NodeHttpHandler } from '@smithy/node-http-handler' +import { getPayload } from 'payload' + +import { assertLocalDatabase } from './assert-local-database.mjs' +import { dieWithLabel, loadCliEnv } from './lib/cli.mjs' +import { + fetchArticlesFromWordPress, + resolveCoverDownloadUrl, + resolveMediaCoverSources, + WP_USER_AGENT, +} from './lib/wpArticles.mjs' + +import { resolveS3StorageEnv } from '../src/utilities/mediaStorage.ts' + +loadCliEnv() + +const die = dieWithLabel('media:recover') + +const PROD_PUBLIC_ORIGIN = process.env.NEXT_PUBLIC_SITE_URL || 'https://jorgesolla1313.com.br' + +const args = new Set(process.argv.slice(2)) +const unknown = [...args].filter((a) => a !== '--dry-run' && a !== '--verify') +if (unknown.length > 0) die(`argumento desconhecido: ${unknown.join(', ')}`) +if (args.size > 1) + die('modos são mutuamente exclusivos: use apenas um de --dry-run / --verify (ou nenhum).') +const mode = args.has('--dry-run') ? 'dry-run' : args.has('--verify') ? 'verify' : 'reconcile' + +assertLocalDatabase( + 'media:recover', + `Este script LÊ o banco de produção (rows de media) e ESCREVE apenas objetos no bucket S3 das envs S3_*.\n` + + ` Runbook: rodar no homeserver, dentro da rede do compose, com o env do stack (ver\n` + + ` docs/plans/ops52-media-recuperar-arquivos-impl.md).\n` + + ` Se você REALMENTE quer apontar para o banco remoto de propósito, re-rodar com: ALLOW_REMOTE_DB=true …`, +) + +const storage = mode === 'verify' ? { enabled: false } : resolveS3StorageEnv(process.env) +if (mode !== 'verify' && !storage.enabled) { + die( + 'nenhuma env S3_* configurada — a recuperação é operação do bucket de produção, nunca do storage local.\n' + + ' Configure as 4 S3_* (bucket, endpoint, access key, secret) ou use --verify (só URLs públicas).', + ) +} + +const targetSummary = [ + `Alvo da execução:`, + ` DB : ${new URL(process.env.DATABASE_URL).host}`, + ...(storage.enabled ? [` Bucket : ${storage.bucket}`, ` Endpoint: ${storage.endpoint}`] : []), + ...(mode === 'verify' ? [` Origin : ${PROD_PUBLIC_ORIGIN}`] : []), + ` Modo : ${mode}`, +].join('\n') +console.log(`\n[media:recover] ${targetSummary}`) + +// --------------------------------------------------------------------------- +// S3 client — fail fast on unreachable endpoints (E3: a hanging headObject +// looks like "Garage morto" when the real cause is routing). Only used by +// reconcile/dry-run — verify needs no bucket credentials. +// --------------------------------------------------------------------------- + +const s3 = storage.enabled + ? new S3Client({ + region: storage.region, + endpoint: storage.endpoint, + forcePathStyle: true, + credentials: { + accessKeyId: storage.accessKeyId, + secretAccessKey: storage.secretAccessKey, + }, + requestHandler: new NodeHttpHandler({ connectionTimeout: 5_000, requestTimeout: 15_000 }), + }) + : null + +// --------------------------------------------------------------------------- +// Reads (read-only — the script never writes the database) +// --------------------------------------------------------------------------- + +async function findAll(payload, collection, where) { + const docs = [] + let page = 1 + while (true) { + const res = await payload.find({ collection, where, page, limit: 100, depth: 0 }) + docs.push(...res.docs) + if (page >= res.totalPages) return docs + page += 1 + } +} + +/** + * Resolves each media row to its cover source (pure mapping in + * scripts/lib/wpArticles.mjs — post relation first, filename fallback). + * @returns {Promise>} + */ +async function resolveRows(payload) { + const mediaRows = await findAll(payload, 'media', {}) + const posts = await findAll(payload, 'post', { coverImage: { exists: true } }) + + const articlesBySlug = new Map() + console.log('[media:recover] Fetching articles from jorgesolla.com.br (WP REST API)...') + for (const article of await fetchArticlesFromWordPress(AbortSignal.timeout(60_000))) { + articlesBySlug.set(article.slug, article) + } + + const resolved = new Map() + for (const entry of resolveMediaCoverSources(mediaRows, posts, articlesBySlug)) { + resolved.set(entry.row.id, entry) + } + return resolved +} + +const fmt = (resolved) => + `id=${resolved.row.id} ${resolved.row.filename} (slug=${resolved.slug || 'não resolvido'}, ` + + `cover=${resolved.coverUrl ? 'ok' : 'sem cover'}, fonte=${resolved.source || '—'})` + +// --------------------------------------------------------------------------- +// Bucket helpers +// --------------------------------------------------------------------------- + +async function keyExists(filename) { + try { + await s3.send(new HeadObjectCommand({ Bucket: storage.bucket, Key: filename })) + return true + } catch (err) { + if (err?.name === 'NotFound' || err?.name === 'NoSuchKey') return false + throw err + } +} + +// --------------------------------------------------------------------------- +// Modes +// --------------------------------------------------------------------------- + +async function runDryRun(payload) { + const resolved = await resolveRows(payload) + let present = 0 + let missing = 0 + let errored = 0 + for (const r of resolved.values()) { + const exists = await keyExists(r.row.filename).catch(() => null) + if (exists === true) present += 1 + else if (exists === false) missing += 1 + else errored += 1 + console.log( + `[media:recover] ${exists === true ? 'presente ' : exists === false ? 'ausente ' : 'erro '} ${fmt(r)}`, + ) + } + console.log( + `[media:recover] dry-run: ${resolved.size} row(s), ${present} objeto(s) presente(s), ${missing} ausente(s), ${errored} erro(s).`, + ) + if (errored > 0) + die( + `${errored} checagem(s) do bucket falharam (endpoint/credenciais?) — o pre-flight não validou nada; corrigir antes de recuperar.`, + ) +} + +async function runReconcile(payload) { + const resolved = await resolveRows(payload) + let uploaded = 0 + let failed = 0 + for (const r of resolved.values()) { + if (!r.coverUrl) { + console.log(`[media:recover] sem-cover ${fmt(r)}`) + failed += 1 + continue + } + try { + const res = await fetch(resolveCoverDownloadUrl(r.coverUrl), { + headers: { 'User-Agent': WP_USER_AGENT }, + signal: AbortSignal.timeout(30_000), + }) + if (!res.ok) throw new Error(`download ${res.status}`) + const contentType = + (res.headers.get('content-type') || 'image/jpeg').split(';')[0].trim() || 'image/jpeg' + if (!contentType.startsWith('image/')) { + throw new Error(`resposta não é imagem (${contentType}) — cobertura recusada`) + } + const body = Buffer.from(await res.arrayBuffer()) + await s3.send( + new PutObjectCommand({ + Bucket: storage.bucket, + Key: r.row.filename, + Body: body, + ContentType: contentType, + }), + ) + uploaded += 1 + console.log(`[media:recover] upload ${fmt(r)} (${body.length} bytes, ${contentType})`) + } catch (err) { + failed += 1 + console.log(`[media:recover] erro ${fmt(r)} — ${err.message}`) + } + } + console.log( + `[media:recover] reconcile: ${uploaded} upload(s), ${failed} falha(s) de ${resolved.size} row(s).`, + ) + if (failed > 0) + die(`houve ${failed} falha(s) — conferir o relatório acima e a Issue #10 para as exceções.`) +} + +async function runVerify(payload) { + const mediaRows = await findAll(payload, 'media', {}) + let ok = 0 + const failures = [] + for (const row of mediaRows) { + const url = `${PROD_PUBLIC_ORIGIN}/api/media/file/${encodeURIComponent(row.filename)}` + try { + const res = await fetch(url, { method: 'HEAD', signal: AbortSignal.timeout(30_000) }) + if (res.ok) { + ok += 1 + console.log(`[media:recover] 200 ${row.filename}`) + } else { + failures.push({ filename: row.filename, status: res.status }) + console.log(`[media:recover] ${res.status} ${row.filename}`) + } + } catch (err) { + failures.push({ filename: row.filename, status: 'erro' }) + console.log(`[media:recover] erro ${row.filename} — ${err.message}`) + } + } + console.log( + `[media:recover] verify: ${ok}/${mediaRows.length} filenames respondendo 200 em ${PROD_PUBLIC_ORIGIN}.`, + ) + if (failures.length > 0) { + console.log( + `[media:recover] Falhas: ${failures.map((f) => `${f.filename} (${f.status})`).join(', ')}`, + ) + die(`${failures.length} filename(s) sem 200 — aceite da Issue #10 não satisfeito.`) + } +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +async function main() { + const config = (await import('../src/payload.config.ts')).default + const payload = await getPayload({ config }) + if (mode === 'dry-run') await runDryRun(payload) + else if (mode === 'verify') await runVerify(payload) + else await runReconcile(payload) + process.exit(0) +} + +main().catch((err) => { + die(err?.message || String(err)) +}) diff --git a/scripts/seed-posts.mjs b/scripts/seed-posts.mjs index f0f088b6..d4f51eee 100644 --- a/scripts/seed-posts.mjs +++ b/scripts/seed-posts.mjs @@ -30,6 +30,16 @@ import { dieWithLabel, loadCliEnv } from './lib/cli.mjs' import { assertLocalDatabase } from './assert-local-database.mjs' +import { + WP_BASE_URL as BASE_URL, + WP_USER_AGENT as USER_AGENT, + fetchArticlesFromWordPress, + resolveCoverSource, + stripHtml, +} from './lib/wpArticles.mjs' + +/** @import { Article } from './lib/wpArticles.mjs' */ + // Mirror Next.js precedence (.env.local wins over .env) without clobbering a // DATABASE_URL already set in the real environment. loadCliEnv() @@ -162,56 +172,10 @@ const CLASSIFICATION = { } // --------------------------------------------------------------------------- -// Live fetch — WordPress REST API (primary) +// Live fetch — WordPress REST API (primary; implementation in +// scripts/lib/wpArticles.mjs — shared with the media-recovery tool) // --------------------------------------------------------------------------- -const BASE_URL = 'https://jorgesolla.com.br' -const USER_AGENT = 'teqo-content-migration-bot/1.0 (+https://jorgesolla.com.br)' - -const stripHtml = (html) => - new JSDOM(`${html || ''}`).window.document.body.textContent - .replace(/\s+/g, ' ') - .trim() - -/** Normalized article shape used by both the REST and HTML strategies. */ -/** @typedef {{ slug: string, title: string, date: string|null, html: string, coverUrl: string|null, coverAlt: string|null }} Article */ - -async function fetchViaRestApi() { - /** @type {Article[]} */ - const articles = [] - let page = 1 - let totalPages = 1 - - do { - const url = `${BASE_URL}/wp-json/wp/v2/posts?per_page=100&page=${page}&_embed` - const res = await fetch(url, { headers: { 'User-Agent': USER_AGENT } }) - if (!res.ok) throw new Error(`REST API returned ${res.status} for page ${page}`) - - if (page === 1) { - totalPages = Number(res.headers.get('x-wp-totalpages') || '1') || 1 - } - - const data = await res.json() - if (!Array.isArray(data) || data.length === 0) break - - for (const item of data) { - const embeddedMedia = item?._embedded?.['wp:featuredmedia']?.[0] - articles.push({ - slug: item.slug, - title: stripHtml(item?.title?.rendered) || item.slug, - date: item.date || null, - html: item?.content?.rendered || '', - coverUrl: embeddedMedia?.source_url || null, - coverAlt: embeddedMedia?.alt_text || null, - }) - } - - page += 1 - } while (page <= totalPages) - - return articles -} - // --------------------------------------------------------------------------- // Live fetch — HTML category-archive crawl (fallback) // Mirrors conteudo-atual-scraping/scrape_jorgesolla.py. @@ -306,9 +270,9 @@ function processContent(article) { if (el.textContent.replace(/\u00a0/g, ' ').trim() === '') el.remove() } - // Cover: prefer the REST featured media; else the first inline image. - const firstImg = root.querySelector('img') - const coverUrl = article.coverUrl || firstImg?.getAttribute('src') || null + // Cover: prefer the REST featured media; else the first inline image + // (shared resolution — scripts/lib/wpArticles.mjs). + const coverUrl = resolveCoverSource(article) // Subtitle (dek): this site places the dek as a first

immediately followed // by the cover-image figure. Only treat the first paragraph as the subtitle @@ -439,7 +403,7 @@ async function main() { console.log('[seed:posts] Fetching articles from jorgesolla.com.br (WP REST API)...') let articles = [] try { - articles = await fetchViaRestApi() + articles = await fetchArticlesFromWordPress() console.log(`[seed:posts] REST API returned ${articles.length} article(s).`) } catch (err) { console.warn(`[seed:posts] REST API failed (${err.message}). Falling back to HTML crawl...`) diff --git a/tests/unit/wpArticles.unit.spec.ts b/tests/unit/wpArticles.unit.spec.ts new file mode 100644 index 00000000..932e586a --- /dev/null +++ b/tests/unit/wpArticles.unit.spec.ts @@ -0,0 +1,175 @@ +// @vitest-environment node + +import { describe, expect, it } from 'vitest' + +import { + inlineCoverUrl, + resolveCoverDownloadUrl, + resolveCoverSource, + resolveMediaCoverSources, + slugFromFilename, + stripHtml, +} from '../../scripts/lib/wpArticles.mjs' + +describe('inlineCoverUrl (OPS52-media — first inline image of the article body)', () => { + it('returns the src of the first ', () => { + const html = '

dek

' + expect(inlineCoverUrl(html)).toBe('https://jorgesolla.com.br/cover.jpg') + }) + + it('skips earlier paragraphs without images', () => { + const html = + '

texto sem imagem

mais texto

x
' + expect(inlineCoverUrl(html)).toBe('/rel.jpg') + }) + + it('returns null when the body has no image', () => { + expect(inlineCoverUrl('

só texto

')).toBeNull() + expect(inlineCoverUrl('')).toBeNull() + }) + + it('keeps relative URLs as-is (same contract as the seed)', () => { + expect(inlineCoverUrl('')).toBe('/uploads/foo.webp') + }) +}) + +describe('resolveCoverSource (OPS52-media — seed-identical cover resolution)', () => { + it('prefers the REST featured media URL', () => { + const article = { + slug: 'x', + title: 'x', + date: null, + html: '

sem img

', + coverUrl: 'https://jorgesolla.com.br/featured.jpg', + coverAlt: null, + } + expect(resolveCoverSource(article)).toBe('https://jorgesolla.com.br/featured.jpg') + }) + + it('falls back to the first inline image when there is no featured media', () => { + const article = { + slug: 'x', + title: 'x', + date: null, + html: '
', + coverUrl: null, + coverAlt: null, + } + expect(resolveCoverSource(article)).toBe('https://jorgesolla.com.br/inline.png') + }) + + it('returns null when neither source exists', () => { + const article = { + slug: 'x', + title: 'x', + date: null, + html: '

sem img

', + coverUrl: null, + coverAlt: null, + } + expect(resolveCoverSource(article)).toBeNull() + }) +}) + +describe('slugFromFilename (OPS52-media — deterministic seed key .)', () => { + it('strips the last extension', () => { + expect(slugFromFilename('solla-e-jeronimo-inauguram-mercado-publico-de-ubata.jpg')).toBe( + 'solla-e-jeronimo-inauguram-mercado-publico-de-ubata', + ) + }) + + it('keeps dots inside the slug', () => { + expect(slugFromFilename('obra.2026.png')).toBe('obra.2026') + }) + + it('leaves filenames without an extension untouched', () => { + expect(slugFromFilename('sem-extensao')).toBe('sem-extensao') + }) + + it('is case-insensitive about the extension', () => { + expect(slugFromFilename('slug.JPG')).toBe('slug') + }) +}) + +describe('stripHtml (shared with the seed)', () => { + it('collapses whitespace and strips tags', () => { + expect(stripHtml('

Olá mundo

')).toBe('Olá mundo') + }) + + it('handles empty input', () => { + expect(stripHtml('')).toBe('') + }) +}) + +describe('resolveCoverDownloadUrl (OPS52-media — covers constrained to the WP origin)', () => { + it('keeps an absolute cover on the WordPress host', () => { + expect( + resolveCoverDownloadUrl('https://jorgesolla.com.br/wp-content/uploads/2026/07/x.jpeg'), + ).toBe('https://jorgesolla.com.br/wp-content/uploads/2026/07/x.jpeg') + }) + + it('resolves relative covers against the WordPress origin', () => { + expect(resolveCoverDownloadUrl('/wp-content/uploads/x.png')).toBe( + 'https://jorgesolla.com.br/wp-content/uploads/x.png', + ) + }) + + it('refuses covers on other hosts (no SSRF from the homeserver)', () => { + expect(() => resolveCoverDownloadUrl('http://100.119.220.31:3900/internal')).toThrow( + /fora do origin/, + ) + expect(() => resolveCoverDownloadUrl('https://evil.example.com/x.png')).toThrow( + /fora do origin/, + ) + }) + + it('refuses non-http(s) schemes', () => { + expect(() => resolveCoverDownloadUrl('file:///etc/passwd')).toThrow(/protocolo/) + }) +}) + +describe('resolveMediaCoverSources (OPS52-media — row→cover mapping)', () => { + const article = (slug: string) => ({ + slug, + title: slug, + date: null, + html: '
', + coverUrl: null, + coverAlt: null, + }) + const articlesBySlug = new Map([ + ['post-a', article('post-a')], + ['post-b', article('post-b')], + ]) + + it('maps via the post coverImage relation (exact source)', () => { + const rows = [{ id: 1, filename: 'post-a.jpg' }] + const posts = [{ coverImage: 1, slug: 'post-a' }] + const [entry] = resolveMediaCoverSources(rows, posts, articlesBySlug) + expect(entry.source).toBe('post') + expect(entry.slug).toBe('post-a') + expect(entry.coverUrl).toBe('https://jorgesolla.com.br/inline.png') + }) + + it('falls back to the filename-derived slug for rows without a post', () => { + const rows = [{ id: 2, filename: 'post-b.png' }] + const [entry] = resolveMediaCoverSources(rows, [], articlesBySlug) + expect(entry.source).toBe('filename') + expect(entry.slug).toBe('post-b') + expect(entry.coverUrl).toBe('https://jorgesolla.com.br/inline.png') + }) + + it('prefers the post relation over a matching filename slug', () => { + const rows = [{ id: 1, filename: 'post-b.jpg' }] + const posts = [{ coverImage: 1, slug: 'post-a' }] + const [entry] = resolveMediaCoverSources(rows, posts, articlesBySlug) + expect(entry.slug).toBe('post-a') + expect(entry.source).toBe('post') + }) + + it('leaves unresolvable rows with no cover and source null', () => { + const rows = [{ id: 3, filename: 'orphan.jpg' }] + const [entry] = resolveMediaCoverSources(rows, [], articlesBySlug) + expect(entry).toMatchObject({ slug: 'orphan', coverUrl: null, source: null }) + }) +})