feat(OPS52-media): recupera os objetos de media de produção (pnpm media:recover) #38
10 changed files with 895 additions and 53 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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/<filename>` (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/<filename>` 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 `<slug>.<ext>`), 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/<filename>` 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 `<slug>.<ext>`), 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 -- <sha>"`); 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-<sha>`), **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.
|
||||
|
|
|
|||
1
docs/changelog/2026-08-18-ops52-media.md
Normal file
1
docs/changelog/2026-08-18-ops52-media.md
Normal file
|
|
@ -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/<filename>` 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 `<slug>.<ext>`), 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`.
|
||||
253
docs/plans/ops52-media-recuperar-arquivos-impl.md
Normal file
253
docs/plans/ops52-media-recuperar-arquivos-impl.md
Normal file
|
|
@ -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/<filename>` → **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/<filename>`;
|
||||
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/<filename>`
|
||||
→ 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<br/>scripts/lib/wpArticles.mjs] -->|slug -> coverUrl<br/>featured | inline 1o img| B
|
||||
B -->|PutObject key = filename<br/>SDK S3 SigV4, bucket teqo-media| G[Garage prod]
|
||||
B -.->|--dry-run headObject<br/>--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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
150
scripts/lib/wpArticles.mjs
Normal file
150
scripts/lib/wpArticles.mjs
Normal file
|
|
@ -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 <img> 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(`<!DOCTYPE html><body>${html || ''}</body>`).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<Article[]>}
|
||||
*/
|
||||
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 <img src> 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(`<!DOCTYPE html><body><div id="root">${html || ''}</div></body>`).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 `<slug>.<ext>`; 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<string, Article>} 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,
|
||||
}
|
||||
})
|
||||
}
|
||||
286
scripts/recover-media.mjs
Normal file
286
scripts/recover-media.mjs
Normal file
|
|
@ -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/<filename>`) 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/<filename>`.
|
||||
*
|
||||
* 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<Map<number|string, { row: object, slug: string|null, coverUrl: string|null, source: 'post'|'filename'|null }>>}
|
||||
*/
|
||||
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))
|
||||
})
|
||||
|
|
@ -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(`<!DOCTYPE html><body>${html || ''}</body>`).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 <p> 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...`)
|
||||
|
|
|
|||
175
tests/unit/wpArticles.unit.spec.ts
Normal file
175
tests/unit/wpArticles.unit.spec.ts
Normal file
|
|
@ -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 <img>', () => {
|
||||
const html = '<p>dek</p><figure><img src="https://jorgesolla.com.br/cover.jpg"></figure>'
|
||||
expect(inlineCoverUrl(html)).toBe('https://jorgesolla.com.br/cover.jpg')
|
||||
})
|
||||
|
||||
it('skips earlier paragraphs without images', () => {
|
||||
const html =
|
||||
'<p>texto sem imagem</p><p>mais texto</p><figure><img src="/rel.jpg" alt="x"></figure>'
|
||||
expect(inlineCoverUrl(html)).toBe('/rel.jpg')
|
||||
})
|
||||
|
||||
it('returns null when the body has no image', () => {
|
||||
expect(inlineCoverUrl('<p>só texto</p>')).toBeNull()
|
||||
expect(inlineCoverUrl('')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps relative URLs as-is (same contract as the seed)', () => {
|
||||
expect(inlineCoverUrl('<img src="/uploads/foo.webp">')).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: '<p>sem img</p>',
|
||||
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: '<figure><img src="https://jorgesolla.com.br/inline.png"></figure>',
|
||||
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: '<p>sem img</p>',
|
||||
coverUrl: null,
|
||||
coverAlt: null,
|
||||
}
|
||||
expect(resolveCoverSource(article)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('slugFromFilename (OPS52-media — deterministic seed key <slug>.<ext>)', () => {
|
||||
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('<p>Olá <strong>mundo</strong></p>')).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: '<figure><img src="https://jorgesolla.com.br/inline.png"></figure>',
|
||||
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 })
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue