mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
feat(archive): varredura e reparo de integridade do acervo no Garage (C246)
Adiciona a CLI pnpm archive:integrity: varredura read-only de todas as archivePhoto (download + decode pelo mesmo caminho da rota pública e do índice facial; sai 1 enquanto houver mídia quebrada approved) e --apply que re-ingere do Flickr pelo flickrId, substitui o objeto na mesma chave via payload.update filePath (sem tocar curadoria/status) e rebaixa a draft as irrecuperáveis (removed intocado). Dono da leitura ganha inspectPrivateMediaObject e isPrivateMediaMissingError (S3 NoSuchKey/404 + ENOENT local; checksum nunca é missing); archivePhotoIngest ganha repairArchivePhotoObject e withdrawArchivePhotoFromPublic; decisão do reparo em scripts/lib/archiveIntegrityRepair.mjs com deps injetadas e puro de CLI/recibo em scripts/lib/archiveIntegrityPlan.mjs. Sem migration, sem UI. Guardas: ARCHIVE_INTEGRITY_CONFIRM + TEQO_ENV exato + 4 S3_* (em qualquer modo no alvo não-local) + FLICKR_API_KEY. Runbook C246, changelog e unit/int/e2e afetado.
This commit is contained in:
parent
ecd9fe478d
commit
dcf8e85a9d
19 changed files with 2326 additions and 22 deletions
1
docs/changelog/2026-10-02-c246.md
Normal file
1
docs/changelog/2026-10-02-c246.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**C246 — Integridade do acervo no Garage: varredura e recuperação das fotos quebradas (2026-10-02):** o acervo do C231/C242 ganha o comando `pnpm archive:integrity` — varredura read-only de TODAS as `archivePhoto` que baixa cada objeto pelo MESMO caminho da rota pública e do índice facial (o SDK S3 valida o checksum na resposta) e força o decode com sharp: objeto ausente é `missing`, falha de leitura (checksum) ou arquivo que não decodifica é `corrupt` com stage/motivo, e a varredura sai **1** enquanto houver mídia quebrada não-removida (critério de convergência) gravando recibo JSON em `data/archive/reports/`. Com `--apply`, cada foto acusada é recuperada do Flickr pelo `flickrId` (mesmo fallback `getSizes` da ingestão), o original baixado é decodificado como prova, o objeto é substituído **na mesma chave** pelo caminho de escrita da collection (`repairArchivePhotoObject` → `payload.update` + `filePath`, sem tocar curadoria/metadados/`publicationStatus`; a foto `approved` volta a servir sozinha) e o resultado é verificado por uma segunda passada; fonte sumida ou que não decodifica **sai do público** (`approved → draft` via `withdrawArchivePhotoFromPublic`, com purge dos descritores faciais e revalidação pelos hooks; `removed` nunca é tocado — takedown continua takedown). O dono da leitura privada ganha `inspectPrivateMediaObject` e o classificador puro `isPrivateMediaMissingError` (`src/lib/privateMedia.ts`, S3 NoSuchKey/404 + ENOENT local; checksum nunca é `missing`); a decisão do reparo é um módulo com deps injetadas (`scripts/lib/archiveIntegrityRepair.mjs`) e o puro de CLI/recibo em `scripts/lib/archiveIntegrityPlan.mjs` (`--only`/`--limit`/`--concurrency 3`/`--out`, map com concorrência limitada, recibos honestos de varredura e reparo). Guardas: `--apply` exige `ARCHIVE_INTEGRITY_CONFIRM=1` + `TEQO_ENV` casando o nome exato do banco + as 4 `S3_*` + `FLICKR_API_KEY`; alvo não-local exige S3 em QUALQUER modo (sem S3 a varredura mediria o storage errado); `ALLOW_REMOTE_DB` recusado. Sem migration e sem UI (Impeccable A); runbook com canário 80/83, varredura completa, reparo, convergência, cache-bust da tag `archivePhotos` e lembrete do `pnpm faces:index` em `docs/ops/teqo-1313-deploy.md` (C246). Testes: unit do parser/recibo/map com concorrência, das guardas da CLI por spawn, do classificador e das nove decisões do reparo com fakes; int sobre `teqo_test` do probe ok/missing/corrupt, do reparo preservando filename/curadoria/status/filesize na mesma chave, do downgrade com purge de descritores sem canal e do `removed` intocado; e2e afetado `frontendFotos`/`frontendFotosSelfie` verde. Impl plan: `docs/plans/acervo-integridade-garage-impl.md`.
|
||||
|
|
@ -1667,6 +1667,79 @@ aditiva; o rollback de código pode deixar a tabela (inofensiva). Desligar o
|
|||
comando (`FACE_FIGURE_SEED_CONFIRM`/`FACE_FIGURE_CONFIRM`). As imagens de
|
||||
referência são locais (nunca entram no repo nem no S3); recibos JSON em
|
||||
`data/face/reports/` sem vetor nem bytes.
|
||||
## C246 — integridade do acervo no Garage (fotos quebradas)
|
||||
|
||||
O comando `pnpm archive:integrity` varre os originais do acervo (`archivePhoto`):
|
||||
baixa **cada** objeto pelo mesmo caminho da rota pública e do índice facial (o SDK
|
||||
S3 valida o checksum na resposta) e força o decode com sharp. Objeto ausente é
|
||||
`ausente`; falha de leitura (checksum) ou arquivo que não decodifica é
|
||||
`corrompida` — o recibo nomeia cada uma com o motivo. A varredura é **read-only**,
|
||||
sai **1** enquanto houver mídia quebrada **pública** (linha `approved` quebrada —
|
||||
o critério de convergência; uma `draft` quebrada é registrada no recibo mas não
|
||||
bloqueia, senão o rebaixamento do `--apply` nunca convergiria) e sempre grava o
|
||||
recibo em `data/archive/reports/`.
|
||||
|
||||
Com `--apply`, cada foto quebrada é recuperada do Flickr pelo `flickrId` (mesmo
|
||||
fallback `getSizes` da ingestão C231): o original baixado é decodificado para
|
||||
provar que serve, o objeto é substituído **na mesma chave** pelo caminho de
|
||||
escrita da própria collection (`payload.update` + `filePath`) — curadoria,
|
||||
metadados e `publicationStatus` intocados — e o recibo guarda a URL-fonte, os
|
||||
bytes e o sha256. Foto cuja fonte sumiu (ou não decodifica) **sai do público**
|
||||
(`approved → draft`) com registro; `removed` nunca é tocado. Rodar `--only` no
|
||||
canário primeiro:
|
||||
|
||||
```bash
|
||||
# no homeserver:
|
||||
ssh homeserver
|
||||
cd ~/stack
|
||||
# 1) canário read-only nas fotos do achado (80/83) — não escreve:
|
||||
docker compose --profile maintenance run --rm \
|
||||
-v /srv/archive-reports:/app/data/archive \
|
||||
teqo-1313-migrate pnpm archive:integrity --only 80,83
|
||||
# 2) reparo do canário (exige TEQO_ENV + confirmação; as 4 S3_* e FLICKR_API_KEY):
|
||||
docker compose --profile maintenance run --rm \
|
||||
-e TEQO_ENV=production -e ARCHIVE_INTEGRITY_CONFIRM=1 \
|
||||
-v /srv/archive-reports:/app/data/archive \
|
||||
teqo-1313-migrate pnpm archive:integrity --apply --only 80,83
|
||||
# 3) conferir as duas URLs públicas respondendo 200 (antes do lote):
|
||||
# https://jorgesolla1313.com.br/fotos/80/midia e /fotos/83/midia
|
||||
# 4) varredura completa (read-only; ~6,5k downloads no bucket local; sai 1 se achar):
|
||||
docker compose --profile maintenance run --rm \
|
||||
-v /srv/archive-reports:/app/data/archive \
|
||||
teqo-1313-migrate pnpm archive:integrity
|
||||
# 5) reparo do que a varredura acusou (mesma linha com --apply):
|
||||
docker compose --profile maintenance run --rm \
|
||||
-e TEQO_ENV=production -e ARCHIVE_INTEGRITY_CONFIRM=1 \
|
||||
-v /srv/archive-reports:/app/data/archive \
|
||||
teqo-1313-migrate pnpm archive:integrity --apply
|
||||
# 6) convergência: re-rodar a varredura; 0 pendências = aceite
|
||||
```
|
||||
|
||||
Guardas: fora de teste o `--apply` exige `TEQO_ENV` casando o **nome exato** do
|
||||
banco (`teqo_staging`/`teqo_1313`), `ARCHIVE_INTEGRITY_CONFIRM=1` e
|
||||
`FLICKR_API_KEY`; alvo não-local exige as 4 `S3_*` **em qualquer modo** (sem S3 a
|
||||
varredura mediria o disco efêmero do container); `ALLOW_REMOTE_DB` é recusado.
|
||||
`--concurrency` (default 3) e `--limit`/`--only` são os canários; o recibo traz
|
||||
varridas/ok/ausentes/corrompidas/removidas, e no reparo recuperadas/
|
||||
irrecuperáveis/falhas. A varredura **não** conserta nada: só o `--apply` escreve.
|
||||
|
||||
Depois de um `--apply` que rebaixou alguma foto, buste a tag pública em um
|
||||
processo que a possui (o write do CLI não revalida o servidor Next):
|
||||
|
||||
```bash
|
||||
curl -X POST "https://jorgesolla1313.com.br/api/revalidate?tag=archivePhotos" \
|
||||
-H "x-revalidate-secret: $REVALIDATE_SECRET"
|
||||
```
|
||||
|
||||
Fotos reparadas que continuam `approved` voltam a servir sozinhas (mesma chave,
|
||||
rota `force-dynamic`) — e o índice facial reprocessa sozinho o que tiver
|
||||
marcador stale (`pnpm faces:index`). Uma foto rebaixada a `draft` **não** deve
|
||||
voltar em lote: o `pnpm archive:publish` aprova todo draft sem checar o objeto,
|
||||
então só rode o publish depois de uma varredura limpa (a foto rebaixada só sai
|
||||
de draft quando a fonte for recuperada e o operador decidir publicá-la).
|
||||
Rollback: sem migration nesta entrega; o comando nunca apaga objeto nem linha —
|
||||
uma foto rebaixada por engano volta a `approved` pela ficha no admin (com o
|
||||
canal de remoção configurado).
|
||||
|
||||
## C230-followup — importação do Instagram para a Central de Conteúdos
|
||||
|
||||
|
|
|
|||
175
docs/plans/acervo-integridade-garage-impl.md
Normal file
175
docs/plans/acervo-integridade-garage-impl.md
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
# Impl: C246 — Integridade do acervo no Garage: achar e recuperar as fotos quebradas
|
||||
|
||||
Status: aprovado (modo --auto — apresentado no chat sem pausa)
|
||||
Atualizado em: 2026-10-01
|
||||
Issue: #1418
|
||||
Intenção: docs/plans/acervo-integridade-garage.md
|
||||
Appetite restante: herdado (~0,5–1 dia eng + ops) — cortes explícitos: sem migration, sem UI, sem varredura de `media`/vídeos, sem paralelismo de download do Flickr, varredura com concorrência limitada (`--concurrency`, default 3), reparo só do que a varredura acusar; a execução real em produção é ops (runbook no homeserver), não o agente.
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** o álbum público nunca serve mídia quebrada — uma varredura read-only de TODAS as fotos acha objeto ausente/corrompido com motivo, a recuperação re-ingere do Flickr pelo `flickrId` sem duplicar linha nem tocar em curadoria, e o recibo diz o que foi varrido, achado, recuperado e falhou (irrecuperável sai do público com registro).
|
||||
- **O que NÃO negociar:** sem tocar em curadoria (campos curados nunca sobrescritos — régua C231/C232); sem duplicar linha (`flickrId` unique); irrecuperável não pode ficar quebrada no público (D3); fora de escopo `media`/OPS52, vídeos e outras plataformas; read-only por padrão, escrita só com confirm explícito; produção via runbook do homeserver; nada de migration nem de `push`.
|
||||
- **O que reavaliar:** (a) `sourceUrl` é a **página** do Flickr, não o arquivo (`ArchivePhoto.ts:521-524` "Página no Flickr"; `src/lib/archivePhoto.ts:107-116` monta `/photos/<alias>/<id>/`) — a fonte real de recuperação é `getLargestSize` por `flickrId`, o mesmo fallback documentado da ingestão; (b) "re-ingestão existente" **não cura** linha existente: `ingestArchivePhoto` retorna cedo em `existing` sem tocar no objeto (`archivePhotoIngest.ts:95-103`) — o reparo é função nova no mesmo módulo (D1); (c) "volta ao público pelo fluxo normal" **não exige re-approve**: uma foto `approved` cujo objeto é substituído na MESMA chave volta a servir sozinha, e `draft` segue o `pnpm archive:publish` (D5); (d) o guard de canal de remoção só cobre a entrada em `approved` (`ArchivePhoto.ts:248`) — downgrade `approved → draft` nunca é bloqueado (resolve a dúvida do D3); (e) sem `S3_*` a varredura mede o disco local, não o bucket (D7).
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph CLI["scripts/check-archive-integrity.mjs — varredura read-only por padrão"]
|
||||
G["guardas: DB por TEQO_ENV, S3 completo no alvo não-local; ARCHIVE_INTEGRITY_CONFIRM só no --apply"] --> L["listArchivePhotos: todas as linhas (approved/draft/removed)"]
|
||||
L --> S["por foto: inspectPrivateMediaObject — download (mesmo caminho do índice facial) + decode sharp failOn:'error'"]
|
||||
S -- "ok" --> R["recibo: ok"]
|
||||
S -- "ausente/corrompido" --> P{"--apply?"}
|
||||
P -- "não (varredura)" --> R
|
||||
P -- "sim" --> F["Flickr getSizes (flickrId) → downloadUrlToFile (UA, teto) → valida decode"]
|
||||
F -- "fonte ok" --> U["repairArchivePhotoObject: payload.update com filePath na MESMA chave"]
|
||||
F -- "fonte sumiu" --> D["approved → draft + registro (purge/revalidação pelos hooks)"]
|
||||
U --> R
|
||||
D --> R
|
||||
end
|
||||
S --> S3[("bucket Garage (objeto privado)")]
|
||||
U --> S3
|
||||
U --> DB[("linha archivePhoto")]
|
||||
D --> DB
|
||||
R --> J["data/archive/reports/archive-integrity-*.json + stdout"]
|
||||
```
|
||||
|
||||
**Opções consideradas:** A) CLI nova de varredura + CLI de recuperação com `PutObject` direto (precedente `recover-media.mjs`) | B) estender `ingestArchivePhoto` para "curar" o `existing` | C) uma CLI nova (`archive:integrity`) cuja varredura reusa o caminho de leitura do índice facial e cujo reparo reusa o dono da escrita do upload (`payload.update` com `filePath`), com função nova no módulo de ingestão.
|
||||
**Recomendação:** C — é o único desenho em que a varredura mede exatamente o que o índice facial e o proxy público medem (download real, com a validação de checksum do SDK e o decode do sharp), o reparo passa pelo dono da escrita de arquivo da collection (hooks de revalidação/purge preservados, metadados coerentes) e nada é duplicado entre `media` e `archivePhoto`; a rede do Flickr fica isolada no client já existente (`flickrApi.mjs`) e a escrita Payload fica testável em int.
|
||||
**Rejeitadas:** A porque faria a CLI um segundo dono da escrita de objeto do `archivePhoto` (client S3 próprio) e pularia os hooks do ciclo de vida — o `PutObject` direto do `recover-media` existe porque lá o problema é DB-intocável, o que não é o caso; B porque mudaria o contrato de idempotência do `existing` (re-executar nunca re-baixa) e misturaria reparo com ingestão.
|
||||
|
||||
### Decisões de engenharia
|
||||
|
||||
**D1 — Onde vive a recuperação**
|
||||
|
||||
- Opções: A) CLI dedicada com `PutObject` direto (precedente `recover-media.mjs:235`) | B) estender `ingestArchivePhoto` para curar o `existing` | C) `payload.update({ id, filePath, overwriteExistingFiles: true })` orquestrado pela CLI, com `repairArchivePhotoObject` no módulo de ingestão.
|
||||
- Recomendação: C — o `payload.update` da Local API aceita `filePath` e `overwriteExistingFiles`; com o basename do temp fixado em `row.filename`, o nome/chave não mudam (`generateFileData` só renomeia quando `!overwriteExistingFiles`) e o objeto é sobrescrito na mesma chave; os hooks seguem donos da revalidação (`ArchivePhoto.ts:276`) e do purge de descritores (`:292`); `filesize`/dimensões ficam coerentes com o objeto; nenhum segundo escritor de objeto para `archivePhoto`. A função entra em `src/utilities/flickr/archivePhotoIngest.ts`, irmã de `updateArchivePhotoExif` (precedente de escrita de manutenção no mesmo módulo).
|
||||
- Rejeitadas: A porque duplicaria o dono da escrita de objeto e pularia os hooks (e `recover-media` é DB-intocável por natureza, caso diferente); B porque `existing` é contrato de idempotência da ingestão ("reexecutar nunca re-baixa") e reparo não é ingestão — a extensão viraria um modo escondido do C231.
|
||||
|
||||
**D2 — Varredura read-only**
|
||||
|
||||
- Opções: A) baixar cada objeto pelo caminho do índice facial (`downloadPrivateMediaToFile`) e validar a decodificação com sharp `failOn:'error'` | B) `headObject` + comparação de `x-amz-checksum-crc32` sem baixar | C) amostragem.
|
||||
- Recomendação: A — o GET real é a fonte da verdade: é exatamente o caminho em que as fotos 80/83 falham (o SDK valida o checksum na resposta) e o mesmo decode que a grade pública roda; um HEAD devolve o metadado armazenado (não o conteúdo recalculado) e não vê corrupção; a amostra de 20 já provou que o achado é raro e escapa. Implementar `inspectPrivateMediaObject({ media, staticDir })` no dono `privateMediaResponse.ts`: reusa `downloadPrivateMediaToFile` para um temp (limpo por foto), força o decode (`sharp({ failOn:'error' }).rotate().resize(...)`), mede bytes e classifica `ok | missing | corrupt` com `stage: 'download' | 'decode'` e motivo; reusa o classificador de ausência existente (exportar o atual `isMissingObject` como `isPrivateMediaMissingError`, uso interno intacto). CLI com `--concurrency` (default 3) e `--limit` (canary).
|
||||
- Rejeitadas: B porque checksum armazenado não detecta corrupção de conteúdo (o mismatch só aparece no GET) e objeto truncado sem checksum passa; C porque o problema é raro e a amostragem falha no único caso que importa.
|
||||
|
||||
**D3 — Fotos irrecuperáveis**
|
||||
|
||||
- Opções: A) `draft` + registro no recibo | B) `removed` | C) ficar como está e só reportar.
|
||||
- Recomendação: A — `payload.update({ publicationStatus: 'draft' })` com `overrideAccess: true` (ator confiável sem sessão, precedente C231/C242); o guard `requireRemovalChannelForApproval` só dispara na transição PARA `approved` (`ArchivePhoto.ts:248`), então o downgrade é permitido mesmo sem canal configurado; os afterChange hooks purgam os descritores (`:292-297`) e revalidam a listagem (`:276`), tirando a foto do público e da busca por selfie. O recibo registra id, flickrId, motivo e status anterior/novo. Linhas `removed` nunca são reparadas nem rebaixadas (takedown continua takedown — C242): a varredura reporta e o reparo registra `skippedRemoved`.
|
||||
- Rejeitadas: B porque `removed` é takedown de pessoa e um arquivo quebrado não é pedido de remoção — além de esconder a causa; C porque viola o aceite ("nunca ficam quebradas").
|
||||
|
||||
**D4 — Recuperação da fonte**
|
||||
|
||||
- Opções: A) re-`getSizes` (maior size) pelo `flickrId` via API | B) baixar o `sourceUrl` persistido | C) A com fallback B.
|
||||
- Recomendação: A — `sourceUrl` é a página humana (fato acima), não o arquivo; o `originalUrl` não é persistido; `getLargestSize` (`scripts/lib/flickrApi.mjs:187-200`) é o mesmo fallback documentado da ingestão (inclui "Original" quando disponível; quando o original é restrito, é exatamente o que foi ingerido). Só as fotos acusadas chamam a API (1 req/foto — rate limit irrelevante); `FLICKR_API_KEY` é exigida só no `--apply`, fail-fast antes de qualquer rede; o download usa `downloadUrlToFile` com `User-Agent` do Flickr e teto de 512 MiB/2 min (constantes do C231). O recibo grava a URL-fonte, bytes e sha256 (proveniência).
|
||||
- Rejeitadas: B porque baixaria HTML (e scraping é anti-goal); C porque o fallback não existe de verdade — sem size usável, a foto é irrecuperável e segue o D3.
|
||||
|
||||
**D5 — Reaprovação pós-recuperação**
|
||||
|
||||
- Opções: A) a CLI não toca `publicationStatus` no reparo (approved continua approved; draft segue draft) | B) re-aprovar automaticamente se estava approved antes | C) deixar sempre draft.
|
||||
- Recomendação: A — a linha nunca foi mutada; só o objeto foi substituído na mesma chave. O gate público é metadado (`approved` + `filename` inalterado, `archivePhotoReads.ts:68`) e a rota de mídia lê a linha fresca e faz stream do objeto (`route.ts:33-72`, `force-dynamic` + `no-store`), então a foto aprovada volta a servir sem nenhuma escrita de status; `draft` permanece `draft` e segue o fluxo normal `pnpm archive:publish` (que exige canal de remoção). O recibo lembra de rodar `pnpm faces:index` — foto cujo índice falhou fica stale (o marcador só é gravado no sucesso, `faceDescriptorIndex.ts:180-252`) e é reprocessada sozinha.
|
||||
- Rejeitadas: B porque reparo não é ato de curadoria e um re-approve re-dispararia o guard de canal (podendo transformar um reparo em falha); C porque despublicaria fotos boas (viola "voltar ao público pelo fluxo normal").
|
||||
|
||||
**D6 — Recibo**
|
||||
|
||||
- Opções: A) JSON datado em `data/archive/reports/archive-integrity-<stamp>.json` (varredura) e `...-repair.json` (`--apply`) + resumo humano no stdout, via `scripts/lib/archiveIntegrityPlan.mjs` puro (parse/summarize/format) | B) `data/flickr/reports/` | C) só stdout.
|
||||
- Recomendação: A — segue o regime do C242 (`data/archive/reports/archive-publish-<stamp>.json`) e o padrão plan-lib (`archivePublishPlan.mjs`), com `writeRepoFile` do esqueleto CLI. Campos: `runAt`, `mode` (`verify|repair`), `target` (sem segredo), `scanned`, `ok`, `missing`, `corrupted[]` (id, flickrId, filename, stage, reason), `recovered[]`, `failed[]`, `unrecoverable[]` (status anterior/novo), `skippedRemoved`, `bytes`, `durationMs`; no reparo, `results[]` por foto (status, stage, sourceUrl, sha256, bytes). Exit codes: varredura sai 1 se `missing+corrupted > 0` (espelha `faces:index --verify`), 0 quando limpa — é o critério de convergência; `--apply` sai 1 se restar `failed` que não virou `unrecoverable` tratado.
|
||||
- Rejeitadas: B porque o domínio é o acervo, não o Flickr; C porque o aceite pede conferência posterior e a Issue pede recibo anexável.
|
||||
- **Nota de execução (triage pós-simplify, 2026-10-02):** o shape final do recibo difere do previsto acima em três pontos, sem mudar a decisão: (a) `pending` conta **só** as quebradas `approved` — é o exit 1 da varredura, e quebradas `draft`/`removed` (`draftBroken`/`removedBroken`) são registradas sem bloquear a convergência pós-`--apply`; (b) `scan` ganhou `draftBroken`, `removedScanned` e `removedBroken` (em vez do único `skippedRemoved`), e o rebaixamento de uma foto que já era `draft` é registrado sem `previousStatus`/`newStatus`; (c) o JSON é `{ runAt, mode, target, scan, repair?, results[], repairResults?, durationMs }` — os itens por foto ficam nas arrays e o formatador lê sempre `report.scan`.
|
||||
|
||||
**D7 — Guardas de escrita**
|
||||
|
||||
- Opções: A) `--apply` exige `ARCHIVE_INTEGRITY_CONFIRM=1` + `assertWriteConfirm` + `assertEnvironmentDatabaseTarget` (TEQO_ENV casando o banco exato) + S3 completo (`mirroredMediaRequired`) + `FLICKR_API_KEY`; a varredura é read-only, mas exige S3 completo quando o alvo não é local | B) reusar `ARCHIVE_PUBLISH_CONFIRM` | C) `assertLocalDatabase` + `ALLOW_REMOTE_DB`.
|
||||
- Recomendação: A — flag própria porque o write path é outro (reparo de objeto + eventual downgrade) e a intenção de operação é diferente da publicação em lote; mesmo stack do C231/C242 (`assertWriteConfirm` + `assertEnvironmentDatabaseTarget`, que distingue staging/produção pelo nome do banco e recusa `ALLOW_REMOTE_DB`); S3 completo obrigatório no alvo não-local via `mirroredMediaRequired` (sem S3 a varredura/reparo mediriam o disco errado); `FLICKR_API_KEY` fail-fast no `--apply`. Runbook no homeserver (compose maintenance, env do stack), como `flickr:import`/`archive:publish`.
|
||||
- Rejeitadas: B porque publicar e reparar são intenções distintas (o operador pode ter armado só uma); C porque não separa staging de produção e admite o override remoto que o C195/C231 recusa.
|
||||
|
||||
**Migration:** sem migration — nenhum campo, índice ou collection novos. Stop-condition do modo autônomo: se aparecer necessidade de campo novo (ex.: marcador de integridade), parar e sinalizar em vez de criar schema.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`inspectPrivateMediaObject`** (`src/utilities/privateMedia/privateMediaResponse.ts`, edição): novo export que baixa o objeto pelo caminho do índice facial (`downloadPrivateMediaToFile`), valida decode com sharp `failOn:'error'` e classifica `ok | missing | corrupt` (`stage: 'download' | 'decode'`, motivo, bytes); exporta o classificador de ausência atual como `isPrivateMediaMissingError` (renome do `isMissingObject` privado, uso interno intacto); temp limpo por chamada. Reusa o client S3 module-private e o fallback de disco local (dev/test).
|
||||
- **`repairArchivePhotoObject`** (`src/utilities/flickr/archivePhotoIngest.ts`, edição): `payload.update({ collection: ARCHIVE_PHOTO_SLUG, id, data: {}, filePath, overwriteExistingFiles: true, overrideAccess: true })`; retorna `{ status: 'repaired' | 'failed', filename, error? }`; nunca envia campos curados nem `publicationStatus`; nome do temp = `row.filename ?? archivePhotoStorageFilename(flickrId, originalUrl)` (determinístico C231).
|
||||
- **`archiveIntegrityPlan`** (`scripts/lib/archiveIntegrityPlan.mjs`, novo): `parseArchiveIntegrityCliArgs` (`--apply`, `--limit`, `--only`, `--out`, `--help`; modos exclusivos), `archiveIntegrityReportStamp`, `summarizeArchiveIntegrity`, `formatArchiveIntegrityReport`; entra em `SCRIPTS_SPEC_PINNED` (`scripts/lib/test-affected-core.mjs`).
|
||||
- **`check-archive-integrity`** (`scripts/check-archive-integrity.mjs`, novo + script `"archive:integrity"` no `package.json`): varredura default read-only (`--concurrency`, `--limit`, `--out`) e `--apply` (varredura + reparo com guardas D7); por foto acusada: `client.getLargestSize(flickrId)` → `downloadUrlToFile` (UA/teto C231) → validação decode → `repairArchivePhotoObject`; fonte sumiu → downgrade a draft (D3); `removed` intocado; recibo D6; exit codes D6.
|
||||
- **`docs/ops/teqo-1313-deploy.md`** (edição): seção C246 com canary, varredura completa, reparo e re-varredura, no serviço `teqo-<env>-migrate` com volume de recibos; lembrete do `curl /api/revalidate?tag=archivePhotos` quando houver downgrade (o write do CLI não revalida o servidor Next vivo).
|
||||
- **`docs/changelog/2026-10-01-c246.md`** + este impl no mesmo PR.
|
||||
- **Access / Consent:** nenhuma mudança de access; as chamadas são de ator confiável sem sessão (`overrideAccess: true` documentado, precedente `archivePhotoIngest.ts:43-44`); sem `Consent` novo e sem `Contact` (não há opt-in nem PII de titular nesta fatia). Escritas são single-collection (`payload.update`), sem necessidade de transação multi-collection.
|
||||
- **UI:** Impeccable A — sem UI; nenhum shell/rota nova; a ficha admin é gerada pelo Payload.
|
||||
|
||||
### Dados → forma
|
||||
|
||||
- Forma escolhida: recibo JSON operacional (`data/archive/reports/`) + linhas humanas no stdout (contagens e falhas nomeadas), consumido por operador — não há superfície pública.
|
||||
- Rejeitadas: relatório markdown separado (duplicaria o JSON e criaria segunda fonte de verdade); CSV (ninguém consome); painel/admin (não pedido; fora do appetite).
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Tracer / schema+server — varredura read-only (~40% do appetite):** `inspectPrivateMediaObject` no dono + `archiveIntegrityPlan.mjs` (parser/summarize/format) + modo varredura da CLI + recibo + `SCRIPTS_SPEC_PINNED`. Testes: unit do plan-lib (args, summarize, format) e do classificador; int com fixture real (`ARCHIVE_PHOTO_JPEG_BYTES`): objeto íntegro → `ok`; arquivo corrompido no staticDir → `corrupt` (stage decode); arquivo removido → `missing`; checksum error classificado como `corrupt` (unit do predicado, sem S3). Prova local: ingerir fixture, corromper o arquivo, `pnpm archive:integrity --limit 20` acusa com motivo e sai 1; varredura limpa sai 0.
|
||||
2. **Recuperação (~30%):** `repairArchivePhotoObject` + modo `--apply` (Flickr `getSizes` + download + validação + update) + guardas D7. Testes: int — reparo substitui os bytes na MESMA chave, `filename` inalterado, `curatedFields`/curadoria/status intactos, `filesize` atualizado; unit — `flickrApi.getLargestSize` já pinado, `--only`/canary e guardas da CLI por spawn (espelho de `archiveCatalogCli.unit.spec.ts`: sem confirm, sem TEQO_ENV, banco ≠ ambiente, S3 incompleto).
|
||||
3. **Irrecuperáveis + `removed` (~15%):** downgrade `approved → draft` com registro; int — descritores purgados no downgrade, canal de remoção ausente NÃO bloqueia o downgrade, `removed` não é tocado pelo reparo (fica no recibo como `skippedRemoved`); convergência — corromper → reparar → `inspect` volta `ok`.
|
||||
4. **Gates + docs + ops (~15%):** `pnpm gate:fast`; runbook no `teqo-1313-deploy.md`; changelog; push via `pnpm push`. **Ops (fora do merge):** no homeserver, canário nas fotos 80/83 (`--only 80,83`), checar `/fotos/80/midia` e `/fotos/83/midia` 200, varredura completa (read-only) e `--apply`; anexar os recibos à Issue #1418; re-varredura limpa é o aceite final.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Não varrer `media` (OPS52), vídeos, gravações ou outras plataformas — só `archivePhoto`.
|
||||
- Não re-ingerir o acervo inteiro (corte de produto): só o que a varredura acusar.
|
||||
- Não apagar fotos nem objetos (corte de produto): recuperar primeiro; sair do público só quando a fonte não existir.
|
||||
- Não adicionar campo/marcador de integridade (sem migration): reexecutar converge por varredura completa, que é a checagem honesta.
|
||||
- Não mexer em curadoria, não re-aprovar, não publicar lote, não tocar em `removed`.
|
||||
- Não paralelizar download do Flickr (poucas fotos; client C231 sequencial com retry já basta).
|
||||
- Não editar migrações existentes nem usar `push`; nenhuma UI/admin nova.
|
||||
- Não criar `Consent`/`Contact`/cadastro paralelo (não se aplica).
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Checksum/ETag multipart:** ETag de multipart não é MD5 — não usar como hash; a validação é o download real (checksum do SDK quando presente) + decode do sharp (cobre objeto truncado sem checksum). Falso negativo residual aceito e registrado no recibo.
|
||||
- **Flickr auth/rate-limit:** só as fotos acusadas chamam `getSizes` (1 req/foto); `FLICKR_API_KEY` fail-fast; retry/backoff do client C231; falha de fonte → `unrecoverable` → D3.
|
||||
- **Objeto curado não pode ser sobrescrito:** `repairArchivePhotoObject` só envia `filePath` (+ `overwriteExistingFiles`), nunca campos curados nem status; `deriveArchivePhotoCatalogIndex` não marca curadoria em escrita sem `req.user` (`ArchivePhoto.ts:175`); int pin de `curatedFields`/status.
|
||||
- **Drift de filename/chave:** temp com basename = `row.filename` + `overwriteExistingFiles: true` (só renomeia quando `false`); int pin do filename inalterado e do objeto na mesma chave; sem filename, nome determinístico C231. Canário de produção confere as fotos 80/83 antes do lote.
|
||||
- **S3 config parcial / alvo errado:** `resolveS3StorageEnv` lança em config parcial (`mediaStorage.ts:46`); `mirroredMediaRequired` exige as 4 envs no alvo não-local; sem S3 em dev/test a varredura mede o disco local (documentado; nunca é o caso de produção).
|
||||
- **Fotos já `approved` sem canal de remoção:** não bloqueiam nada — o guard só cobre a entrada em `approved` e o reparo não re-aprova; se o operador quiser publicar um draft de volta, o fluxo normal exige o canal.
|
||||
- **Custo de baixar todas as fotos:** ~6.492 objetos na rede local + decode; `--concurrency` (default 3) e `--limit` canary; recibo com bytes/tempo; execução no homeserver evita egress externo.
|
||||
- **Falsos positivos:** a varredura não escreve nada — um falso positivo custa apenas um reparo redundante (idempotente, re-baixar da fonte é seguro) e fica no recibo com motivo.
|
||||
- **Cache do servidor vivo após downgrade:** o hook de revalidação roda no processo de manutenção e não alcança o Next vivo (mesmo caveat do `archive:publish`); o runbook manda bustar `tag=archivePhotos` após `--apply` com downgrade; fotos reparadas (approved, filename inalterado) voltam a servir sem bust.
|
||||
- **Janela scan→reparo:** a foto segue 500 até o reparo; curto e aceitável (o scan é o pre-flight do próprio `--apply`).
|
||||
- **`payload.update` com `filePath` via plugin S3 em produção:** caminho não coberto por int sem credenciais; mitigado pelo canário `--only 80,83` + verificação pública 200 antes do lote completo.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção ainda coberto (varredura de todas as fotos, recuperação sem duplicar/curar, irrecuperável fora do público, reexecução converge, recibo honesto)
|
||||
- [ ] Invariantes AGENTS/engineering-standards (overrideAccess documentado em CLI sem sessão; sem migration; sem `push`; copy pt-BR / identificadores em inglês; produção só com confirm e via runbook)
|
||||
- [ ] Testes de domínio previstos: unit (plan-lib, classificador, guardas da CLI por spawn) e int (inspect ok/missing/corrupt; repair preserva filename/curadoria/status; downgrade purga descritores sem canal; `removed` intocado; convergência corromper→reparar→ok)
|
||||
- [ ] Sem migration / sem campo novo (stop-condition respeitada)
|
||||
- [ ] `inspectPrivateMediaObject` reusa o caminho do índice facial e classifica `ok | missing | corrupt` com motivo
|
||||
- [ ] `repairArchivePhotoObject` mantém a chave/filename e não toca curadoria nem `publicationStatus`
|
||||
- [ ] Varredura read-only não escreve e sai 1 com pendência; `--apply` exige `ARCHIVE_INTEGRITY_CONFIRM`, `TEQO_ENV` exato, S3 completo e `FLICKR_API_KEY`
|
||||
- [ ] Recibos em `data/archive/reports/` + resumo stdout; runbook C246 no `teqo-1313-deploy.md`; changelog no PR
|
||||
- [ ] `pnpm gate:fast` verde; push via `pnpm push`
|
||||
|
||||
## Débitos deferidos (triage pós-simplify, 2026-10-02)
|
||||
|
||||
- **S1 — pipeline de decode com donos múltiplos:** `inspectPrivateMediaObject` (`privateMediaResponse.ts:384-387`) e a prova da fonte do reparo (`archiveIntegrityRepair.mjs:105-108`) duplicam hoje o mesmo proof (`failOn:'error'` + `rotate` + `resize width 1024 withoutEnlargement`); o `prepareFaceImage` (`faceDescriptorIndex.ts:55-60`) é transform vizinho (resize com `height`/`fit` e saída `raw()`), não o mesmo contrato. **Gatilho:** um 4º consumidor do proof de decode ou a necessidade de mudar edge/transform/`failOn` — então extrair um `assertImageDecodes` no dono `privateMediaResponse` e consumir de inspect/repair (não forçar o face index no mesmo helper).
|
||||
- **S3 — `mapWithConcurrency` no plan-lib de domínio** (`archiveIntegrityPlan.mjs:104`), 1 consumidor (a própria CLI). **Gatilho:** um 2º script precisar de mapa com concorrência limitada — mover para o esqueleto `scripts/lib/cli.mjs` e importar nos dois.
|
||||
- **S4 — contrato do recibo em JSDoc solto em 3 arquivos** (`archiveIntegrityPlan.mjs`, `archiveIntegrityRepair.mjs`, `check-archive-integrity.mjs`). **Gatilho:** o recibo ganhar um 2º consumidor (parsing por script/painel) ou uma 4ª superfície — então typedef único no plan-lib e referência nos demais.
|
||||
- **S6 — constantes de download do Flickr duplicadas** (`ingest-flickr-archive.mjs:84-85` do C231 × `archiveIntegrityRepair.mjs:25-26`). **Gatilho:** um 3º consumidor de download do Flickr ou mudança do teto/timeout — mover para o dono do client (`scripts/lib/flickrApi.mjs`) e importar nos dois.
|
||||
- **S5 — registrado como C249 (depende do C246):** `pnpm archive:publish` aprova todo draft sem olhar o objeto e pode devolver ao público uma foto que o C246 rebaixou por estar quebrada; hoje só o aviso do runbook mitiga. Plano: `docs/plans/acervo-publish-preflight-integridade.md`.
|
||||
|
||||
### Já resolvido no /simplify (não reabrir)
|
||||
|
||||
- `isFlickrSourceGone` só reconhece códigos 1/2 + HTTP 404 (100/105/401/403/429/5xx/network viram `failed` retryável).
|
||||
- `pending` conta só `approved`; `draftBroken`/`removedScanned`/`removedBroken` registram sem bloquear; runbook alinhado.
|
||||
- `process.exit()` movido para depois do `finally` (temp `/tmp/archive-integrity-*` não vaza mais).
|
||||
- `skipped already-draft` não mente mais `draft → draft` (sem previous/newStatus).
|
||||
- Formatador sempre usa `report.scan` (fim do fallback com NaN).
|
||||
- `isPrivateMediaMissingError` narrowado antes do cast.
|
||||
- Docblock de `archivePhotoIngest` alinhado ao reparo/withdraw.
|
||||
- Typo `jorgessolla` corrigido no int spec.
|
||||
- Fila de reparo deriva de `scan.missing`/`scan.corrupted` (fim da regra duplicada de `removed`).
|
||||
|
||||
### Descartado
|
||||
|
||||
- **S2 — `messageOf` local no script de reparo:** one-liner cuja cópia local é a convenção dos scripts (25+ equivalentes inline) e cujo dono (`src/utilities/media/ffmpeg.ts`) é `import 'server-only'`, não importável do script; sem risco real de divergência.
|
||||
|
||||
## Self-score decision-quality
|
||||
|
||||
**5/5** — (1) todas as decisões caras foram deliberadas agora com opções/recomendação/rejeitadas (D1 write path, D2 método de detecção, D3 saída do público, D4 fonte, D5 status, D7 guardas); (2) depth check feito — reusa o dono de leitura de mídia privada (`privateMediaResponse`), o dono de escrita do upload (`payload.update` via `archivePhotoIngest`) e o esqueleto CLI, sem twin e sem pass-through raso; (3) a hipótese "sourceUrl é arquivo" foi corrigida contra o codebase e a intenção preservada no outcome; (4) rabbit holes de produto (re-ingerir tudo, apagar) e de engenharia (campo novo/migration, UI, varredura de `media`) explicitamente cortados; (5) riscos com mitigação verificável (canário 80/83, int pins, cache bust do downgrade) e aceite com checkboxes.
|
||||
43
docs/plans/acervo-publish-preflight-integridade.md
Normal file
43
docs/plans/acervo-publish-preflight-integridade.md
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
# Plano curto: C249 — archive:publish com preflight de integridade
|
||||
|
||||
Status: proposto (triage do C246, 2026-10-02)
|
||||
Issue: C249 (a registrar; `depends: [C246]`)
|
||||
Appetite: ~0,5 dia eng. Sem migration, sem UI, sem mudar contrato público.
|
||||
|
||||
## Contexto
|
||||
|
||||
O C246 rebaixa a `draft` a foto irrecuperável (objeto quebrado/ausente) e o
|
||||
`pnpm archive:publish` (C242) aprova **todo** draft sem olhar o objeto — um
|
||||
publish depois de um `--apply` pode devolver ao público uma foto quebrada
|
||||
(500 em `/fotos/<id>/midia`). Hoje a mitigação é só o aviso do runbook
|
||||
(`docs/ops/teqo-1313-deploy.md`, seção C246): "não rode o publish depois de um
|
||||
reparo sem varredura limpa".
|
||||
|
||||
## Fases
|
||||
|
||||
1. **Preflight na fila do publish (~70%):** no `--apply` (e no plano, para o
|
||||
operador ver antes), cada draft elegível passa pelo `inspectPrivateMediaObject`
|
||||
do C246; objeto fora de `ok` **não é aprovado**, entra no recibo como
|
||||
`skippedBroken` (id, stage, reason) e o comando sai 1 se houver puladas. A
|
||||
query da fila passa a selecionar `filename` além de `id`. Reusa o dono da
|
||||
varredura e o `scripts/lib/archivePublishPlan.mjs` (summarize/format ganham a
|
||||
chave) — sem varredura própria na CLI do publish.
|
||||
2. **Provas (~20%):** unit do summarize/format com `skippedBroken`; int: draft
|
||||
com objeto íntegro é aprovado, draft com objeto removido/corrompido fica
|
||||
`draft` e é nomeado no recibo; spawn da CLI pinando os guardas atuais.
|
||||
3. **Runbook (~10%):** atualizar a seção C246/C242 do `teqo-1313-deploy.md` —
|
||||
o aviso deixa de ser a única proteção.
|
||||
|
||||
## Rabbit holes / Não escopo
|
||||
|
||||
- Não reparar objeto no publish (reparo é C246): só pular e nomear.
|
||||
- Não varrer `media`, vídeos ou outras collections; não tocar `removed`.
|
||||
- Não mudar as guardas/confirm do publish; sem migration nem campo novo (o
|
||||
`skippedBroken` vive no recibo, não no schema).
|
||||
- Não adicionar cache/revalidação além do que o publish já manda.
|
||||
|
||||
## Aceite
|
||||
|
||||
- [ ] `archive:publish --apply` nunca aprova draft com objeto quebrado.
|
||||
- [ ] Recibo nomeia cada pulada com stage/motivo; exit 1 se houver puladas.
|
||||
- [ ] Int cobre aprovadas e puladas; guardas do publish intactos; runbook atualizado.
|
||||
|
|
@ -43,6 +43,7 @@
|
|||
"faces:enroll-figure": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/enroll-face-figure.mjs",
|
||||
"archive:publish": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/publish-archive-photos.mjs",
|
||||
"ops:global": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/ops-global.mjs",
|
||||
"archive:integrity": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/check-archive-integrity.mjs",
|
||||
"seed:face-consents": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-face-consents.mjs",
|
||||
"seed:face-figures": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-face-figures.mjs",
|
||||
"db:seed:minimal": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-minimal.mjs",
|
||||
|
|
|
|||
344
scripts/check-archive-integrity.mjs
Normal file
344
scripts/check-archive-integrity.mjs
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
/**
|
||||
* C246 — integrity sweep of the private photo archive (C231): every
|
||||
* `archivePhoto` row is downloaded through the SAME path the public media route
|
||||
* and the face index use (the S3 SDK validates the stored checksum on the GET)
|
||||
* and then decoded with sharp; a missing object is `missing`, a read failure or
|
||||
* an undecodable file is `corrupt`, and the receipt names every one with its
|
||||
* motive. The sweep is READ-ONLY by default and exits 1 while any non-removed
|
||||
* photo is broken — that exit is the convergence criteria.
|
||||
*
|
||||
* With `--apply`, each broken photo is re-ingested from Flickr by `flickrId`
|
||||
* (the same `getLargestSize` fallback the C231 ingestion documents), the
|
||||
* downloaded original is decoded to prove it is a real image, and the object is
|
||||
* replaced through the collection's own upload write path
|
||||
* (`repairArchivePhotoObject`) — same key, no metadata/curation/status write.
|
||||
* A photo whose Flickr source is gone (or does not decode) leaves the public
|
||||
* with a record (`approved → draft`); `removed` is never touched.
|
||||
*
|
||||
* Modes:
|
||||
* pnpm archive:integrity sweep (default) — read-only, no writes
|
||||
* pnpm archive:integrity --apply sweep + repair; requires
|
||||
* ARCHIVE_INTEGRITY_CONFIRM=1 outside
|
||||
* local dev, the declared TEQO_ENV
|
||||
* matching the exact database, the
|
||||
* complete S3_* envs and FLICKR_API_KEY
|
||||
*
|
||||
* Options: --only <ids> (canary, comma-separated row ids), --limit <n> (canary),
|
||||
* --concurrency <n> (default 3), --out <dir> (receipts, default `data/archive`),
|
||||
* --help.
|
||||
*
|
||||
* Guards: --apply refuses a non-local/production target without the intent flag
|
||||
* (C155) and demands the declared TEQO_ENV matching the exact database name
|
||||
* (C195/C231); a non-local target also demands the complete S3_* set in BOTH
|
||||
* modes (without S3 the sweep would measure the wrong storage).
|
||||
*
|
||||
* Runbook: on the homeserver, inside the compose maintenance service with the
|
||||
* stack env file — see docs/ops/teqo-1313-deploy.md. After a `--apply` that
|
||||
* withdrew a photo, bust the public cache from a process that owns it:
|
||||
* curl -X POST "https://jorgesolla1313.com.br/api/revalidate?tag=archivePhotos" ...
|
||||
*/
|
||||
import { mkdir, mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import {
|
||||
ARCHIVE_INTEGRITY_DEFAULT_CONCURRENCY,
|
||||
ARCHIVE_INTEGRITY_DEFAULT_OUT_DIR,
|
||||
archiveIntegrityReportStamp,
|
||||
formatArchiveIntegrityRepairReport,
|
||||
formatArchiveIntegrityScanReport,
|
||||
mapWithConcurrency,
|
||||
parseArchiveIntegrityCliArgs,
|
||||
summarizeArchiveIntegrityRepair,
|
||||
summarizeArchiveIntegrityScan,
|
||||
} from './lib/archiveIntegrityPlan.mjs'
|
||||
import { repairArchivePhotoFromSource } from './lib/archiveIntegrityRepair.mjs'
|
||||
import {
|
||||
TEQO_ENV_DATABASE_BY_ENV,
|
||||
assertEnvironmentDatabaseTarget,
|
||||
assertWriteConfirm,
|
||||
databaseTarget,
|
||||
dieWithLabel,
|
||||
loadCliEnv,
|
||||
mirroredMediaRequired,
|
||||
writeRepoFile,
|
||||
} from './lib/cli.mjs'
|
||||
import { createFlickrClient } from './lib/flickrApi.mjs'
|
||||
|
||||
loadCliEnv()
|
||||
|
||||
const die = dieWithLabel('archive:integrity')
|
||||
|
||||
const WRITE_CONFIRM_FLAG = 'ARCHIVE_INTEGRITY_CONFIRM'
|
||||
const PROGRESS_EVERY = 250
|
||||
|
||||
const envLines = Object.entries(TEQO_ENV_DATABASE_BY_ENV)
|
||||
.map(([environment, database]) => `${environment} (${database})`)
|
||||
.join(' | ')
|
||||
|
||||
const HELP = `
|
||||
Uso: pnpm archive:integrity [opções]
|
||||
|
||||
Varre a integridade dos originais do acervo privado (archivePhoto): baixa cada
|
||||
objeto pelo mesmo caminho da rota pública e do índice facial e força o decode
|
||||
com sharp. O recibo nomeia cada foto ausente/corrompida com o motivo. Reexecutar
|
||||
converge; a varredura é read-only e sai 1 enquanto houver mídia quebrada pública.
|
||||
|
||||
Modos:
|
||||
(sem flag) varredura read-only — todas as fotos; sem escrita
|
||||
--apply varredura + recuperação: re-ingere do Flickr pelo flickrId,
|
||||
substitui o objeto na MESMA chave (sem tocar curadoria nem
|
||||
status) e rebaixa a draft as irrecuperáveis (fonte sumiu)
|
||||
|
||||
Opções:
|
||||
--only <ids> canário: só os ids de linha informados (ex.: --only 80,83)
|
||||
--limit <n> processa no máximo n fotos (canário)
|
||||
--concurrency <n> downloads simultâneos (default ${ARCHIVE_INTEGRITY_DEFAULT_CONCURRENCY})
|
||||
--out <dir> diretório dos recibos (default ${ARCHIVE_INTEGRITY_DEFAULT_OUT_DIR})
|
||||
--help esta ajuda
|
||||
|
||||
Ambiente:
|
||||
DATABASE_URL alvo (obrigatório em todos os modos)
|
||||
FLICKR_API_KEY chave da API do Flickr (exigida no --apply)
|
||||
|
||||
--apply em alvo não-local/produção exige ${WRITE_CONFIRM_FLAG}=1 e TEQO_ENV=${envLines}
|
||||
casando o banco exato. Alvo não-local exige as quatro envs S3_* em QUALQUER modo
|
||||
(sem S3 a varredura mediria o storage errado).
|
||||
`
|
||||
|
||||
const writeReport = async (options, report, suffix = '') => {
|
||||
const stamp = archiveIntegrityReportStamp(report.runAt)
|
||||
const relativePath = join(options.out, 'reports', `archive-integrity-${stamp}${suffix}.json`)
|
||||
await writeRepoFile({
|
||||
label: 'archive:integrity',
|
||||
root: process.cwd(),
|
||||
relativePath,
|
||||
body: JSON.stringify(report, null, 2),
|
||||
})
|
||||
return join(process.cwd(), relativePath)
|
||||
}
|
||||
|
||||
const selectRows = (rows, options) => {
|
||||
let selected = rows
|
||||
if (options.only) {
|
||||
const wanted = new Set(options.only)
|
||||
selected = selected.filter((row) => wanted.has(row.id))
|
||||
}
|
||||
if (options.limit !== null) selected = selected.slice(0, options.limit)
|
||||
return selected
|
||||
}
|
||||
|
||||
/**
|
||||
* One read-only probe: download + decode, with the caller-owned temp removed
|
||||
* right away (6k+ photos cannot accumulate on disk).
|
||||
*/
|
||||
const inspectRow = async ({ row, tempRoot, staticDir, inspect }) => {
|
||||
const dir = join(tempRoot, String(row.id))
|
||||
await mkdir(dir, { recursive: true })
|
||||
const filename = row.filename ?? null
|
||||
const destinationPath = join(dir, filename ?? `foto-${row.id}.bin`)
|
||||
try {
|
||||
const inspection = await inspect({
|
||||
media: { filename },
|
||||
staticDir,
|
||||
destinationPath,
|
||||
})
|
||||
return {
|
||||
id: row.id,
|
||||
flickrId: String(row.flickrId),
|
||||
filename,
|
||||
publicationStatus: row.publicationStatus,
|
||||
...inspection,
|
||||
}
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true }).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArchiveIntegrityCliArgs(process.argv.slice(2))
|
||||
if (options.help) {
|
||||
console.log(HELP)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (!process.env.DATABASE_URL) die('DATABASE_URL não definida; recusando continuar.')
|
||||
const mode = options.apply ? 'repair' : 'verify'
|
||||
const runAt = new Date().toISOString()
|
||||
console.log(`[archive:integrity] alvo: ${databaseTarget()} | modo: ${mode}`)
|
||||
|
||||
if (mode === 'repair') {
|
||||
assertWriteConfirm({
|
||||
label: 'archive:integrity',
|
||||
flag: WRITE_CONFIRM_FLAG,
|
||||
command: 'pnpm archive:integrity --apply',
|
||||
})
|
||||
assertEnvironmentDatabaseTarget()
|
||||
}
|
||||
|
||||
const { resolveS3StorageEnv } = await import('../src/utilities/mediaStorage.ts')
|
||||
const storage = resolveS3StorageEnv(process.env)
|
||||
if (mirroredMediaRequired({ s3Enabled: storage.enabled })) {
|
||||
die(
|
||||
'alvo não-local exige mídia espelhada: configure S3_BUCKET, S3_ENDPOINT, S3_ACCESS_KEY_ID e S3_SECRET_ACCESS_KEY; sem elas a varredura mediria o storage errado.',
|
||||
)
|
||||
}
|
||||
if (mode === 'repair' && !process.env.FLICKR_API_KEY?.trim()) {
|
||||
die('FLICKR_API_KEY ausente — o reparo re-ingere do Flickr (veja .env.example).')
|
||||
}
|
||||
|
||||
const config = (await import('../src/payload.config.ts')).default
|
||||
const { listArchivePhotos, repairArchivePhotoObject, withdrawArchivePhotoFromPublic } =
|
||||
await import('../src/utilities/flickr/archivePhotoIngest.ts')
|
||||
const { inspectPrivateMediaObject, resolvePrivateMediaStaticDir } =
|
||||
await import('../src/utilities/privateMedia/privateMediaResponse.ts')
|
||||
const { downloadUrlToFile } = await import('../src/utilities/media/downloadToFile.ts')
|
||||
const payload = await getPayload({ config })
|
||||
const startedAt = Date.now()
|
||||
const staticDir = resolvePrivateMediaStaticDir(payload, 'archivePhoto')
|
||||
|
||||
const allRows = await listArchivePhotos(payload)
|
||||
const selected = selectRows(allRows, options)
|
||||
if (options.only && selected.length === 0) {
|
||||
die(`--only ${options.only.join(',')} não casou nenhuma foto do acervo (ids de linha).`)
|
||||
}
|
||||
if (options.only && selected.length !== options.only.length) {
|
||||
const found = new Set(selected.map((row) => row.id))
|
||||
console.log(
|
||||
`[archive:integrity] aviso: --only ignorou id(s) sem linha no acervo: ${options.only
|
||||
.filter((id) => !found.has(id))
|
||||
.join(', ')}`,
|
||||
)
|
||||
}
|
||||
console.log(
|
||||
`[archive:integrity] fila: ${selected.length} de ${allRows.length} foto(s) (concorrência ${options.concurrency})`,
|
||||
)
|
||||
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), 'archive-integrity-'))
|
||||
let report
|
||||
let scan
|
||||
let repair = null
|
||||
try {
|
||||
let processed = 0
|
||||
const results = await mapWithConcurrency(selected, options.concurrency, async (row) => {
|
||||
const result = await inspectRow({
|
||||
row,
|
||||
tempRoot,
|
||||
staticDir,
|
||||
inspect: inspectPrivateMediaObject,
|
||||
})
|
||||
processed += 1
|
||||
if (result.status !== 'ok') {
|
||||
const detail = result.status === 'missing' ? 'ausente' : `corrompida [${result.stage}]`
|
||||
console.log(
|
||||
`[archive:integrity] ${detail} ${result.id}: ${result.reason ?? 'objeto ausente'}`,
|
||||
)
|
||||
}
|
||||
if (processed % PROGRESS_EVERY === 0) {
|
||||
console.log(`[archive:integrity] … ${processed}/${selected.length}`)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
scan = summarizeArchiveIntegrityScan(results)
|
||||
|
||||
if (mode === 'repair') {
|
||||
const rowsById = new Map(selected.map((row) => [row.id, row]))
|
||||
// The summary owns the "removed never enters the repair" rule.
|
||||
const brokenIds = new Set([...scan.missing, ...scan.corrupted].map((item) => item.id))
|
||||
const broken = results.filter((result) => brokenIds.has(result.id))
|
||||
|
||||
const client = createFlickrClient({ apiKey: process.env.FLICKR_API_KEY })
|
||||
const withdraw = async ({ id, flickrId, filename, reason }) => {
|
||||
const result = await withdrawArchivePhotoFromPublic(payload, { id, flickrId })
|
||||
if (result.status === 'withdrawn') {
|
||||
return {
|
||||
id,
|
||||
flickrId,
|
||||
filename,
|
||||
action: 'unrecoverable',
|
||||
previousStatus: result.previousStatus,
|
||||
newStatus: 'draft',
|
||||
reason,
|
||||
}
|
||||
}
|
||||
// An already-draft photo was already out of the public: record the
|
||||
// unrecoverable verdict without pretending a status change happened.
|
||||
return { id, flickrId, filename, action: 'unrecoverable', reason }
|
||||
}
|
||||
|
||||
const repairResults = await mapWithConcurrency(broken, options.concurrency, async (entry) => {
|
||||
const row = rowsById.get(entry.id)
|
||||
const result = await repairArchivePhotoFromSource({
|
||||
row,
|
||||
tempRoot,
|
||||
staticDir,
|
||||
inspect: inspectPrivateMediaObject,
|
||||
payload,
|
||||
client,
|
||||
repair: repairArchivePhotoObject,
|
||||
download: downloadUrlToFile,
|
||||
withdraw,
|
||||
})
|
||||
const detail =
|
||||
result.action === 'recovered'
|
||||
? `recuperada (${result.sourceUrl ?? 'fonte'})`
|
||||
: `${result.action}${result.reason ? `: ${result.reason}` : ''}`
|
||||
console.log(`[archive:integrity] ${entry.id}: ${detail}`)
|
||||
return result
|
||||
})
|
||||
|
||||
repair = summarizeArchiveIntegrityRepair(repairResults)
|
||||
report = {
|
||||
runAt,
|
||||
mode,
|
||||
target: databaseTarget(),
|
||||
scan,
|
||||
repair,
|
||||
results,
|
||||
repairResults,
|
||||
durationMs: Date.now() - startedAt,
|
||||
}
|
||||
} else {
|
||||
report = {
|
||||
runAt,
|
||||
mode,
|
||||
target: databaseTarget(),
|
||||
scan,
|
||||
results,
|
||||
durationMs: Date.now() - startedAt,
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await rm(tempRoot, { recursive: true, force: true }).catch(() => undefined)
|
||||
}
|
||||
|
||||
const reportPath = await writeReport(options, report, mode === 'repair' ? '-repair' : '')
|
||||
for (const line of formatArchiveIntegrityScanReport(report)) console.log(line)
|
||||
if (mode === 'repair') {
|
||||
for (const line of formatArchiveIntegrityRepairReport(report)) console.log(line)
|
||||
console.log(
|
||||
'\n[archive:integrity] se houve saída do público, buste a tag: curl -X POST "https://jorgesolla1313.com.br/api/revalidate?tag=archivePhotos" -H "x-revalidate-secret: $REVALIDATE_SECRET"',
|
||||
)
|
||||
console.log(
|
||||
'[archive:integrity] lembre de rodar `pnpm faces:index` — a foto recuperada cujo índice falhou é reprocessada sozinha.',
|
||||
)
|
||||
}
|
||||
console.log(`[archive:integrity] recibo JSON: ${reportPath}`)
|
||||
|
||||
if (mode === 'verify' && scan.pending > 0) {
|
||||
die(
|
||||
`${scan.pending} foto(s) com mídia quebrada pública — o acervo ainda não está íntegro; rode o --apply para recuperar.`,
|
||||
)
|
||||
}
|
||||
if (mode === 'repair' && repair.failed.length > 0) {
|
||||
die(`${repair.failed.length} foto(s) falharam no reparo — falhas listadas no recibo.`)
|
||||
}
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
die(error?.message || String(error))
|
||||
})
|
||||
275
scripts/lib/archiveIntegrityPlan.mjs
Normal file
275
scripts/lib/archiveIntegrityPlan.mjs
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
/**
|
||||
* C246 — pure planning/reporting of the photo-archive integrity sweep: the argv
|
||||
* parser, the bounded-concurrency map, the scan/repair summaries and the human
|
||||
* lines + JSON receipt. No I/O of its own: the inspector, the Flickr client and
|
||||
* the Payload writes are injected, so the unit spec drives everything with fakes.
|
||||
*/
|
||||
|
||||
import { formatArchiveBytes } from './flickrPlan.mjs'
|
||||
|
||||
const USAGE =
|
||||
'uso: `pnpm archive:integrity [--apply] [--only <ids>] [--limit <n>] [--concurrency <n>] [--out <dir>]`'
|
||||
|
||||
export const ARCHIVE_INTEGRITY_DEFAULT_OUT_DIR = 'data/archive'
|
||||
export const ARCHIVE_INTEGRITY_DEFAULT_CONCURRENCY = 3
|
||||
|
||||
export const archiveIntegrityReportStamp = (runAt) => String(runAt).replace(/[:.]/g, '-')
|
||||
|
||||
/**
|
||||
* `--only 80,83` → `[80, 83]` (photo ids, the row primary key). Exact digits
|
||||
* only: a typo must refuse the run, never silently widen or empty the selection.
|
||||
*
|
||||
* @param {string} value
|
||||
* @returns {number[]}
|
||||
*/
|
||||
export const parseArchiveIntegrityOnly = (value) => {
|
||||
const text = String(value ?? '').trim()
|
||||
if (text === '')
|
||||
throw new Error(`--only vazio — informe ao menos um id (ex.: --only 80,83) — ${USAGE}.`)
|
||||
const ids = []
|
||||
for (const part of text.split(',')) {
|
||||
const item = part.trim()
|
||||
if (!/^\d+$/.test(item)) {
|
||||
throw new Error(
|
||||
`--only espera ids numéricos separados por vírgula (recebi "${value}") — ${USAGE}.`,
|
||||
)
|
||||
}
|
||||
const id = Number(item)
|
||||
if (!Number.isSafeInteger(id) || id < 1) {
|
||||
throw new Error(`--only recebeu um id fora do intervalo: "${item}" — ${USAGE}.`)
|
||||
}
|
||||
if (!ids.includes(id)) ids.push(id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
/**
|
||||
* `pnpm archive:integrity` argv parser — pure, so the unit spec pins it (the
|
||||
* CLI owns the exit and the help text). The default mode is the read-only
|
||||
* sweep; `--apply` adds the repair pass. Errors carry the usage line.
|
||||
*
|
||||
* @param {string[]} [argv]
|
||||
* @returns {{ apply: boolean, only: number[] | null, limit: number | null, concurrency: number, out: string, help: boolean }}
|
||||
*/
|
||||
export const parseArchiveIntegrityCliArgs = (argv = process.argv.slice(2)) => {
|
||||
const options = {
|
||||
apply: false,
|
||||
only: null,
|
||||
limit: null,
|
||||
concurrency: ARCHIVE_INTEGRITY_DEFAULT_CONCURRENCY,
|
||||
out: ARCHIVE_INTEGRITY_DEFAULT_OUT_DIR,
|
||||
help: false,
|
||||
}
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index]
|
||||
const value = () => {
|
||||
const next = argv[++index]
|
||||
if (next === undefined || next.startsWith('--')) {
|
||||
throw new Error(`faltou valor para ${arg} — ${USAGE}.`)
|
||||
}
|
||||
return next
|
||||
}
|
||||
if (arg === '--help' || arg === '-h') options.help = true
|
||||
else if (arg === '--apply') options.apply = true
|
||||
else if (arg === '--only') options.only = parseArchiveIntegrityOnly(value())
|
||||
else if (arg === '--limit') options.limit = Number(value())
|
||||
else if (arg === '--concurrency') options.concurrency = Number(value())
|
||||
else if (arg === '--out') options.out = value()
|
||||
else throw new Error(`argumento desconhecido: ${arg} — ${USAGE}.`)
|
||||
}
|
||||
|
||||
if (options.help) return options
|
||||
if (options.limit !== null && (!Number.isInteger(options.limit) || options.limit < 1)) {
|
||||
throw new Error(`--limit deve ser >= 1 — ${USAGE}.`)
|
||||
}
|
||||
if (!Number.isInteger(options.concurrency) || options.concurrency < 1) {
|
||||
throw new Error(`--concurrency deve ser >= 1 — ${USAGE}.`)
|
||||
}
|
||||
if (options.out.split(/[\\/]/).includes('..')) {
|
||||
throw new Error('--out não pode escapar do diretório do repo (sem "..").')
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounded-concurrency map that preserves input order. The sweep downloads and
|
||||
* decodes 6k+ photos, so unbounded `Promise.all` would be reckless; the pool
|
||||
* keeps at most `concurrency` workers alive and returns the results in the
|
||||
* same order as the input (receipts stay deterministic).
|
||||
*
|
||||
* @template T, R
|
||||
* @param {readonly T[]} items
|
||||
* @param {number} concurrency
|
||||
* @param {(item: T, index: number) => Promise<R>} worker
|
||||
* @returns {Promise<R[]>}
|
||||
*/
|
||||
export const mapWithConcurrency = async (items, concurrency, worker) => {
|
||||
const results = new Array(items.length)
|
||||
const size = Math.max(1, Math.min(Math.trunc(Number(concurrency)) || 1, items.length))
|
||||
let next = 0
|
||||
|
||||
const runner = async () => {
|
||||
for (;;) {
|
||||
const index = next
|
||||
next += 1
|
||||
if (index >= items.length) return
|
||||
results[index] = await worker(items[index], index)
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(Array.from({ length: size }, runner))
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates one sweep: what was read whole, what is missing/corrupt, and the
|
||||
* rows kept out of the repair (`removed` is a takedown, C242). `pending` counts
|
||||
* ONLY the broken `approved` rows — the public gap the read-only sweep fails on.
|
||||
* A broken `draft` (already out of the public, e.g. withdrawn by a previous
|
||||
* repair) and a broken `removed` are recorded but never block: the convergence
|
||||
* after an `--apply` must be reachable.
|
||||
*
|
||||
* @param {Array<{ id: number, flickrId: string, filename: string | null, publicationStatus: string, status: 'ok' | 'missing' | 'corrupt', stage?: string | null, reason?: string | null, bytes?: number | null }>} results
|
||||
*/
|
||||
export const summarizeArchiveIntegrityScan = (results) => {
|
||||
const missing = []
|
||||
const corrupted = []
|
||||
const draftBroken = []
|
||||
const removedBroken = []
|
||||
let ok = 0
|
||||
let removedScanned = 0
|
||||
let pending = 0
|
||||
let bytes = 0
|
||||
|
||||
for (const result of results) {
|
||||
bytes += Number(result.bytes) || 0
|
||||
const publicationStatus = result.publicationStatus
|
||||
const removed = publicationStatus === 'removed'
|
||||
if (removed) removedScanned += 1
|
||||
|
||||
if (result.status === 'ok') {
|
||||
if (!removed) ok += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const entry = {
|
||||
id: result.id,
|
||||
flickrId: result.flickrId,
|
||||
filename: result.filename ?? null,
|
||||
publicationStatus,
|
||||
stage: result.stage ?? null,
|
||||
reason: result.reason ?? null,
|
||||
}
|
||||
if (removed) {
|
||||
removedBroken.push({ ...entry, status: result.status })
|
||||
continue
|
||||
}
|
||||
if (result.status === 'missing') missing.push(entry)
|
||||
else corrupted.push(entry)
|
||||
if (publicationStatus === 'approved') pending += 1
|
||||
else draftBroken.push({ ...entry, status: result.status })
|
||||
}
|
||||
|
||||
return {
|
||||
scanned: results.length,
|
||||
ok,
|
||||
missing,
|
||||
corrupted,
|
||||
draftBroken,
|
||||
removedScanned,
|
||||
removedBroken,
|
||||
pending,
|
||||
bytes,
|
||||
}
|
||||
}
|
||||
|
||||
const PUBLICATION_LABEL = { approved: 'aprovada', draft: 'rascunho', removed: 'removida' }
|
||||
|
||||
/**
|
||||
* Human lines for the sweep; the JSON file keeps the full report. Every broken
|
||||
* row is named with id/arquivo/estado e o motivo.
|
||||
*
|
||||
* @param {Record<string, any>} report
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export const formatArchiveIntegrityScanReport = (report) => {
|
||||
const label = '[archive:integrity]'
|
||||
const scan = report.scan
|
||||
const describe = (item) =>
|
||||
`${item.id} (${item.filename ?? 'sem arquivo'}, ${PUBLICATION_LABEL[item.publicationStatus] ?? item.publicationStatus})`
|
||||
const lines = [
|
||||
`${label} varredura: ${scan.scanned} foto(s) · íntegras: ${scan.ok} · ausentes: ${scan.missing.length} · corrompidas: ${scan.corrupted.length} · quebradas fora do público: ${scan.draftBroken.length}`,
|
||||
`${label} pendência pública (aprovadas quebradas): ${scan.pending}`,
|
||||
`${label} removidas (takedown, não reparadas): ${scan.removedScanned} · com objeto quebrado: ${scan.removedBroken.length}`,
|
||||
`${label} objeto lido: ${formatArchiveBytes(scan.bytes)} em ${(report.durationMs / 1000).toFixed(1)}s`,
|
||||
]
|
||||
for (const item of scan.missing) {
|
||||
lines.push(` - ausente ${describe(item)}`)
|
||||
}
|
||||
for (const item of scan.corrupted) {
|
||||
lines.push(
|
||||
` - corrompida ${describe(item)}${item.stage ? ` [${item.stage}]` : ''}: ${item.reason ?? 'erro'}`,
|
||||
)
|
||||
}
|
||||
for (const item of scan.removedBroken) {
|
||||
lines.push(` - removida quebrada ${describe(item)} [${item.status}]: ${item.reason ?? 'erro'}`)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates one repair pass over the broken non-removed rows: what came back
|
||||
* (`recovered`), what left the public with a record (`unrecoverable`) and what
|
||||
* failed and needs another run.
|
||||
*
|
||||
* @param {Array<{ id: number, flickrId: string, filename: string | null, action: 'recovered' | 'unrecoverable' | 'failed', sourceUrl?: string | null, sha256?: string | null, bytes?: number | null, previousStatus?: string | null, newStatus?: string | null, stage?: string | null, reason?: string | null }>} results
|
||||
*/
|
||||
export const summarizeArchiveIntegrityRepair = (results) => {
|
||||
const recovered = []
|
||||
const unrecoverable = []
|
||||
const failed = []
|
||||
|
||||
for (const result of results) {
|
||||
if (result.action === 'recovered') recovered.push(result)
|
||||
else if (result.action === 'unrecoverable') unrecoverable.push(result)
|
||||
else failed.push(result)
|
||||
}
|
||||
|
||||
return { recovered, unrecoverable, failed }
|
||||
}
|
||||
|
||||
/**
|
||||
* Human lines for the repair pass. Recovered photos carry provenance (source
|
||||
* URL, bytes, sha256 prefix); unrecoverable ones carry the record of the exit
|
||||
* from the public; failures name their stage.
|
||||
*
|
||||
* @param {Record<string, any>} report
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export const formatArchiveIntegrityRepairReport = (report) => {
|
||||
const label = '[archive:integrity]'
|
||||
const repair = report.repair
|
||||
const lines = [
|
||||
`${label} reparo: recuperadas: ${repair.recovered.length} · irrecuperáveis (fora do público): ${repair.unrecoverable.length} · falhas: ${repair.failed.length}`,
|
||||
]
|
||||
for (const item of repair.recovered) {
|
||||
lines.push(
|
||||
` - recuperada ${item.id} (${item.filename ?? 'sem arquivo'}) ${formatArchiveBytes(item.bytes)}` +
|
||||
`${item.sha256 ? ` sha256:${String(item.sha256).slice(0, 12)}…` : ''}` +
|
||||
`${item.sourceUrl ? ` ← ${item.sourceUrl}` : ''}`,
|
||||
)
|
||||
}
|
||||
for (const item of repair.unrecoverable) {
|
||||
lines.push(
|
||||
` - irrecuperável ${item.id} (${item.filename ?? 'sem arquivo'})` +
|
||||
`${item.previousStatus ? ` ${item.previousStatus} → ${item.newStatus ?? 'draft'}` : ''}: ${item.reason ?? 'fonte indisponível'}`,
|
||||
)
|
||||
}
|
||||
for (const item of repair.failed) {
|
||||
lines.push(
|
||||
` - falha ${item.id} (${item.filename ?? 'sem arquivo'})${item.stage ? ` [${item.stage}]` : ''}: ${item.reason ?? 'erro'}`,
|
||||
)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
146
scripts/lib/archiveIntegrityRepair.mjs
Normal file
146
scripts/lib/archiveIntegrityRepair.mjs
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
/**
|
||||
* C246 — one repair decision of the archive integrity sweep: resolve the
|
||||
* Flickr source by `flickrId`, download it, prove it decodes, replace the
|
||||
* stored object through the collection's own write path and verify the result.
|
||||
* Every side effect is injected (Flickr client, download, inspector, Payload
|
||||
* repair, withdraw, sha256), so the unit spec drives every branch with fakes and
|
||||
* the CLI stays a thin composition. No writes happen here beyond the caller's
|
||||
* functions.
|
||||
*
|
||||
* Classification: a gone/refused source (deleted/private Flickr photo) or a
|
||||
* source that does not decode is `unrecoverable` (withdraw); a transient
|
||||
* source/download failure is `failed` (retried on the next run); a write or
|
||||
* verification failure is `failed` too — the object was not proven good.
|
||||
*/
|
||||
import { mkdir, readFile, rm, stat } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import sharp from 'sharp'
|
||||
|
||||
import { archivePhotoStorageFilename } from '../../src/lib/archivePhoto.ts'
|
||||
|
||||
import { sha256Hex } from './cli.mjs'
|
||||
import { FLICKR_USER_AGENT, isFlickrSourceGone } from './flickrApi.mjs'
|
||||
|
||||
/** A photo original is MBs; the guard only catches a stalled/runaway server. */
|
||||
const PHOTO_DOWNLOAD_MAX_BYTES = 512 * 1024 * 1024
|
||||
const PHOTO_DOWNLOAD_TIMEOUT_MS = 2 * 60_000
|
||||
/** Long edge of the decode proof; the same size the face index feeds the engine. */
|
||||
const DECODE_MAX_EDGE = 1024
|
||||
|
||||
const messageOf = (error) => (error instanceof Error ? error.message : String(error))
|
||||
|
||||
/**
|
||||
* Every side effect arrives injected; loose types are deliberate (the unit spec
|
||||
* drives them with `vi.fn` fakes and the CLI with the real owners).
|
||||
*
|
||||
* @typedef {object} ArchiveIntegrityRepairDeps
|
||||
* @property {{ id: number, flickrId: string | number, filename?: string | null }} row
|
||||
* @property {string} tempRoot
|
||||
* @property {string} staticDir
|
||||
* @property {(args: { media: { filename: string | null }, staticDir: string, destinationPath: string }) => Promise<{ status: string, stage?: string | null, reason?: string | null, bytes?: number | null }>} inspect
|
||||
* @property {any} payload
|
||||
* @property {{ getLargestSize: (flickrId: string) => Promise<{ url?: string | null } | null> }} client
|
||||
* @property {(payload: any, args: { id: number, filePath: string }) => Promise<{ status: string, filename?: string | null, error?: string | null }>} repair
|
||||
* @property {(args: { url: string, destinationPath: string, headers: Record<string, string>, timeoutMs: number, maxBytes: number }) => Promise<void>} download
|
||||
* @property {(entry: { id: number, flickrId: string, filename: string | null, reason: string }) => Promise<any>} withdraw
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {ArchiveIntegrityRepairDeps} deps
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
export const repairArchivePhotoFromSource = async ({
|
||||
row,
|
||||
tempRoot,
|
||||
staticDir,
|
||||
inspect,
|
||||
payload,
|
||||
client,
|
||||
repair,
|
||||
download,
|
||||
withdraw,
|
||||
}) => {
|
||||
const flickrId = String(row.flickrId)
|
||||
const base = { id: row.id, flickrId, filename: row.filename ?? null }
|
||||
const dir = join(tempRoot, `repair-${row.id}`)
|
||||
await mkdir(dir, { recursive: true })
|
||||
|
||||
try {
|
||||
let source
|
||||
try {
|
||||
source = await client.getLargestSize(flickrId)
|
||||
} catch (error) {
|
||||
if (isFlickrSourceGone(error)) {
|
||||
return await withdraw({
|
||||
...base,
|
||||
reason: `o Flickr recusou a fonte (${messageOf(error)})`,
|
||||
})
|
||||
}
|
||||
return { ...base, action: 'failed', stage: 'source', reason: messageOf(error) }
|
||||
}
|
||||
if (!source?.url) {
|
||||
return await withdraw({
|
||||
...base,
|
||||
reason: 'o Flickr não devolveu nenhum tamanho utilizável para a foto',
|
||||
})
|
||||
}
|
||||
|
||||
const filename = row.filename ?? archivePhotoStorageFilename(flickrId, source.url)
|
||||
const filePath = join(dir, filename)
|
||||
try {
|
||||
await download({
|
||||
url: source.url,
|
||||
destinationPath: filePath,
|
||||
headers: { 'User-Agent': FLICKR_USER_AGENT },
|
||||
timeoutMs: PHOTO_DOWNLOAD_TIMEOUT_MS,
|
||||
maxBytes: PHOTO_DOWNLOAD_MAX_BYTES,
|
||||
})
|
||||
} catch (error) {
|
||||
return { ...base, action: 'failed', stage: 'download', reason: messageOf(error) }
|
||||
}
|
||||
|
||||
let bytes = 0
|
||||
try {
|
||||
bytes = (await stat(filePath)).size
|
||||
await sharp(filePath, { failOn: 'error' })
|
||||
.rotate()
|
||||
.resize({ width: DECODE_MAX_EDGE, withoutEnlargement: true })
|
||||
.toBuffer()
|
||||
} catch (error) {
|
||||
return await withdraw({
|
||||
...base,
|
||||
reason: `a fonte do Flickr não decodifica (${messageOf(error)})`,
|
||||
})
|
||||
}
|
||||
|
||||
const repaired = await repair(payload, { id: row.id, filePath })
|
||||
if (repaired.status === 'failed') {
|
||||
return { ...base, action: 'failed', stage: 'repair', reason: repaired.error }
|
||||
}
|
||||
|
||||
const repairedFilename = repaired.filename ?? filename
|
||||
const verification = await inspect({
|
||||
media: { filename: repairedFilename },
|
||||
staticDir,
|
||||
destinationPath: join(dir, `verify-${repairedFilename}`),
|
||||
})
|
||||
if (verification.status !== 'ok') {
|
||||
const reason =
|
||||
verification.status === 'missing'
|
||||
? 'o objeto reparado não foi encontrado depois da escrita'
|
||||
: `${verification.stage}: ${verification.reason}`
|
||||
return { ...base, filename: repairedFilename, action: 'failed', stage: 'verify', reason }
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
filename: repairedFilename,
|
||||
action: 'recovered',
|
||||
sourceUrl: source.url,
|
||||
sha256: sha256Hex(await readFile(filePath)),
|
||||
bytes: verification.bytes || bytes,
|
||||
}
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true }).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
|
@ -24,7 +24,7 @@ const FLICKR_TIMEOUT_MS = 30_000
|
|||
const FLICKR_RETRY_ATTEMPTS = 4
|
||||
const FLICKR_RETRY_BASE_DELAY_MS = 1_000
|
||||
|
||||
class FlickrApiError extends Error {
|
||||
export class FlickrApiError extends Error {
|
||||
/**
|
||||
* @param {string} message
|
||||
* @param {{ code?: string | number | null, status?: number | null }} [options]
|
||||
|
|
@ -45,6 +45,21 @@ const isRetryable = (error) => {
|
|||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* C246 — a terminal refusal that says the SOURCE is gone: the Flickr
|
||||
* "photo not found" `stat=fail` codes (1/2) or an HTTP 404. Auth/service
|
||||
* failures (`stat=fail` 100/105, HTTP 401/403) and transient errors (429/5xx,
|
||||
* network) are NOT a source verdict — the integrity repair keeps them as
|
||||
* retryable instead of withdrawing photos because of a bad API key or an
|
||||
* outage.
|
||||
*/
|
||||
const FLICKR_SOURCE_GONE_CODES = new Set([1, 2])
|
||||
|
||||
export const isFlickrSourceGone = (error) =>
|
||||
error instanceof FlickrApiError &&
|
||||
(error.status === 404 ||
|
||||
(error.status === null && FLICKR_SOURCE_GONE_CODES.has(Number(error.code))))
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* apiKey?: string,
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ export const SCRIPTS_SPEC_PINNED = [
|
|||
'scripts/lib/agent-session.mjs',
|
||||
'scripts/lib/ansi.mjs',
|
||||
'scripts/lib/archiveCatalogPlan.mjs',
|
||||
'scripts/lib/archiveIntegrityPlan.mjs',
|
||||
'scripts/lib/archiveIntegrityRepair.mjs',
|
||||
'scripts/lib/archivePublishPlan.mjs',
|
||||
'scripts/lib/archiveVisionApi.mjs',
|
||||
'scripts/lib/auto-unblock.mjs',
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
/**
|
||||
* C193/C199 — pure response rules of a private media artifact: the safe content
|
||||
* type, the content disposition and the range/private headers. No I/O and no
|
||||
* `server-only`: the streaming utility and the unit tests share this module.
|
||||
* type, the content disposition, the range/private headers and the classification
|
||||
* of an absent object. No I/O and no `server-only`: the streaming utility and the
|
||||
* unit tests share this module.
|
||||
*
|
||||
* The owner serves every private upload collection (`reelMedia`, `recordingMedia`)
|
||||
* through the authenticated `/campanha` routes; the public `media` contract
|
||||
|
|
@ -45,6 +46,31 @@ export type PrivateMediaRange = {
|
|||
headers: Record<string, string>
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the read error means the OBJECT is absent in whichever store served
|
||||
* it: S3 `NoSuchKey`/`NotFound` (or a 404 in the SDK metadata) or the local disk
|
||||
* `ENOENT`. Everything else — a checksum mismatch included — is a real read
|
||||
* failure of an object that exists (C246 classifies it as corrupt, never as
|
||||
* missing). Pure so the response paths, the integrity sweep and the unit spec
|
||||
* share one classification.
|
||||
*/
|
||||
export const isPrivateMediaMissingError = (error: unknown): boolean => {
|
||||
if ((error as { code?: unknown } | null)?.code === 'ENOENT') return true
|
||||
if (!error || typeof error !== 'object') return false
|
||||
const name = 'name' in error ? String(error.name) : ''
|
||||
const errorCode = 'Code' in error ? String(error.Code) : ''
|
||||
const metadata =
|
||||
'$metadata' in error && error.$metadata && typeof error.$metadata === 'object'
|
||||
? (error.$metadata as { httpStatusCode?: number })
|
||||
: null
|
||||
return (
|
||||
name === 'NoSuchKey' ||
|
||||
name === 'NotFound' ||
|
||||
errorCode === 'NoSuchKey' ||
|
||||
metadata?.httpStatusCode === 404
|
||||
)
|
||||
}
|
||||
|
||||
const isInlineSafe = (mimeType: unknown): mimeType is string =>
|
||||
typeof mimeType === 'string' &&
|
||||
(PRIVATE_MEDIA_INLINE_MIME_TYPES as readonly string[]).includes(mimeType)
|
||||
|
|
|
|||
|
|
@ -16,7 +16,13 @@ import { withPayloadTransaction } from '@/utilities/payloadTransaction'
|
|||
* `flickrId`, the transactional upload of the original and the row, and the
|
||||
* honest result the CLI turns into the receipt. The network side (listing,
|
||||
* metadata, download) belongs to `pnpm flickr:import`; nothing here touches
|
||||
* the Flickr API, publishes or curates.
|
||||
* the Flickr API or curates.
|
||||
*
|
||||
* C246 adds the maintenance writes of the integrity sweep: the object replace
|
||||
* of one existing row (`repairArchivePhotoObject` — same key, no field edits)
|
||||
* and the unrecoverable exit from the public (`withdrawArchivePhotoFromPublic`
|
||||
* — `approved → draft`; `removed` stays untouched). Both are trusted-actor
|
||||
* writes with no session, like the ingestion.
|
||||
*/
|
||||
|
||||
export type ArchivePhotoExisting = {
|
||||
|
|
@ -182,3 +188,92 @@ export const updateArchivePhotoExif = async (
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type ArchivePhotoRepairResult =
|
||||
| { status: 'repaired'; id: number; filename: string | null; filesize: number | null }
|
||||
| { status: 'failed'; id: number; error: string }
|
||||
|
||||
/**
|
||||
* C246 — replaces the STORED OBJECT of one existing row with the file at
|
||||
* `filePath`, through the collection's own upload write path (`payload.update` +
|
||||
* `filePath` + `overwriteExistingFiles`). The update carries NO data fields: the
|
||||
* curation, the metadata and the `publicationStatus` are never touched; with the
|
||||
* caller naming the temp file after `row.filename`, the stored key does not
|
||||
* change and an `approved` photo returns to serving on the next request. Every
|
||||
* failure is returned, never thrown — the caller owns the receipt.
|
||||
* Same documented CLI bypass as the ingestion (trusted actor, no session).
|
||||
*/
|
||||
export const repairArchivePhotoObject = async (
|
||||
payload: Payload,
|
||||
{ id, filePath }: { id: number; filePath: string },
|
||||
): Promise<ArchivePhotoRepairResult> => {
|
||||
try {
|
||||
const doc = await payload.update({
|
||||
collection: ARCHIVE_PHOTO_SLUG,
|
||||
id,
|
||||
data: {},
|
||||
filePath,
|
||||
overwriteExistingFiles: true,
|
||||
// Intentional bypass: the integrity CLI is a trusted actor with no session.
|
||||
overrideAccess: true,
|
||||
})
|
||||
return {
|
||||
status: 'repaired',
|
||||
id,
|
||||
filename: doc.filename ?? null,
|
||||
filesize: doc.filesize ?? null,
|
||||
}
|
||||
} catch (error) {
|
||||
return { status: 'failed', id, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
export type ArchivePhotoWithdrawalResult =
|
||||
| { status: 'withdrawn'; id: number; flickrId: string; previousStatus: string }
|
||||
| { status: 'skipped'; id: number; flickrId: string; reason: 'removed' | 'already-draft' }
|
||||
| { status: 'failed'; id: number; flickrId: string; error: string }
|
||||
|
||||
/**
|
||||
* C246 — takes one unrecoverable photo out of the public: `approved → draft`
|
||||
* (the intent recommendation; `removed` is a takedown request from a person and
|
||||
* stays untouched). The removal-channel guard only covers the transition INTO
|
||||
* `approved`, so the downgrade never needs the channel; the collection hooks
|
||||
* purge the face descriptors and revalidate the public listing on the way out.
|
||||
* Every failure is returned, never thrown — the caller owns the receipt.
|
||||
*/
|
||||
export const withdrawArchivePhotoFromPublic = async (
|
||||
payload: Payload,
|
||||
{ id, flickrId }: { id: number; flickrId: string },
|
||||
): Promise<ArchivePhotoWithdrawalResult> => {
|
||||
try {
|
||||
const row = await payload.findByID({
|
||||
collection: ARCHIVE_PHOTO_SLUG,
|
||||
id,
|
||||
depth: 0,
|
||||
// Intentional bypass: the integrity CLI is a trusted actor with no session.
|
||||
overrideAccess: true,
|
||||
})
|
||||
const previousStatus = row.publicationStatus
|
||||
if (previousStatus === 'removed') {
|
||||
return { status: 'skipped', id, flickrId, reason: 'removed' }
|
||||
}
|
||||
if (previousStatus === 'draft') {
|
||||
return { status: 'skipped', id, flickrId, reason: 'already-draft' }
|
||||
}
|
||||
await payload.update({
|
||||
collection: ARCHIVE_PHOTO_SLUG,
|
||||
id,
|
||||
data: { publicationStatus: 'draft' },
|
||||
// Intentional bypass: same trusted actor.
|
||||
overrideAccess: true,
|
||||
})
|
||||
return { status: 'withdrawn', id, flickrId, previousStatus }
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'failed',
|
||||
id,
|
||||
flickrId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,10 +13,12 @@ import { Readable } from 'stream'
|
|||
import { pipeline } from 'stream/promises'
|
||||
|
||||
import {
|
||||
isPrivateMediaMissingError,
|
||||
PRIVATE_MEDIA_CACHE_CONTROL,
|
||||
privateMediaHeaders,
|
||||
type PrivateMediaRange,
|
||||
} from '@/lib/privateMedia'
|
||||
import { messageOf } from '@/utilities/media/ffmpeg'
|
||||
import { resolveS3StorageEnv } from '@/utilities/mediaStorage'
|
||||
|
||||
/**
|
||||
|
|
@ -77,21 +79,8 @@ const privateMediaStorage = (): PrivateMediaStorage | null => {
|
|||
return cachedStorage
|
||||
}
|
||||
|
||||
const isMissingObject = (error: unknown): boolean => {
|
||||
if (!error || typeof error !== 'object') return false
|
||||
const name = 'name' in error ? String(error.name) : ''
|
||||
const code = 'Code' in error ? String(error.Code) : ''
|
||||
const metadata =
|
||||
'$metadata' in error && error.$metadata && typeof error.$metadata === 'object'
|
||||
? (error.$metadata as { httpStatusCode?: number })
|
||||
: null
|
||||
return (
|
||||
name === 'NoSuchKey' ||
|
||||
name === 'NotFound' ||
|
||||
code === 'NoSuchKey' ||
|
||||
metadata?.httpStatusCode === 404
|
||||
)
|
||||
}
|
||||
/** Long edge of the decode probe — the same size the face index feeds the engine. */
|
||||
const INSPECTION_DECODE_MAX_EDGE = 1024
|
||||
|
||||
/** Resolves the on-disk path of an artifact, refusing any traversal attempt. */
|
||||
const resolveLocalPath = (staticDir: string, filename: string): string => {
|
||||
|
|
@ -223,7 +212,7 @@ export const buildPrivateMediaResponse = async ({
|
|||
}),
|
||||
})
|
||||
} catch (error) {
|
||||
if (isMissingObject(error) || (error as NodeJS.ErrnoException)?.code === 'ENOENT') {
|
||||
if (isPrivateMediaMissingError(error)) {
|
||||
return notFound()
|
||||
}
|
||||
throw error
|
||||
|
|
@ -299,7 +288,7 @@ export const buildPrivateMediaImageResponse = async ({
|
|||
}),
|
||||
})
|
||||
} catch (error) {
|
||||
if (isMissingObject(error) || (error as NodeJS.ErrnoException)?.code === 'ENOENT') {
|
||||
if (isPrivateMediaMissingError(error)) {
|
||||
return notFound()
|
||||
}
|
||||
throw error
|
||||
|
|
@ -342,3 +331,68 @@ export const downloadPrivateMediaToFile = async ({
|
|||
createWriteStream(destinationPath),
|
||||
)
|
||||
}
|
||||
|
||||
export type PrivateMediaObjectInspection =
|
||||
| { status: 'ok'; bytes: number }
|
||||
| { status: 'missing' }
|
||||
| { status: 'corrupt'; stage: 'download' | 'decode'; reason: string; bytes: number }
|
||||
|
||||
/**
|
||||
* C246 — read-only integrity probe of one stored object: it downloads the
|
||||
* artifact through the SAME path the serving routes and the face index use
|
||||
* (checksum validation of the S3 SDK included) and then forces a real decode of
|
||||
* the image with sharp (the same `failOn: 'error'` decode the public grid runs).
|
||||
* A missing object is classified as `missing`; every other read failure (a
|
||||
* checksum mismatch, for one) is `corrupt` at the `download` stage; a file that
|
||||
* downloads but does not decode is `corrupt` at the `decode` stage. Never
|
||||
* throws: the caller owns the receipt.
|
||||
*
|
||||
* `destinationPath` is a caller-owned temp path (the sweep removes it per
|
||||
* photo) — nothing here writes to the archive.
|
||||
*/
|
||||
export const inspectPrivateMediaObject = async ({
|
||||
media,
|
||||
staticDir,
|
||||
destinationPath,
|
||||
}: {
|
||||
media: PrivateMediaFile
|
||||
staticDir: string
|
||||
destinationPath: string
|
||||
}): Promise<PrivateMediaObjectInspection> => {
|
||||
if (!media.filename) return { status: 'missing' }
|
||||
|
||||
try {
|
||||
await downloadPrivateMediaToFile({ media, staticDir, destinationPath })
|
||||
} catch (error) {
|
||||
if (isPrivateMediaMissingError(error)) return { status: 'missing' }
|
||||
return {
|
||||
status: 'corrupt',
|
||||
stage: 'download',
|
||||
reason: messageOf(error, 'erro desconhecido'),
|
||||
bytes: 0,
|
||||
}
|
||||
}
|
||||
|
||||
let bytes = 0
|
||||
try {
|
||||
bytes = (await stat(destinationPath)).size
|
||||
} catch {
|
||||
// The decode below still classifies the failure; bytes stay 0.
|
||||
}
|
||||
|
||||
try {
|
||||
await sharp(destinationPath, { failOn: 'error' })
|
||||
.rotate()
|
||||
.resize({ width: INSPECTION_DECODE_MAX_EDGE, withoutEnlargement: true })
|
||||
.toBuffer()
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'corrupt',
|
||||
stage: 'decode',
|
||||
reason: messageOf(error, 'erro desconhecido'),
|
||||
bytes,
|
||||
}
|
||||
}
|
||||
|
||||
return { status: 'ok', bytes }
|
||||
}
|
||||
|
|
|
|||
337
tests/int/archiveIntegrity.int.spec.ts
Normal file
337
tests/int/archiveIntegrity.int.spec.ts
Normal file
|
|
@ -0,0 +1,337 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { Payload } from 'payload'
|
||||
import { getPayload } from 'payload'
|
||||
import sharp from 'sharp'
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('next/cache', () => ({
|
||||
revalidatePath: () => undefined,
|
||||
revalidateTag: vi.fn(),
|
||||
unstable_cache: (fn: unknown) => fn,
|
||||
}))
|
||||
|
||||
import { ARCHIVE_PHOTO_SLUG, type ArchivePhotoImport } from '@/lib/archivePhoto'
|
||||
import config from '@/payload.config'
|
||||
import {
|
||||
ingestArchivePhoto,
|
||||
repairArchivePhotoObject,
|
||||
withdrawArchivePhotoFromPublic,
|
||||
} from '@/utilities/flickr/archivePhotoIngest'
|
||||
import {
|
||||
inspectPrivateMediaObject,
|
||||
resolvePrivateMediaStaticDir,
|
||||
} from '@/utilities/privateMedia/privateMediaResponse'
|
||||
|
||||
import { ARCHIVE_PHOTO_JPEG_BYTES } from '../helpers/archivePhotoFixture'
|
||||
import { PHOTO_ALBUM_LEASE_KEY, withExclusiveTestDatabaseLease } from '../helpers/testDatabaseLease'
|
||||
|
||||
// C246 — the integrity boundary over the real Payload `teqo_test`: the probe
|
||||
// classification (ok/missing/corrupt through the serving path), the object
|
||||
// repair on the same key (curation/status untouched), and the unrecoverable
|
||||
// exit from the public (approved → draft, descriptors purged, removed intact).
|
||||
|
||||
let payload: Payload
|
||||
const createdFlickrIds = new Set<string>()
|
||||
const tempDirs: string[] = []
|
||||
|
||||
const CHANNEL = 'https://jorgesolla.example.org/remocao'
|
||||
|
||||
const record = (
|
||||
flickrId: string,
|
||||
overrides: Partial<ArchivePhotoImport> = {},
|
||||
): ArchivePhotoImport => ({
|
||||
flickrId,
|
||||
sourceUrl: `https://www.flickr.com/photos/depjorgesolla/${flickrId}/`,
|
||||
owner: '12345678@N00',
|
||||
license: '0',
|
||||
title: `Foto ${flickrId}`,
|
||||
description: 'Registro do mandato',
|
||||
tags: [],
|
||||
takenAt: '2025-03-14 10:20:30',
|
||||
postedAt: '2025-03-15T10:00:00.000Z',
|
||||
albums: [],
|
||||
geo: null,
|
||||
exif: [],
|
||||
originalUrl: `https://live.staticflickr.com/65535/${flickrId}_abcdef_o.jpg`,
|
||||
originalKind: 'original',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const tempDir = async (prefix: string): Promise<string> => {
|
||||
const dir = await mkdtemp(join(tmpdir(), prefix))
|
||||
tempDirs.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
const createPhoto = async (): Promise<{ id: number; flickrId: string; filename: string }> => {
|
||||
const flickrId = `c246-${randomUUID().slice(0, 8)}`
|
||||
createdFlickrIds.add(flickrId)
|
||||
const dir = await tempDir('archive-integrity-')
|
||||
const filePath = join(dir, `flickr-${flickrId}.jpg`)
|
||||
await writeFile(filePath, ARCHIVE_PHOTO_JPEG_BYTES)
|
||||
const created = await ingestArchivePhoto(payload, record(flickrId), { filePath })
|
||||
if (created.status !== 'created' || !created.filename) {
|
||||
throw new Error(`fixture not created: ${created.status}`)
|
||||
}
|
||||
return { id: created.id, flickrId, filename: created.filename }
|
||||
}
|
||||
|
||||
const staticDir = (): string => resolvePrivateMediaStaticDir(payload, ARCHIVE_PHOTO_SLUG)
|
||||
const storedPath = (filename: string): string => join(staticDir(), filename)
|
||||
|
||||
const probe = async (filename: string | null) => {
|
||||
const dir = await tempDir('archive-integrity-probe-')
|
||||
return inspectPrivateMediaObject({
|
||||
media: { filename },
|
||||
staticDir: staticDir(),
|
||||
destinationPath: join(dir, 'probe.bin'),
|
||||
})
|
||||
}
|
||||
|
||||
const statusOf = async (id: number): Promise<string> => {
|
||||
const doc = await payload.findByID({
|
||||
collection: ARCHIVE_PHOTO_SLUG,
|
||||
id,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
return doc.publicationStatus
|
||||
}
|
||||
|
||||
const approve = async (id: number): Promise<void> => {
|
||||
await payload.update({
|
||||
collection: ARCHIVE_PHOTO_SLUG,
|
||||
id,
|
||||
data: { publicationStatus: 'approved' },
|
||||
overrideAccess: true,
|
||||
})
|
||||
}
|
||||
|
||||
const setChannel = async (removalChannelUrl: string | null): Promise<void> => {
|
||||
await withExclusiveTestDatabaseLease(payload, PHOTO_ALBUM_LEASE_KEY, () =>
|
||||
payload.updateGlobal({
|
||||
slug: 'photoAlbum',
|
||||
data: { published: removalChannelUrl !== null, removalChannelUrl },
|
||||
overrideAccess: true,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
describe('archive photo integrity (C246)', () => {
|
||||
beforeAll(async () => {
|
||||
payload = await getPayload({ config: await config })
|
||||
await setChannel(CHANNEL)
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
for (const flickrId of createdFlickrIds) {
|
||||
await payload.delete({
|
||||
collection: ARCHIVE_PHOTO_SLUG,
|
||||
where: { flickrId: { equals: flickrId } },
|
||||
overrideAccess: true,
|
||||
})
|
||||
}
|
||||
await setChannel(null)
|
||||
await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
it('reads a stored original as ok through the serving path', async () => {
|
||||
const photo = await createPhoto()
|
||||
|
||||
const inspection = await probe(photo.filename)
|
||||
|
||||
expect(inspection).toEqual({ status: 'ok', bytes: ARCHIVE_PHOTO_JPEG_BYTES.length })
|
||||
})
|
||||
|
||||
it('classifies an undecodable object at the decode stage', async () => {
|
||||
const photo = await createPhoto()
|
||||
await writeFile(storedPath(photo.filename), Buffer.from('isto não é uma imagem'))
|
||||
|
||||
const inspection = await probe(photo.filename)
|
||||
|
||||
expect(inspection.status).toBe('corrupt')
|
||||
if (inspection.status !== 'corrupt') return
|
||||
expect(inspection.stage).toBe('decode')
|
||||
expect(inspection.reason.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('classifies a deleted object as missing', async () => {
|
||||
const photo = await createPhoto()
|
||||
await rm(storedPath(photo.filename), { force: true })
|
||||
|
||||
expect(await probe(photo.filename)).toEqual({ status: 'missing' })
|
||||
})
|
||||
|
||||
it('classifies a row without a stored filename as missing', async () => {
|
||||
expect(await probe(null)).toEqual({ status: 'missing' })
|
||||
})
|
||||
|
||||
it('repairs the object on the same key without touching curation or status', async () => {
|
||||
const photo = await createPhoto()
|
||||
await approve(photo.id)
|
||||
await payload.update({
|
||||
collection: ARCHIVE_PHOTO_SLUG,
|
||||
id: photo.id,
|
||||
data: { title: 'Título curado', curatedFields: ['alt'] },
|
||||
overrideAccess: true,
|
||||
})
|
||||
await writeFile(storedPath(photo.filename), Buffer.from('quebrado'))
|
||||
const replacement = await sharp({
|
||||
create: { width: 3, height: 3, channels: 3, background: { r: 255, g: 0, b: 0 } },
|
||||
})
|
||||
.jpeg()
|
||||
.toBuffer()
|
||||
|
||||
const dir = await tempDir('archive-integrity-repair-')
|
||||
const filePath = join(dir, photo.filename)
|
||||
await writeFile(filePath, replacement)
|
||||
|
||||
const repaired = await repairArchivePhotoObject(payload, { id: photo.id, filePath })
|
||||
|
||||
expect(repaired).toEqual({
|
||||
status: 'repaired',
|
||||
id: photo.id,
|
||||
filename: photo.filename,
|
||||
filesize: replacement.length,
|
||||
})
|
||||
expect(await readFile(storedPath(photo.filename))).toEqual(replacement)
|
||||
|
||||
const row = await payload.findByID({
|
||||
collection: ARCHIVE_PHOTO_SLUG,
|
||||
id: photo.id,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(row.publicationStatus).toBe('approved')
|
||||
expect(row.title).toBe('Título curado')
|
||||
expect(row.curatedFields).toEqual(['alt'])
|
||||
expect(row.filesize).toBe(replacement.length)
|
||||
|
||||
// Convergence: the repaired object now inspects clean.
|
||||
expect(await probe(photo.filename)).toEqual({ status: 'ok', bytes: replacement.length })
|
||||
})
|
||||
|
||||
it('reports a failed repair without touching the row', async () => {
|
||||
const photo = await createPhoto()
|
||||
const before = await payload.findByID({
|
||||
collection: ARCHIVE_PHOTO_SLUG,
|
||||
id: photo.id,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
const failed = await repairArchivePhotoObject(payload, {
|
||||
id: photo.id,
|
||||
filePath: join(tmpdir(), 'nao-existe', photo.filename),
|
||||
})
|
||||
|
||||
expect(failed.status).toBe('failed')
|
||||
if (failed.status !== 'failed') return
|
||||
expect(failed.error.length).toBeGreaterThan(0)
|
||||
const after = await payload.findByID({
|
||||
collection: ARCHIVE_PHOTO_SLUG,
|
||||
id: photo.id,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(after.publicationStatus).toBe(before.publicationStatus)
|
||||
expect(after.title).toBe(before.title)
|
||||
})
|
||||
|
||||
it('withdraws an unrecoverable approved photo to draft and purges descriptors', async () => {
|
||||
const photo = await createPhoto()
|
||||
await approve(photo.id)
|
||||
await payload.create({
|
||||
collection: 'archivePhotoFace',
|
||||
data: {
|
||||
photo: photo.id,
|
||||
model: 'modelo-de-teste',
|
||||
detectedAt: new Date().toISOString(),
|
||||
vector: [0.1, 0.2, 0.3],
|
||||
},
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
const withdrawn = await withdrawArchivePhotoFromPublic(payload, {
|
||||
id: photo.id,
|
||||
flickrId: photo.flickrId,
|
||||
})
|
||||
|
||||
expect(withdrawn).toEqual({
|
||||
status: 'withdrawn',
|
||||
id: photo.id,
|
||||
flickrId: photo.flickrId,
|
||||
previousStatus: 'approved',
|
||||
})
|
||||
expect(await statusOf(photo.id)).toBe('draft')
|
||||
const faces = await payload.find({
|
||||
collection: 'archivePhotoFace',
|
||||
where: { photo: { equals: photo.id } },
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(faces.docs).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('never touches a removed photo', async () => {
|
||||
const photo = await createPhoto()
|
||||
await payload.update({
|
||||
collection: ARCHIVE_PHOTO_SLUG,
|
||||
id: photo.id,
|
||||
data: { publicationStatus: 'removed' },
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
const result = await withdrawArchivePhotoFromPublic(payload, {
|
||||
id: photo.id,
|
||||
flickrId: photo.flickrId,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'skipped',
|
||||
id: photo.id,
|
||||
flickrId: photo.flickrId,
|
||||
reason: 'removed',
|
||||
})
|
||||
expect(await statusOf(photo.id)).toBe('removed')
|
||||
})
|
||||
|
||||
it('keeps an already-draft photo out of the public without a write', async () => {
|
||||
const photo = await createPhoto()
|
||||
|
||||
const result = await withdrawArchivePhotoFromPublic(payload, {
|
||||
id: photo.id,
|
||||
flickrId: photo.flickrId,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'skipped',
|
||||
id: photo.id,
|
||||
flickrId: photo.flickrId,
|
||||
reason: 'already-draft',
|
||||
})
|
||||
expect(await statusOf(photo.id)).toBe('draft')
|
||||
})
|
||||
|
||||
it('does not block the downgrade when the removal channel is absent', async () => {
|
||||
const photo = await createPhoto()
|
||||
await approve(photo.id)
|
||||
await setChannel(null)
|
||||
try {
|
||||
const withdrawn = await withdrawArchivePhotoFromPublic(payload, {
|
||||
id: photo.id,
|
||||
flickrId: photo.flickrId,
|
||||
})
|
||||
|
||||
expect(withdrawn.status).toBe('withdrawn')
|
||||
expect(await statusOf(photo.id)).toBe('draft')
|
||||
} finally {
|
||||
await setChannel(CHANNEL)
|
||||
}
|
||||
})
|
||||
})
|
||||
106
tests/unit/archiveIntegrityCli.unit.spec.ts
Normal file
106
tests/unit/archiveIntegrityCli.unit.spec.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { spawnSync } from 'node:child_process'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
// C246 — the integrity CLI's fail-closed guards, exercised as a subprocess:
|
||||
// each refusal must happen before any payload boot, bucket read or Flickr call.
|
||||
|
||||
const repoRoot = process.cwd()
|
||||
const scriptPath = join(repoRoot, 'scripts', 'check-archive-integrity.mjs')
|
||||
|
||||
const run = (args: string[], env: Record<string, string> = {}) =>
|
||||
spawnSync(process.execPath, [scriptPath, ...args], {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8',
|
||||
timeout: 30_000,
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_OPTIONS: '--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs',
|
||||
NODE_ENV: 'production',
|
||||
VITEST: '',
|
||||
DATABASE_URL: 'postgresql://teqo:teqo@127.0.0.1:5432/teqo_1313',
|
||||
PAYLOAD_SECRET: 'test-secret',
|
||||
ARCHIVE_INTEGRITY_CONFIRM: '',
|
||||
TEQO_ENV: '',
|
||||
ALLOW_REMOTE_DB: '',
|
||||
FLICKR_API_KEY: '',
|
||||
S3_BUCKET: '',
|
||||
S3_ENDPOINT: '',
|
||||
S3_ACCESS_KEY_ID: '',
|
||||
S3_SECRET_ACCESS_KEY: '',
|
||||
...env,
|
||||
},
|
||||
})
|
||||
|
||||
const output = (result: ReturnType<typeof run>) => `${result.stdout}${result.stderr}`
|
||||
const withS3 = {
|
||||
S3_BUCKET: 'bucket',
|
||||
S3_ENDPOINT: 'http://127.0.0.1:3900',
|
||||
S3_ACCESS_KEY_ID: 'key',
|
||||
S3_SECRET_ACCESS_KEY: 'secret',
|
||||
}
|
||||
|
||||
describe('archive:integrity write guards (C246)', () => {
|
||||
it('prints the help even without a database', () => {
|
||||
const result = run(['--help'], { DATABASE_URL: '' })
|
||||
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.stdout).toContain('ARCHIVE_INTEGRITY_CONFIRM')
|
||||
expect(result.stdout).toContain('FLICKR_API_KEY')
|
||||
expect(result.stdout).toContain('--only')
|
||||
})
|
||||
|
||||
it('refuses --apply on a production target without the intent flag', () => {
|
||||
const result = run(['--apply'])
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(output(result)).toContain('ARCHIVE_INTEGRITY_CONFIRM=1')
|
||||
})
|
||||
|
||||
it('requires the declared TEQO_ENV once the intent flag is set', () => {
|
||||
const result = run(['--apply'], { ARCHIVE_INTEGRITY_CONFIRM: '1' })
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(output(result)).toContain('TEQO_ENV')
|
||||
})
|
||||
|
||||
it('refuses a target database that does not match the declared environment', () => {
|
||||
const result = run(['--apply'], { ARCHIVE_INTEGRITY_CONFIRM: '1', TEQO_ENV: 'staging' })
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(output(result)).toContain('≠ "teqo_staging"')
|
||||
})
|
||||
|
||||
it('refuses --apply without the complete S3_* set on a declared production target', () => {
|
||||
const result = run(['--apply'], { ARCHIVE_INTEGRITY_CONFIRM: '1', TEQO_ENV: 'production' })
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(output(result)).toContain('S3_BUCKET')
|
||||
})
|
||||
|
||||
it('requires FLICKR_API_KEY for a repair with S3 configured', () => {
|
||||
const result = run(['--apply'], {
|
||||
ARCHIVE_INTEGRITY_CONFIRM: '1',
|
||||
TEQO_ENV: 'production',
|
||||
...withS3,
|
||||
})
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(output(result)).toContain('FLICKR_API_KEY')
|
||||
})
|
||||
|
||||
it('refuses even the read-only sweep on a non-local target without S3', () => {
|
||||
const result = run([])
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(output(result)).toContain('S3_BUCKET')
|
||||
})
|
||||
|
||||
it('refuses an unknown argument before touching anything', () => {
|
||||
const result = run(['--force'])
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(output(result)).toContain('argumento desconhecido')
|
||||
})
|
||||
})
|
||||
322
tests/unit/archiveIntegrityPlan.unit.spec.ts
Normal file
322
tests/unit/archiveIntegrityPlan.unit.spec.ts
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
ARCHIVE_INTEGRITY_DEFAULT_CONCURRENCY,
|
||||
ARCHIVE_INTEGRITY_DEFAULT_OUT_DIR,
|
||||
archiveIntegrityReportStamp,
|
||||
formatArchiveIntegrityRepairReport,
|
||||
formatArchiveIntegrityScanReport,
|
||||
mapWithConcurrency,
|
||||
parseArchiveIntegrityCliArgs,
|
||||
parseArchiveIntegrityOnly,
|
||||
summarizeArchiveIntegrityRepair,
|
||||
summarizeArchiveIntegrityScan,
|
||||
} from '../../scripts/lib/archiveIntegrityPlan.mjs'
|
||||
|
||||
// C246 — the pure parser/summary of `pnpm archive:integrity`: the read-only
|
||||
// default, the bounded-concurrency map and the honest accounting of the sweep
|
||||
// and the repair (removed rows never enter the public gap).
|
||||
|
||||
describe('parseArchiveIntegrityCliArgs', () => {
|
||||
it('defaults to the read-only sweep and parses the options', () => {
|
||||
expect(parseArchiveIntegrityCliArgs([])).toEqual({
|
||||
apply: false,
|
||||
only: null,
|
||||
limit: null,
|
||||
concurrency: ARCHIVE_INTEGRITY_DEFAULT_CONCURRENCY,
|
||||
out: ARCHIVE_INTEGRITY_DEFAULT_OUT_DIR,
|
||||
help: false,
|
||||
})
|
||||
|
||||
const apply = parseArchiveIntegrityCliArgs([
|
||||
'--apply',
|
||||
'--only',
|
||||
'80,83,80',
|
||||
'--limit',
|
||||
'5',
|
||||
'--concurrency',
|
||||
'2',
|
||||
'--out',
|
||||
'x',
|
||||
])
|
||||
expect(apply).toEqual({
|
||||
apply: true,
|
||||
only: [80, 83],
|
||||
limit: 5,
|
||||
concurrency: 2,
|
||||
out: 'x',
|
||||
help: false,
|
||||
})
|
||||
|
||||
expect(parseArchiveIntegrityCliArgs(['--help']).help).toBe(true)
|
||||
expect(parseArchiveIntegrityCliArgs(['-h']).help).toBe(true)
|
||||
})
|
||||
|
||||
it('fails closed on bad values and unknown args', () => {
|
||||
expect(() => parseArchiveIntegrityCliArgs(['--only'])).toThrow(/faltou valor/)
|
||||
expect(() => parseArchiveIntegrityCliArgs(['--only', ''])).toThrow(/--only/)
|
||||
expect(() => parseArchiveIntegrityCliArgs(['--only', '80,abc'])).toThrow(/--only/)
|
||||
expect(() => parseArchiveIntegrityCliArgs(['--only', '80,,83'])).toThrow(/--only/)
|
||||
expect(() => parseArchiveIntegrityCliArgs(['--only', '0'])).toThrow(/--only/)
|
||||
expect(() => parseArchiveIntegrityCliArgs(['--limit', '0'])).toThrow(/--limit/)
|
||||
expect(() => parseArchiveIntegrityCliArgs(['--limit', 'x'])).toThrow(/--limit/)
|
||||
expect(() => parseArchiveIntegrityCliArgs(['--concurrency', '0'])).toThrow(/--concurrency/)
|
||||
expect(() => parseArchiveIntegrityCliArgs(['--concurrency', 'x'])).toThrow(/--concurrency/)
|
||||
expect(() => parseArchiveIntegrityCliArgs(['--out', '../etc'])).toThrow(/--out/)
|
||||
expect(() => parseArchiveIntegrityCliArgs(['--apply=1'])).toThrow(/argumento desconhecido/)
|
||||
})
|
||||
|
||||
it('parses --only without duplicates and refuses an empty selection', () => {
|
||||
expect(parseArchiveIntegrityOnly('80, 83,80')).toEqual([80, 83])
|
||||
expect(() => parseArchiveIntegrityOnly(' ')).toThrow(/--only/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapWithConcurrency', () => {
|
||||
it('preserves order and bounds the live workers', async () => {
|
||||
let live = 0
|
||||
let peak = 0
|
||||
const results = await mapWithConcurrency([1, 2, 3, 4, 5], 2, async (item) => {
|
||||
live += 1
|
||||
peak = Math.max(peak, live)
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
live -= 1
|
||||
return item * 10
|
||||
})
|
||||
|
||||
expect(results).toEqual([10, 20, 30, 40, 50])
|
||||
expect(peak).toBeLessThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('handles an empty list and a concurrency above the list size', async () => {
|
||||
expect(await mapWithConcurrency([], 3, async () => 1)).toEqual([])
|
||||
expect(await mapWithConcurrency(['a'], 8, async (item) => item.toUpperCase())).toEqual(['A'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('summarizeArchiveIntegrityScan', () => {
|
||||
it('keeps pending to the public gap and separates drafts and removed rows', () => {
|
||||
const summary = summarizeArchiveIntegrityScan([
|
||||
{
|
||||
id: 1,
|
||||
flickrId: '1',
|
||||
filename: 'a.jpg',
|
||||
publicationStatus: 'approved',
|
||||
status: 'ok',
|
||||
bytes: 100,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
flickrId: '2',
|
||||
filename: 'b.jpg',
|
||||
publicationStatus: 'draft',
|
||||
status: 'ok',
|
||||
bytes: 50,
|
||||
},
|
||||
{
|
||||
id: 80,
|
||||
flickrId: '80',
|
||||
filename: 'flickr-80.jpg',
|
||||
publicationStatus: 'approved',
|
||||
status: 'corrupt',
|
||||
stage: 'download',
|
||||
reason: 'checksum',
|
||||
bytes: 0,
|
||||
},
|
||||
{
|
||||
id: 83,
|
||||
flickrId: '83',
|
||||
filename: null,
|
||||
publicationStatus: 'approved',
|
||||
status: 'missing',
|
||||
bytes: 0,
|
||||
},
|
||||
{
|
||||
id: 84,
|
||||
flickrId: '84',
|
||||
filename: 'd.jpg',
|
||||
publicationStatus: 'draft',
|
||||
status: 'corrupt',
|
||||
stage: 'decode',
|
||||
reason: 'lixo',
|
||||
bytes: 0,
|
||||
},
|
||||
{
|
||||
id: 99,
|
||||
flickrId: '99',
|
||||
filename: 'r.jpg',
|
||||
publicationStatus: 'removed',
|
||||
status: 'ok',
|
||||
bytes: 10,
|
||||
},
|
||||
{
|
||||
id: 98,
|
||||
flickrId: '98',
|
||||
filename: 'r2.jpg',
|
||||
publicationStatus: 'removed',
|
||||
status: 'corrupt',
|
||||
stage: 'decode',
|
||||
reason: 'lixo',
|
||||
bytes: 5,
|
||||
},
|
||||
])
|
||||
|
||||
expect(summary.scanned).toBe(7)
|
||||
expect(summary.ok).toBe(2)
|
||||
expect(summary.pending).toBe(2)
|
||||
expect(summary.draftBroken).toEqual([
|
||||
{
|
||||
id: 84,
|
||||
flickrId: '84',
|
||||
filename: 'd.jpg',
|
||||
publicationStatus: 'draft',
|
||||
stage: 'decode',
|
||||
reason: 'lixo',
|
||||
status: 'corrupt',
|
||||
},
|
||||
])
|
||||
expect(summary.missing).toEqual([
|
||||
{
|
||||
id: 83,
|
||||
flickrId: '83',
|
||||
filename: null,
|
||||
publicationStatus: 'approved',
|
||||
stage: null,
|
||||
reason: null,
|
||||
},
|
||||
])
|
||||
expect(summary.corrupted).toEqual([
|
||||
{
|
||||
id: 80,
|
||||
flickrId: '80',
|
||||
filename: 'flickr-80.jpg',
|
||||
publicationStatus: 'approved',
|
||||
stage: 'download',
|
||||
reason: 'checksum',
|
||||
},
|
||||
{
|
||||
id: 84,
|
||||
flickrId: '84',
|
||||
filename: 'd.jpg',
|
||||
publicationStatus: 'draft',
|
||||
stage: 'decode',
|
||||
reason: 'lixo',
|
||||
},
|
||||
])
|
||||
expect(summary.removedScanned).toBe(2)
|
||||
expect(summary.removedBroken).toEqual([
|
||||
{
|
||||
id: 98,
|
||||
flickrId: '98',
|
||||
filename: 'r2.jpg',
|
||||
publicationStatus: 'removed',
|
||||
stage: 'decode',
|
||||
reason: 'lixo',
|
||||
status: 'corrupt',
|
||||
},
|
||||
])
|
||||
expect(summary.bytes).toBe(165)
|
||||
})
|
||||
})
|
||||
|
||||
describe('summarizeArchiveIntegrityRepair', () => {
|
||||
it('buckets recovered, unrecoverable and failed', () => {
|
||||
const summary = summarizeArchiveIntegrityRepair([
|
||||
{ id: 1, flickrId: '1', filename: 'a.jpg', action: 'recovered' },
|
||||
{ id: 2, flickrId: '2', filename: 'b.jpg', action: 'unrecoverable' },
|
||||
{ id: 3, flickrId: '3', filename: 'c.jpg', action: 'failed' },
|
||||
{ id: 4, flickrId: '4', filename: 'd.jpg', action: 'failed' },
|
||||
])
|
||||
|
||||
expect(summary.recovered.map((item) => item.id)).toEqual([1])
|
||||
expect(summary.unrecoverable.map((item) => item.id)).toEqual([2])
|
||||
expect(summary.failed.map((item) => item.id)).toEqual([3, 4])
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatting', () => {
|
||||
it('stamps the receipt name', () => {
|
||||
expect(archiveIntegrityReportStamp('2026-10-01T04:09:04.123Z')).toBe('2026-10-01T04-09-04-123Z')
|
||||
})
|
||||
|
||||
it('prints the sweep counts and names every broken photo with its state', () => {
|
||||
const report = {
|
||||
scan: {
|
||||
scanned: 10,
|
||||
ok: 8,
|
||||
missing: [
|
||||
{
|
||||
id: 83,
|
||||
flickrId: '83',
|
||||
filename: null,
|
||||
publicationStatus: 'approved',
|
||||
stage: null,
|
||||
reason: null,
|
||||
},
|
||||
],
|
||||
corrupted: [
|
||||
{
|
||||
id: 80,
|
||||
flickrId: '80',
|
||||
filename: 'flickr-80.jpg',
|
||||
publicationStatus: 'approved',
|
||||
stage: 'download',
|
||||
reason: 'checksum mismatch',
|
||||
},
|
||||
],
|
||||
draftBroken: [],
|
||||
removedScanned: 1,
|
||||
removedBroken: [],
|
||||
pending: 2,
|
||||
bytes: 1024 * 1024,
|
||||
},
|
||||
durationMs: 2_000,
|
||||
}
|
||||
const text = formatArchiveIntegrityScanReport(report).join('\n')
|
||||
|
||||
expect(text).toContain('varredura: 10 foto(s)')
|
||||
expect(text).toContain('ausentes: 1')
|
||||
expect(text).toContain('corrompidas: 1')
|
||||
expect(text).toContain('pendência pública (aprovadas quebradas): 2')
|
||||
expect(text).toContain('ausente 83 (sem arquivo, aprovada)')
|
||||
expect(text).toContain('corrompida 80 (flickr-80.jpg, aprovada) [download]: checksum mismatch')
|
||||
expect(text).toContain('1.0 MiB')
|
||||
})
|
||||
|
||||
it('prints the repair buckets with provenance and the exit record', () => {
|
||||
const report = {
|
||||
repair: {
|
||||
recovered: [
|
||||
{
|
||||
id: 80,
|
||||
filename: 'flickr-80.jpg',
|
||||
bytes: 2048,
|
||||
sha256: 'abcdef0123456789',
|
||||
sourceUrl: 'https://live.staticflickr.com/x_o.jpg',
|
||||
},
|
||||
],
|
||||
unrecoverable: [
|
||||
{
|
||||
id: 83,
|
||||
filename: null,
|
||||
previousStatus: 'approved',
|
||||
newStatus: 'draft',
|
||||
reason: 'fonte sumiu',
|
||||
},
|
||||
],
|
||||
failed: [{ id: 84, filename: 'flickr-84.jpg', stage: 'download', reason: 'timeout' }],
|
||||
},
|
||||
}
|
||||
const text = formatArchiveIntegrityRepairReport(report).join('\n')
|
||||
|
||||
expect(text).toContain('recuperadas: 1')
|
||||
expect(text).toContain('irrecuperáveis (fora do público): 1')
|
||||
expect(text).toContain('falhas: 1')
|
||||
expect(text).toContain('recuperada 80 (flickr-80.jpg)')
|
||||
expect(text).toContain('sha256:abcdef012345…')
|
||||
expect(text).toContain('irrecuperável 83 (sem arquivo) approved → draft: fonte sumiu')
|
||||
expect(text).toContain('falha 84 (flickr-84.jpg) [download]: timeout')
|
||||
})
|
||||
})
|
||||
231
tests/unit/archiveIntegrityRepair.unit.spec.ts
Normal file
231
tests/unit/archiveIntegrityRepair.unit.spec.ts
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { basename, join } from 'node:path'
|
||||
import { afterAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { repairArchivePhotoFromSource } from '../../scripts/lib/archiveIntegrityRepair.mjs'
|
||||
import { FlickrApiError } from '../../scripts/lib/flickrApi.mjs'
|
||||
|
||||
import { ARCHIVE_PHOTO_JPEG_BYTES } from '../helpers/archivePhotoFixture'
|
||||
|
||||
// C246 — the per-photo repair decision with every side effect faked: a gone
|
||||
// source or an undecodable download leaves the public (withdraw); a transient
|
||||
// source/download failure is retryable; a write/verification failure is never
|
||||
// reported as recovered.
|
||||
|
||||
const tempDirs: string[] = []
|
||||
|
||||
type Deps = {
|
||||
row: { id: number; flickrId: string; filename: string | null; publicationStatus: string }
|
||||
tempRoot: string
|
||||
staticDir: string
|
||||
payload: unknown
|
||||
client: { getLargestSize: (flickrId: string) => Promise<{ url: string } | null> }
|
||||
download: (args: {
|
||||
url: string
|
||||
destinationPath: string
|
||||
headers: Record<string, string>
|
||||
timeoutMs: number
|
||||
maxBytes: number
|
||||
}) => Promise<void>
|
||||
repair: (
|
||||
payload: unknown,
|
||||
args: { id: number; filePath: string },
|
||||
) => Promise<{ status: string; filename?: string | null; error?: string }>
|
||||
inspect: (args: {
|
||||
media: { filename: string | null }
|
||||
staticDir: string
|
||||
destinationPath: string
|
||||
}) => Promise<{ status: string; stage?: string; reason?: string; bytes?: number }>
|
||||
withdraw: (entry: {
|
||||
id: number
|
||||
flickrId: string
|
||||
filename: string | null
|
||||
reason: string
|
||||
}) => Promise<Record<string, unknown>>
|
||||
}
|
||||
|
||||
const makeTempRoot = async (): Promise<string> => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'c246-repair-'))
|
||||
tempDirs.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
const makeDeps = async (
|
||||
overrides: Partial<Deps> = {},
|
||||
): Promise<{ deps: Deps; callOrder: string[] }> => {
|
||||
const callOrder: string[] = []
|
||||
const deps: Deps = {
|
||||
row: { id: 80, flickrId: '123', filename: 'flickr-123.jpg', publicationStatus: 'approved' },
|
||||
tempRoot: await makeTempRoot(),
|
||||
staticDir: '/nao-usado',
|
||||
payload: {},
|
||||
client: {
|
||||
getLargestSize: vi.fn(async () => {
|
||||
callOrder.push('source')
|
||||
return { url: 'https://live.staticflickr.com/65535/123_o.jpg' }
|
||||
}),
|
||||
},
|
||||
download: vi.fn(async ({ destinationPath }: { destinationPath: string }) => {
|
||||
callOrder.push('download')
|
||||
await writeFile(destinationPath, ARCHIVE_PHOTO_JPEG_BYTES)
|
||||
}),
|
||||
repair: vi.fn(async () => {
|
||||
callOrder.push('repair')
|
||||
return { status: 'repaired', id: 80, filename: 'flickr-123.jpg', filesize: 268 }
|
||||
}),
|
||||
inspect: vi.fn(async () => {
|
||||
callOrder.push('inspect')
|
||||
return { status: 'ok', bytes: ARCHIVE_PHOTO_JPEG_BYTES.length }
|
||||
}),
|
||||
withdraw: vi.fn(async (entry: Record<string, unknown>) => {
|
||||
callOrder.push('withdraw')
|
||||
return { ...entry, action: 'unrecoverable', previousStatus: 'approved', newStatus: 'draft' }
|
||||
}),
|
||||
...overrides,
|
||||
}
|
||||
return { deps, callOrder }
|
||||
}
|
||||
|
||||
const repair = (deps: Deps) => repairArchivePhotoFromSource(deps)
|
||||
|
||||
afterAll(async () => {
|
||||
await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('repairArchivePhotoFromSource (C246)', () => {
|
||||
it('recovers: source → download → decode → repair → verify, with provenance', async () => {
|
||||
const { deps, callOrder } = await makeDeps()
|
||||
|
||||
const result = await repair(deps)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
id: 80,
|
||||
flickrId: '123',
|
||||
filename: 'flickr-123.jpg',
|
||||
action: 'recovered',
|
||||
sourceUrl: 'https://live.staticflickr.com/65535/123_o.jpg',
|
||||
bytes: ARCHIVE_PHOTO_JPEG_BYTES.length,
|
||||
})
|
||||
expect(result.sha256).toMatch(/^[0-9a-f]{64}$/)
|
||||
expect(callOrder).toEqual(['source', 'download', 'repair', 'inspect'])
|
||||
expect(basename(vi.mocked(deps.repair).mock.calls[0][1].filePath)).toBe('flickr-123.jpg')
|
||||
})
|
||||
|
||||
it('withdraws when Flickr refuses the source (photo gone)', async () => {
|
||||
const client = {
|
||||
getLargestSize: vi.fn(async () => {
|
||||
throw new FlickrApiError('Flickr 1: Photo not found', { code: 1 })
|
||||
}),
|
||||
}
|
||||
const { deps } = await makeDeps({ client })
|
||||
|
||||
const result = await repair(deps)
|
||||
|
||||
expect(result).toMatchObject({ action: 'unrecoverable', newStatus: 'draft' })
|
||||
expect(result.reason).toContain('recusou a fonte')
|
||||
expect(deps.repair).not.toHaveBeenCalled()
|
||||
expect(deps.withdraw).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('withdraws when the source has no usable size', async () => {
|
||||
const client = { getLargestSize: vi.fn(async () => null) }
|
||||
const { deps } = await makeDeps({ client })
|
||||
|
||||
const result = await repair(deps)
|
||||
|
||||
expect(result).toMatchObject({ action: 'unrecoverable' })
|
||||
expect(result.reason).toContain('nenhum tamanho utilizável')
|
||||
})
|
||||
|
||||
it('keeps a transient source failure as a retryable failure', async () => {
|
||||
const client = {
|
||||
getLargestSize: vi.fn(async () => {
|
||||
throw new Error('getaddrinfo ENOTFOUND')
|
||||
}),
|
||||
}
|
||||
const { deps } = await makeDeps({ client })
|
||||
|
||||
const result = await repair(deps)
|
||||
|
||||
expect(result).toMatchObject({ action: 'failed', stage: 'source' })
|
||||
expect(deps.withdraw).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps a download failure as retryable and never touches the object', async () => {
|
||||
const download = vi.fn(async () => {
|
||||
throw new Error('Download falhou (HTTP 500).')
|
||||
})
|
||||
const { deps } = await makeDeps({ download })
|
||||
|
||||
const result = await repair(deps)
|
||||
|
||||
expect(result).toMatchObject({ action: 'failed', stage: 'download' })
|
||||
expect(deps.repair).not.toHaveBeenCalled()
|
||||
expect(deps.withdraw).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('withdraws when the downloaded source does not decode', async () => {
|
||||
const download = vi.fn(async ({ destinationPath }: { destinationPath: string }) => {
|
||||
await writeFile(destinationPath, Buffer.from('nao e imagem'))
|
||||
})
|
||||
const { deps } = await makeDeps({ download })
|
||||
|
||||
const result = await repair(deps)
|
||||
|
||||
expect(result).toMatchObject({ action: 'unrecoverable' })
|
||||
expect(result.reason).toContain('não decodifica')
|
||||
expect(deps.repair).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a failed object write without claiming recovery', async () => {
|
||||
const repairMock = vi.fn(async () => ({ status: 'failed', id: 80, error: 'boom' }))
|
||||
const { deps } = await makeDeps({ repair: repairMock })
|
||||
|
||||
const result = await repair(deps)
|
||||
|
||||
expect(result).toMatchObject({ action: 'failed', stage: 'repair', reason: 'boom' })
|
||||
expect(deps.inspect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a failed verification of the repaired object', async () => {
|
||||
const inspect = vi.fn(async () => ({
|
||||
status: 'corrupt',
|
||||
stage: 'decode',
|
||||
reason: 'ainda quebrado',
|
||||
bytes: 1,
|
||||
}))
|
||||
const { deps } = await makeDeps({ inspect })
|
||||
|
||||
const result = await repair(deps)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
action: 'failed',
|
||||
stage: 'verify',
|
||||
reason: 'decode: ainda quebrado',
|
||||
})
|
||||
})
|
||||
|
||||
it('derives the deterministic name when the row has no stored filename', async () => {
|
||||
const repairMock = vi.fn(async (_payload: unknown, { filePath }: { filePath: string }) => ({
|
||||
status: 'repaired',
|
||||
id: 81,
|
||||
filename: basename(filePath),
|
||||
filesize: 0,
|
||||
}))
|
||||
const { deps } = await makeDeps({
|
||||
row: { id: 81, flickrId: '456', filename: null, publicationStatus: 'draft' },
|
||||
repair: repairMock,
|
||||
})
|
||||
|
||||
const result = await repair(deps)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
action: 'recovered',
|
||||
filename: 'flickr-456.jpg',
|
||||
})
|
||||
expect(basename(vi.mocked(deps.repair).mock.calls[0][1].filePath)).toBe('flickr-456.jpg')
|
||||
})
|
||||
})
|
||||
|
|
@ -2,7 +2,12 @@
|
|||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { FLICKR_PHOTO_EXTRAS, createFlickrClient } from '../../scripts/lib/flickrApi.mjs'
|
||||
import {
|
||||
FLICKR_PHOTO_EXTRAS,
|
||||
FlickrApiError,
|
||||
createFlickrClient,
|
||||
isFlickrSourceGone,
|
||||
} from '../../scripts/lib/flickrApi.mjs'
|
||||
|
||||
// C231 — the Flickr client contract: request shape, pacing, transient retry,
|
||||
// named `stat=fail` refusals, the exif tolerance and the largest-size
|
||||
|
|
@ -172,3 +177,31 @@ describe('createFlickrClient (C231)', () => {
|
|||
expect(await clientWith(onlyUndimensioned).getLargestSize('1')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isFlickrSourceGone (C246)', () => {
|
||||
it('treats a photo-not-found refusal and an HTTP 404 as a gone source', () => {
|
||||
expect(isFlickrSourceGone(new FlickrApiError('Flickr 1: Photo not found', { code: 1 }))).toBe(
|
||||
true,
|
||||
)
|
||||
expect(isFlickrSourceGone(new FlickrApiError('Flickr 2: Photo not found', { code: 2 }))).toBe(
|
||||
true,
|
||||
)
|
||||
expect(isFlickrSourceGone(new FlickrApiError('HTTP 404', { status: 404 }))).toBe(true)
|
||||
})
|
||||
|
||||
it('never withdraws a photo for auth, outage, transient or unrelated errors', () => {
|
||||
expect(
|
||||
isFlickrSourceGone(new FlickrApiError('Flickr 100: Invalid API Key', { code: 100 })),
|
||||
).toBe(false)
|
||||
expect(
|
||||
isFlickrSourceGone(
|
||||
new FlickrApiError('Flickr 105: Service currently unavailable', { code: 105 }),
|
||||
),
|
||||
).toBe(false)
|
||||
expect(isFlickrSourceGone(new FlickrApiError('HTTP 403', { status: 403 }))).toBe(false)
|
||||
expect(isFlickrSourceGone(new FlickrApiError('HTTP 429', { status: 429 }))).toBe(false)
|
||||
expect(isFlickrSourceGone(new FlickrApiError('HTTP 500', { status: 500 }))).toBe(false)
|
||||
expect(isFlickrSourceGone(new Error('getaddrinfo ENOTFOUND'))).toBe(false)
|
||||
expect(isFlickrSourceGone(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
isPrivateMediaMissingError,
|
||||
PRIVATE_MEDIA_FALLBACK_MIME_TYPE,
|
||||
privateMediaContentDisposition,
|
||||
privateMediaContentType,
|
||||
|
|
@ -118,3 +119,27 @@ describe('private media response rules (C193/C199)', () => {
|
|||
expect(headers.get('Content-Type')).toBe('video/mp4')
|
||||
})
|
||||
})
|
||||
|
||||
describe('private media missing-object classification (C246)', () => {
|
||||
it('recognizes the S3 and local-disk absence shapes', () => {
|
||||
expect(isPrivateMediaMissingError({ name: 'NoSuchKey' })).toBe(true)
|
||||
expect(isPrivateMediaMissingError({ name: 'NotFound' })).toBe(true)
|
||||
expect(isPrivateMediaMissingError({ Code: 'NoSuchKey' })).toBe(true)
|
||||
expect(isPrivateMediaMissingError({ $metadata: { httpStatusCode: 404 } })).toBe(true)
|
||||
expect(isPrivateMediaMissingError(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }))).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
it('never classifies a read failure of an existing object as missing', () => {
|
||||
expect(isPrivateMediaMissingError(new Error('checksum mismatch'))).toBe(false)
|
||||
expect(isPrivateMediaMissingError({ name: 'BadDigest' })).toBe(false)
|
||||
expect(
|
||||
isPrivateMediaMissingError(
|
||||
Object.assign(new Error('socket hang up'), { $metadata: { httpStatusCode: 500 } }),
|
||||
),
|
||||
).toBe(false)
|
||||
expect(isPrivateMediaMissingError(null)).toBe(false)
|
||||
expect(isPrivateMediaMissingError('erro')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue