- scripts/recover-media.mjs: CLI de reconciliação (reconcile/--dry-run/--verify) — lê rows de media + posts.coverImage, resolve a capa com a mesma lógica do seed (featured media, senão 1ª imagem inline), PutObject com key = filename; DB nunca escrito; fail-closed (S3_* completas, ALLOW_REMOTE_DB, echo do alvo, timeouts curtos, covers só do origin WP e image/*) - scripts/lib/wpArticles.mjs: fetch ao vivo do WordPress extraído do seed (sem twin), helpers puros de resolução + row→cover mapping (unit-testados) - seed-posts.mjs: importa o fetch da lib (comportamento idêntico) e usa resolveCoverSource em processContent - runbook de execução no homeserver (impl plan), nota no AGENTS.md, changelog Closes #10
14 KiB
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
_embedde jorgesolla.com.br tem 7/43 posts comwp:featuredmedia; a maioria das capas do seed vem do fallback primeira imagem inline do corpo (processContentemscripts/seed-posts.mjs— resolução compartilhada viaresolveCoverSource). 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 hostjorgesolla.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 usacover.urlrelativo comosrc(src/components/PostCard.tsx:26) e o staticHandler do@payloadcms/storage-s3serve a rota streamando o objeto (getObject com range/etag-304 nativos;NoSuchKey→ 404 rápido, vernode_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 ⇒ oheadObjectdo 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(DATABASEURL resolve o serviçopostgres; 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 viapost.coverImage(FOREIGN KEY confirmada no schema local); fallback filename-derivado para órfãs.
Abordagem recomendada
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(modoreconcile/--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.mjscom--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 descripts/seed-posts.mjso que é fetch puro do WordPress (BASE_URL,USER_AGENT,stripHtml,fetchViaRestApi, o typedefArticle) e adicionainlineCoverUrl(html)(1ªimg[src]— a peça que oprocessContentdo seed usa como fallback; idêntica semântica). O seed importa daqui (comportamento inalterado;fetchViaHtmlArchiveeprocessContentpermanecem no seed). Helpers puros adicionais:slugFromFilename(filename)(remove a última extensão) e a resoluçãoresolveCoverSource(article)(article.coverUrl || inlineCoverUrl(...)). Unit-testados (padrão dos módulos descripts/lib).scripts/recover-media.mjs(novo): fluxo —loadCliEnv+assertLocalDatabase('media:recover', hint do runbook)+resolveS3StorageEnv(process.env)(reuso desrc/utilities/mediaStorage.ts; aborta seenabled: false— recuperação é operação de prod, nunca storage local) → echo do alvo (host do DB, endpoint, bucket) antes de qualquer escrita.- Leitura read-only:
payload.findemmedia(todas as rows) e empostcomcoverImage(depth 0) → mapamediaId → post.slug; fallbackslugFromFilename(row.filename). fetchArticlesFromWordPress()→ mapaslug → coverUrl(featured → inline 1º img, exatamente como o seed).- Por row: resolve cover;
fetchdo cover (User-Agent do seed);PutObjectCommandkey =row.filenameverbatim,ContentType= content-type do download (os bytes reais mandam no decode — o nome do arquivo é cosmético no contrato), sobrescreve (idempotente). - 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 +headObjectpor key (presente/ausente), zero escrita;--verify=HEADde cada filename contra a URL pública (NEXT_PUBLIC_SITE_URLouhttps://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) comconnectionTimeout: 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.mjsdos seeds — os hooks depostchamamrevalidateTag).- Migration: nenhuma. Access/Consent: intocados (
media.readpú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. --verifynão resolve S3_* (config parcial não mata o aceite); origin do verify ecoado no resumo de alvo.--dry-runsai com exit 1 quando QUALQUER checagem do bucket falha (pre-flight honesto — all-erro ≠ 0).resolveMediaCoverSourcesextraído puro para a lib e unit-testado (mapeamento row→post→cover, fallback e classificação de fonte);fmte o twin doprocessContentcorrigidos.- DevDeps com range
^(convenção do repo); scriptmedia:recoverfora do blocodb:seed:; typedefArticlevia@importno seed.
Fases verificáveis
- Lib + refactor do seed —
scripts/lib/wpArticles.mjs+ seed importando (fetch idêntico); unit tests deslugFromFilename/inlineCoverUrl/resolveCoverSource;pnpm gate:fast+pnpm format:check+knip+check:cycles; conferirgit diffdo seed = só import. - Script de recuperação —
recover-media.mjs+ npm script + devDeps;--dry-runcontra DB local (rows vazias — valida guards e caminho read-only); unit dos helpers puros; gates da fase 1 repetidos. - Docs + changelog — runbook no impl plan, nota no AGENTS.md,
docs/changelog/2026-08-18-ops52-media.md,pnpm changelog:build+pnpm changelog:check. - Gates finais —
pnpm gate:fast,pnpm format:check,pnpm exec knip,pnpm check:cycles,pnpm test(unit+int),pnpm buildlocal. - 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):
- Deploy do merge em
main(CI OPS53) — o script e a lib chegam ao homeserver com a imagem do SHA. - Pre-flight no homeserver (rede do compose, env do stack
~/stack/teqo-1313.env—DATABASE_URLresolve o serviçopostgrese asS3_*já estão lá):
Esperado:cd ~/stack set -a; source teqo-1313.env; set +a ALLOW_REMOTE_DB=true pnpm media:recover --dry-runheadObjectde cada filename respondepresente/ausenteem segundos. Se TODAS as rows deremerro(timeout/ECONNREFUSED), o container não alcança o Garage — diagnosticar antes de recuperar:
(odocker 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'))".env.localdo worktree NUNCA recebe as credenciais de prod — rodar sempre no homeserver). - Recuperar:
Esperado: 40 uploads (ALLOW_REMOTE_DB=true pnpm media:recoverPutObjectkey = 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. - Aceite:
Esperado:ALLOW_REMOTE_DB=true pnpm media:recover --verify40/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. - 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.
- 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).
--verifyexigir 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;
--verifypó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).
resolveS3StorageEnvfail-closed (todas ou nenhuma) + echo do alvo +ALLOW_REMOTE_DB=trueobrigatório para DB remoto (mesmo padrão dos seeds). - R5 — Extensão ≠ conteúdo (row
.jpgcom 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.mjse da resolução row→cover; gates da checklist do AGENTS rodados - Docs: runbook de execução em prod + nota AGENTS.md + changelog