mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
feat(C234): busca por selfie no álbum público — índice A/C consentido, selfie on-device
- /fotos/encontre + POST /api/fotos/selfie: só o vetor 128-d, sem imagem, sem score e sem nomes de terceiros; fail-closed pela flag photoAlbum.selfieSearchEnabled (default false) e pelos Consents por chave estável (busca-selfie-fotos/busca-selfie-indice, não seedados — a superfície nasce fechada até o aval jurídico/DPIA). - Índice A/C do gate (PR #1370): só quem aderiu é indexado; descriptors do acervo são transitórios. Collection faceSubject (admin-only, sem vínculo com Contact), vínculos matchedPhotos e marker faces.checkedKey no archivePhoto (modelo + enrolledAt). - Engine @vladmandic/face-api nos dois lados: browser lazy com modelos same-origin (copy-face-vision-assets no prebuild/predev/Docker) e CLI node-wasm sem tfjs-node. CLIs ops-only faces:enroll / faces:index --apply|--verify com guardas C231/C232. - Opt-out self-service por reconhecimento (leave-index) + canal de remoção do C233; rate limit próprio (namespace selfie:<hash>) e bounded body extraído do beacon. - UI portada classe-a-classe do design estendido (paridade certificada, tier primário); e2e frontendFotosSelfie (manifest+curado+projeto, engine stubada) e canário do engine real; runbook §C234, contrato em AGENTS-public.md e changelog. - Migration aditiva add_face_subject (SQL revisado); hard-stops aprovados na sessão.
This commit is contained in:
parent
87002b7e82
commit
9e9864f420
65 changed files with 27450 additions and 214 deletions
|
|
@ -36,6 +36,14 @@ NEXT_PUBLIC_SITE_URL=http://localhost:3000
|
|||
# NEXT_PUBLIC_CARDS_CUTOUT_STUB=1 pnpm build
|
||||
NEXT_PUBLIC_CARDS_CUTOUT_STUB=
|
||||
|
||||
# C234 — test seam for the on-device selfie-search engine. `1` swaps the
|
||||
# face-api engine (and its ~7 MB of same-origin models) for a deterministic
|
||||
# descriptor derived from the file bytes (e2e builds and the Playwright
|
||||
# webServer only; the real engine must run in dev/production). Local E2E_PROD=1
|
||||
# builds set it too:
|
||||
# NEXT_PUBLIC_FACE_SEARCH_STUB=1 pnpm build
|
||||
NEXT_PUBLIC_FACE_SEARCH_STUB=
|
||||
|
||||
# Shared secret guarding POST /api/revalidate, which busts ISR cache tags on demand.
|
||||
# Needed after direct-DB writes (e.g. `pnpm db:seed:posts`, Onda 0 migrations) since those
|
||||
# bypass the deployed Payload hooks. Default tag is `posts`; allowlisted tags also include
|
||||
|
|
|
|||
1
.github/workflows/ci-pr.yml
vendored
1
.github/workflows/ci-pr.yml
vendored
|
|
@ -224,6 +224,7 @@ jobs:
|
|||
# S15 — deterministic cutout stub: the e2e build never downloads the
|
||||
# MediaPipe wasm nor runs inference.
|
||||
NEXT_PUBLIC_CARDS_CUTOUT_STUB: '1'
|
||||
NEXT_PUBLIC_FACE_SEARCH_STUB: '1'
|
||||
- name: E2E tests (blast radius — manifest-selected or curated specs)
|
||||
if: steps.scope.outputs.e2e_mode == 'selected' || steps.scope.outputs.e2e_mode == 'curated'
|
||||
# OPS72 + OPS86: the PR never runs e2e full — a high-risk diff runs
|
||||
|
|
|
|||
1
.github/workflows/deploy.yml
vendored
1
.github/workflows/deploy.yml
vendored
|
|
@ -198,6 +198,7 @@ jobs:
|
|||
# S15 — deterministic cutout stub: the e2e build never downloads the
|
||||
# MediaPipe wasm nor runs inference.
|
||||
NEXT_PUBLIC_CARDS_CUTOUT_STUB: '1'
|
||||
NEXT_PUBLIC_FACE_SEARCH_STUB: '1'
|
||||
- name: E2E tests (full suite, single job, 4 workers)
|
||||
# Explicit CI workers; the local default stays 2 (playwright.config.ts).
|
||||
# No `--` separator: pnpm would forward it verbatim to playwright and
|
||||
|
|
|
|||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -88,6 +88,7 @@ next-env.d.ts
|
|||
/data/acervo-index/
|
||||
# C232 — photo-archive cataloguing receipts (pnpm archive:catalog)
|
||||
/data/archive/
|
||||
/data/face/
|
||||
# Solla × Ceuci report HTML cache (scripts/build-solla-ceuci-salvador-report.mjs)
|
||||
/data/solla-ceuci-salvador/
|
||||
|
||||
|
|
@ -154,6 +155,7 @@ next-env.d.ts
|
|||
|
||||
# S15 — MediaPipe wasm runtime, copied from node_modules at dev/build time
|
||||
/public/cards/mediapipe/
|
||||
/public/fotos/modelos/
|
||||
|
||||
/data/encode/
|
||||
/data/poster-candidates/
|
||||
|
|
|
|||
|
|
@ -6,6 +6,14 @@ The public album over the private archive (`archivePhoto`, C231/C232): **only `p
|
|||
|
||||
The album global `photoAlbum` holds the two product knobs: `published` (kill switch — **only `false` closes**; an absent row means the schema default `true`) and `removalChannelUrl` (the visible "é você nesta foto? peça a remoção" channel, never invented in code). Publishing the album or approving a photo **requires a valid channel** (fail-closed guard in the global and in the collection); the album has no `loading.tsx` on purpose — a Suspense boundary would turn the closed album's `notFound()` into a `200`. The "Fotos" discovery link in `CampaignFooter` appears while there are approved photos (and always on `/fotos` itself).
|
||||
|
||||
## Selfie search (`/fotos/encontre`) — C234
|
||||
|
||||
The visitor's photo never leaves their device: the browser runs the face-api nets locally (models served same-origin from `/fotos/modelos`, copied by `scripts/copy-face-vision-assets.mjs` in prebuild/predev/Docker) and posts only the 128-float descriptor to `POST /api/fotos/selfie` (`{ vector, intent: 'search' | 'leave-index' }`, JSON ≤4 KB, same-origin, rate-limited 20/10 min per hashed IP with its own key namespace). The answer is a list of **approved** photos (`{ ok, found, photos }`), never a score, a percentage or a third-party name — the view model (`src/lib/faceSearch.ts` `toFaceSearchPhotoView`) has no such fields by shape. Recusals: 400 body, 403 origin, 429 throttle, 404 when `photoAlbum.published === false` or `selfieSearchEnabled !== true`, 503 when the query Consent is missing.
|
||||
|
||||
**The index is A/C by gate decision (PR #1370): only people who adhered and consented are searchable; there is no anonymous face index of the archive (that is option B, gated on DPIA + legal review).** Enrollment and indexing are ops-only CLIs (`pnpm faces:enroll`, `pnpm faces:index --apply|--verify`), run from the maintenance service with the same write guards as C231/C232 (`TEQO_ENV`, `FACE_*_CONFIRM=1`, mirrored `S3_*`). The batch computes descriptors transiently and persists only the photo↔subject links (`faceSubject.matchedPhotos`) plus the `archivePhoto.faces.checkedKey` marker (model + each subject's `enrolledAt`, so the batch's own writes never invalidate it). The CLI engine (`scripts/lib/faceEngine.mjs`) loads the same nets from `node_modules`; a model change invalidates the index (re-enroll + `--refresh`).
|
||||
|
||||
Both sides are **fail-closed**: `selfieSearchEnabled` defaults to `false`, and the two Consent keys (`busca-selfie-fotos` for the query, `busca-selfie-indice` for enrollment, in `src/lib/campaignConsentKeys.ts`) are never seeded — without the admin row with the exact key the page renders the closed state and the API refuses. Editing the enrollment text makes every subject ineligible until re-consent (`consentHash` snapshot). Opt-out is self-service ("Minha presença" → face match removes the vector and the links, status `removed`); photo takedown keeps using `photoAlbum.removalChannelUrl`. E2E builds stub the engine with `NEXT_PUBLIC_FACE_SEARCH_STUB=1` (`window.__faceSearchStub`); the real engine is exercised by the CLI canary and manually. Runbook: `docs/ops/teqo-1313-deploy.md` §C234.
|
||||
|
||||
## Posts & Tags (news / publications)
|
||||
|
||||
The public news/publications system is backed by two collections (both in the `Publicações` admin group) plus the helpers in `src/utilities/posts.ts`.
|
||||
|
|
|
|||
|
|
@ -47,6 +47,10 @@ ENV NEXT_TELEMETRY_DISABLED=1
|
|||
# same-origin (the files are gitignored build artifacts, never committed).
|
||||
RUN node scripts/copy-card-vision-assets.mjs
|
||||
|
||||
# C234 — copy the face-api model weights into public/ so the selfie search runs
|
||||
# same-origin too (same gitignored-artifact contract).
|
||||
RUN node scripts/copy-face-vision-assets.mjs
|
||||
|
||||
# OPS99: Generate importMap with dummy S3_* envs BEFORE next build.
|
||||
# This ensures the production image always has the S3ClientUploadHandler entry,
|
||||
# eliminating the class of bugs where the admin goes blank (OPS69/OPS72/OPS73).
|
||||
|
|
|
|||
1
docs/changelog/2026-09-27-c234.md
Normal file
1
docs/changelog/2026-09-27-c234.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**C234 — Busca por selfie — visitante encontra as fotos em que aparece (2026-09-27):** o álbum público do C233 ganha a busca por selfie **fechada por padrão**: `/fotos/encontre` (noindex, 404 com a flag `photoAlbum.selfieSearchEnabled` desligada — default `false` — ou o álbum fechado; estado fail-closed da cena 7 sem o Consent) e `POST /api/fotos/selfie`, que aceita **só o vetor 128-d** (JSON ≤4 KB, same-origin, rate limit 20/10 min por IP hasheado com chave própria `selfie:<hash>` sobre o dono do beacon; sem imagem, sem score, sem nomes de terceiros). A selfie nunca sai do aparelho: o engine `@vladmandic/face-api` (mesmos três nets do CLI, `tinyFaceDetector`+`faceLandmark68Net`+`faceRecognitionNet`, opções compartilhadas em `src/lib/faceSearch.ts`) roda no browser com os modelos same-origin (`/fotos/modelos`, artefato de build copiado por `scripts/copy-face-vision-assets.mjs` no prebuild/predev/Docker) e no lote pela build node-wasm (sem `tfjs-node`, sem browser headless). **Escopo A/C do gate (PR #1370): só quem aderiu é indexado; índice anônimo do acervo não existe.** A collection nova `faceSubject` (admin-only, sem vínculo com `Contact`) guarda o descriptor consentido, o snapshot do `consentHash` de `busca-selfie-indice` e os vínculos derivados `matchedPhotos`; o enrollment e a indexação são ops-only (`pnpm faces:enroll`, `pnpm faces:index --apply|--verify`, guardas C231/C232, recibos sem vetor/bytes em `data/face/reports/`), com descriptors do acervo transitórios (nunca persistidos) e o marker `archivePhoto.faces.checkedKey` (modelo + `enrolledAt` de cada sujeito elegível — os writes do próprio lote não invalidam a revisão). A consulta resolve os dois Consents por chave estável (`busca-selfie-fotos`/`busca-selfie-indice`, **não seedados**: a superfície nasce fechada até o aval jurídico/DPIA), exige `status: active` + modelo corrente + hash vigente (texto editado ⇒ inelegível até re-consentimento) e cruza o match com o read aprovado do C233 (draft/removida nunca aparecem; a foto removida some sem deploy). Opt-out self-service por reconhecimento ("Minha presença" → `leave-index` apaga vetor e vínculos, status `removed`; sem match, encaminha ao canal de remoção do álbum). Migration aditiva `add_face_subject` (tabela + join + colunas do marker + flag do global; SQL revisado; nada do acervo muda). UI portada classe-a-classe do design estendido (entrada entre hero e facetas com o submit do álbum secundário, consentimento com o richText do Consent, escolha com prévia local, processando, resultado em grade larga sem "Quem aparece", vazio honesto A/C, presença + diálogo destrutivo com trap de Tab, e os estados de navegador incompatível/erro/limite/rede) e **`Design tier: openai/gpt-5.6-sol` com paridade certificada** contra o app renderizado (3 rodadas, 390/1280). Testes: unit do contrato puro/limiar/schema/parsers/guardas, int do enrollment+índice e do endpoint sobre `teqo_test` (só aprovadas, sem nomes/score, opt-out, rate limit, same-origin), e2e novo `frontendFotosSelfie` (manifest + curado + projeto, engine stubada) e canário manual do engine real (enroll→index→vínculo). Runbook §C234 e contrato em `AGENTS-public.md`. Impl plan: `docs/plans/busca-fotos-por-selfie-impl.md`.
|
||||
|
|
@ -1429,6 +1429,65 @@ Rollback: a migration `add_archive_photo_catalog` é aditiva; o rollback funcion
|
|||
é limpar o grupo `catalog`/`curatedFields` no admin. Reexecutar nunca duplica nem
|
||||
sobrescreve curadoria.
|
||||
|
||||
## C234 — busca por selfie no álbum público
|
||||
|
||||
A busca por selfie (`/fotos/encontre` + `POST /api/fotos/selfie`) nasce
|
||||
**fechada**: a flag `photoAlbum.selfieSearchEnabled` é `false` por default e o
|
||||
fluxo só abre com o **Consent de consulta** (`busca-selfie-fotos`) configurado
|
||||
no admin — sem ele a página mostra o estado fail-closed e a API recusa (503).
|
||||
O índice é A/C (decisão do gate, PR #1370): só rostos de pessoas que aderiram
|
||||
entram; não existe índice anônimo do acervo. A selfie nunca sai do aparelho do
|
||||
visitante (o navegador envia só o vetor 128-d).
|
||||
|
||||
### Abertura (depois do aval jurídico/DPIA registrado)
|
||||
|
||||
1. No admin (`/admin`), crie os dois Consentimentos com as chaves exatas:
|
||||
`busca-selfie-fotos` (consulta, texto mostrado no fluxo) e
|
||||
`busca-selfie-indice` (adesão, assinado com a pessoa no enrollment). Sem os
|
||||
dois, o fluxo não abre / ninguém é elegível.
|
||||
2. Inscreva cada pessoa que aderiu (selfie com **um** rosto, com a pessoa
|
||||
presente e o consentimento assinado). Rode no homeserver, no serviço de
|
||||
manutenção do alvo (o engine roda local, sem endpoint externo):
|
||||
```bash
|
||||
cd ~/stack
|
||||
# plano (dry-run: detecta o rosto e resolve o consentimento; não grava)
|
||||
docker compose --profile maintenance run --rm -v /srv/face-selfies:/app/data/face-selfies:ro -v /srv/face-reports:/app/data/face/reports teqo-1313-migrate pnpm faces:enroll --label "<nome interno>" --selfie /app/data/face-selfies/<arquivo>.jpg
|
||||
# grava (exige TEQO_ENV=production e FACE_ENROLL_CONFIRM=1):
|
||||
docker compose --profile maintenance run --rm -e TEQO_ENV=production -e FACE_ENROLL_CONFIRM=1 -v /srv/face-selfies:/app/data/face-selfies:ro -v /srv/face-reports:/app/data/face-reports teqo-1313-migrate pnpm faces:enroll --label "<nome interno>" --selfie /app/data/face-selfies/<arquivo>.jpg --apply
|
||||
```
|
||||
3. Indexe o acervo aprovado para os inscritos (canário primeiro):
|
||||
```bash
|
||||
docker compose --profile maintenance run --rm -e TEQO_ENV=production -e FACE_INDEX_CONFIRM=1 -v /srv/face-reports:/app/data/face/reports teqo-1313-migrate pnpm faces:index --apply --limit 20
|
||||
# sem --limit processa todas as aprovadas; adicionar/reenrolar alguém
|
||||
# muda a revisão e a próxima execução reprocessa tudo (~6,5k fotos).
|
||||
docker compose --profile maintenance run --rm teqo-1313-migrate pnpm faces:index --verify # inventário read-only; sai 1 com pendências
|
||||
```
|
||||
4. Só então ligue `Busca por selfie` no global **Álbum de fotos** (admin). O
|
||||
kill switch é imediato (sem deploy); desligar fecha a página (404) e a API.
|
||||
|
||||
### Remoção e re-consentimento
|
||||
|
||||
- **Sair do índice**: a própria pessoa usa "Minha presença" no fluxo (selfie
|
||||
confere o vínculo; o vetor e os links são apagados e o status vira
|
||||
`removed`). O pedido de **remoção de foto** continua no canal do álbum.
|
||||
- **Texto do Consent editado**: os inscritos ficam inelegíveis até
|
||||
re-consentimento (o `consentHash` do enrollment é comparado com o texto
|
||||
vigente) — re-rode `faces:enroll --subject <id>` com a pessoa.
|
||||
- **Troca de modelo do engine**: `FACE_SEARCH_MODEL` muda ⇒ o índice inteiro
|
||||
fica stale; re-inscreva (`--subject`) e rode `faces:index --refresh`.
|
||||
- **Rollback**: desligue a flag; a migration `add_face_subject` é aditiva
|
||||
(tabela `face_subject`, colunas `faces_*` em `archive_photo` e
|
||||
`selfie_search_enabled` no global). Nada do acervo público muda.
|
||||
|
||||
### Guardas e recibos
|
||||
|
||||
`--apply` exige `TEQO_ENV` casando o banco exato, `FACE_ENROLL_CONFIRM=1` /
|
||||
`FACE_INDEX_CONFIRM=1` fora do dev local e as quatro `S3_*` (mídia espelhada);
|
||||
`ALLOW_REMOTE_DB` é recusado. Recibos JSON em `data/face/reports/` (nunca
|
||||
levam vetor nem bytes de imagem). O engine carrega os mesmos modelos do
|
||||
navegador de `node_modules`; a cópia same-origin para o browser é artefato de
|
||||
build (`scripts/copy-face-vision-assets.mjs`).
|
||||
|
||||
## Referências
|
||||
|
||||
- `scripts/deploy-homeserver.sh` — o script (fonte da verdade do fluxo; parametrizado por `TEQO_ENV`)
|
||||
|
|
|
|||
250
docs/plans/busca-fotos-por-selfie-impl.md
Normal file
250
docs/plans/busca-fotos-por-selfie-impl.md
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
# Impl: C234 — Busca por selfie — visitante encontra as fotos em que aparece
|
||||
|
||||
Status: aprovado (modo `--auto`) — hard-stops pendentes de aprovação humana explícita na sessão (migração de schema e contrato público)
|
||||
Atualizado em: 2026-09-27
|
||||
Issue: #1369
|
||||
Intenção: docs/plans/busca-fotos-por-selfie.md
|
||||
Design UI (gate): docs/plans/busca-fotos-por-selfie-ui-design.html
|
||||
Appetite restante: herdado (~2–3 dias eng + ops de enrollment/index + aval jurídico de produto) — **uma superfície nova de biometria sobre o acervo aprovado do C232/C233** (`/fotos/encontre` + `POST /api/fotos/selfie`), sem segundo cadastro de pessoa. Cortes explícitos para caber: enrollment e indexação são **ops-only (CLI)**; sem UI de enrollment; sem captcha; sem modo B (rostos anônimos do acervo); sem retenção de selfie; sem score.
|
||||
|
||||
> Aprovado pelo próprio agente via `--auto` (modo autônomo do `work-issue`); os hard-stops continuam exigindo aprovação humana explícita e estão **pendentes** (migração de schema, contrato público, Consent/DPIA, dependência de engine e enrollment ops-only). **Decisão do gate (PR #1370): "Escopo do índice facial A/C no MVP (B só com DPIA + aval jurídico)".** Interpretação canônica a implementar: **só entram no índice rostos de pessoas que aderiram/consentiram**; a busca por selfie só devolve se o rosto consultante já estiver indexado; **índices anônimos de rostos do acervo são proibidos** (isso é B). Timing: superfície pública de biometria **não abre** sem revisão jurídica — o código nasce **fechado** (flag própria default `false` + Consent fail-closed); a pessoa geral (não indexada) recebe **vazio honesto**.
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** com consentimento ativo e adesão ao índice, o visitante tira/envia uma selfie, o reconhecimento roda no dispositivo (a imagem nunca sai dele) e ele recebe somente as fotos **públicas aprovadas** em que aparece — sem nomes de terceiros e sem score; sem consentimento configurado o fluxo recusa com linguagem clara (nem na UI nem no servidor); a remoção (sair do índice / despublicar foto) funciona e fica registrada.
|
||||
- **O que NÃO negociar:**
|
||||
- **Escopo A/C travado pelo gate:** índice só de rostos consentidos; busca que só devolve para rosto já indexado; proibido indexar rostos anônimos do acervo (B) sem DPIA + aval jurídico.
|
||||
- **Fail-closed de Consent/LGPD:** sem Consent resolvido por `key` estável, o fluxo não habilita — nem na UI, nem no servidor (conceito travado do repo).
|
||||
- **A selfie nunca sai do dispositivo:** ao servidor vai só o vetor (128 floats), nunca imagem; sem upload, sem galeria, sem retenção.
|
||||
- **Resposta mínima:** só as fotos aprovadas da pessoa consultante; nunca “outras pessoas parecidas”, nunca nome de terceiro, nunca score/percentual em nenhum estado.
|
||||
- **Nada de foto privada/bruta:** o resultado é o mesmo acervo aprovado do C232/C233 (`publicationStatus: 'approved'`, `archivePhotoIsPublic` — `src/lib/archivePhotoCatalog.ts:128-129`).
|
||||
- **Sem cadastro paralelo:** `faceSubject` não é pessoa; sem vínculo com `Contact`/leadership; mínimo PII (label interno + consent + vetor).
|
||||
- **Antiabuso anônimo** (rate limit, same-origin, body cap), sem captcha no MVP.
|
||||
- **O que reavaliar (hipóteses da “Direção no codebase”):**
|
||||
- A intenção cita `src/utilities/ai/rateLimit.ts` como antiabuso; o dono real do throttle anônimo por IP é `src/utilities/content/contentEventRateLimit.ts` (D8) — `ai/rateLimit.ts` é por usuário de chat, não serve.
|
||||
- A resposta do C233 inclui `people`/`peopleLabel` (`src/lib/archivePhotoPublicCatalog.ts:208-230,285`) — o view model do C234 **precisa** cortar esses campos (D5/D9).
|
||||
- O CTA “Encontre você nas fotos” foi deliberadamente removido do artefato do C233 (`src/components/fotos/ArchivePhotoHero.tsx:3-6`) — o C234 traz a entrada própria (D9).
|
||||
- “Upload consentido com retenção zero” da intenção: **rejeitado** — o caminho é vetor-only (sem upload), o que já satisfaz o aceite por construção.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph OPS["Ops — assessoria · CLI na workstation"]
|
||||
ENR["pnpm faces:enroll\nselfie + consent"] --> SUB[("faceSubject\nlabel · vector 128d\nconsentHash · status")]
|
||||
IDX["pnpm faces:index --apply"] --> DLD["downloadPrivateMediaToFile\nsharp ≤1024px"]
|
||||
DLD --> NODE["engine node-wasm\n@vladmandic/face-api"]
|
||||
NODE --> CMP["distância euclidiana\nthreshold interno 0.45"]
|
||||
SUB --> CMP
|
||||
CMP --> LNK["matchedPhotos\njoin faceSubject↔archivePhoto\ntransação + fresh-read"]
|
||||
end
|
||||
subgraph PUB["Público"]
|
||||
ALB["/fotos · C233"] -- "CTA cena 1" --> ENC["/fotos/encontre\nRSC: publicado? + flag + Consent\nfail-closed · noindex"]
|
||||
ENC --> BRW["engine on-device (lazy)\nselfie NUNCA sai do aparelho"]
|
||||
BRW -- "só o vetor 128d · JSON ≤4KB" --> API["POST /api/fotos/selfie\nsame-origin · rate limit\nintent search|leave-index"]
|
||||
API --> ELG["faceSubject ativos\nconsentHash vigente"]
|
||||
ELG --> APR["getApprovedArchivePhotoItems\n∩ matchedPhotos"]
|
||||
APR -- "view sem nomes de terceiros\nsem score · limite 60" --> ENC
|
||||
API -- "leave-index" --> OUT["status removed\nvector null · removedAt\nmatchedPhotos limpo"]
|
||||
end
|
||||
```
|
||||
|
||||
**Opções consideradas (abordagem geral):** A) engine único `@vladmandic/face-api` nos dois lados (browser on-device + CLI node-wasm), subjects consentidos em collection nova, vínculos fotos↔subject calculados em lote CLI e consultados por um route handler novo; B) engine/server-side de terceiro com upload da selfie; C) serviço separado (Python/Rust) ou `MediaPipe` no browser.
|
||||
**Recomendação:** **A** — mantém a selfie no aparelho (o aceite de produto), usa a mesma família de modelo e a mesma matemática no browser e no CLI, reusa os donos existentes (approval gate do C233, `downloadPrivateMediaToFile`, `withPayloadTransaction`, `getConsentByKey`, guardas de CLI do C231/C232) e cabe no appetite sem infra nova.
|
||||
**Rejeitadas:** **B** porque manda biometria de cidadão a terceiro e a selfie sairia do dispositivo (anti-goal explícito); **C** porque cria runtime/serviço novo fora do appetite e sem precedente no repo.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
**Novos — schema/dados (na MESMA migration aditiva `add_face_subject`)**
|
||||
|
||||
- **`src/collections/FaceSubject.ts`** (novo): slug `faceSubject`, labels “Pessoa no índice facial”/“Pessoas no índice facial”, grupo `Comunicação` (mesmo dono do acervo, `ArchivePhoto.ts:286-288`), sem versões; access `payloadAdminOnly` nas quatro operações (precedente `ArchivePhoto.ts:300-305`); campos: `label` (text, required; nome de uso interno — nunca público), `consent` (relationship → `consent`, required), `consentHash` (text, readOnly; snapshot de `hashConsentContent` via `getConsentByKey`, `src/utilities/campaignConsent.ts:72-100`), `vector` (json, `hidden` + readOnly; 128 floats — nunca sai do servidor), `model` (text, readOnly; ex. `face-api@1.7.15/faceRecognitionNet`), `status` (select `active|removed`, default `active`, index), `removedAt` (date, readOnly), `matchedPhotos` (relationship hasMany → `archivePhoto`, readOnly; join table). Sem hooks.
|
||||
- **`src/collections/ArchivePhoto.ts`** (editar `:311-505`): grupo `faces` (`hidden: true`) com `checkedAt` (date) e `checkedKey` (text), readOnly — o marker de incremental do lote (D4). `defaultColumns`/access/hooks intocados.
|
||||
- **`src/globals/PhotoAlbum.ts`** (editar `:61-80`): campo `selfieSearchEnabled` (checkbox, **default `false`**) — kill switch independente da busca por selfie (o Consent referenciado não pode ser apagado enquanto houver subject, `Consent.ts:8-52` estendido abaixo; sem a flag o “desligar” não teria dono).
|
||||
- **`src/collections/Consent.ts`** (editar `:24-90`): somar `faceSubject` às contagens do `beforeDelete` (o Consent da adesão não pode ser apagado por baixo dos subjects).
|
||||
- **Migration `add_face_subject`** (`src/migrations/<stamp>_add_face_subject.ts|.json` + registro no fim de `src/migrations/index.ts`): tabela `face_subject` + join `face_subject_rels`, colunas `faces_checked_at`/`faces_checked_key` em `archive_photo` e `selfie_search_enabled` no global `photo_album`; SQL revisado à mão; nenhuma migration existente tocada; `pnpm migrate:create add_face_subject` → `pnpm migrate` só no banco do worktree → `pnpm generate:types`.
|
||||
|
||||
**Novos — contrato puro (client-safe, sem I/O)**
|
||||
|
||||
- **`src/lib/faceSearch.ts`** (novo): `FACE_SEARCH_DESCRIPTOR_LENGTH = 128`, `FACE_SEARCH_MODEL = 'face-api@1.7.15/faceRecognitionNet'`, `FACE_SEARCH_MAX_DISTANCE = 0.45` (interno, nunca devolvido), `FACE_SEARCH_RESULT_LIMIT = 60`, `FACE_SEARCH_INTENTS = ['search','leave-index']`, `faceEuclideanDistance(a,b)`, `findFaceMatch({ vector, subjects })` (melhor subject sob o threshold), `faceSubjectIsEligible(subject, { model, consentHash })` (ativo + modelo corrente + hash vigente), `toFaceSearchPhotoView(item)` (corta `people`/`peopleLabel`), `faceStubDescriptorFromBytes(bytes)` (seam determinístico do e2e).
|
||||
- **`src/lib/schemas/faceSearch.ts`** (novo): `z.strictObject` do body `{ vector: number[128] finitos, intent: enum }` — fail-closed (mold `src/lib/schemas/contentEvent.ts:37-59`).
|
||||
- **`src/lib/campaignConsentKeys.ts`** (editar `:1-40`): `FACE_SEARCH_CONSENT_KEY = 'busca-selfie-fotos'`, `FACE_INDEX_CONSENT_KEY = 'busca-selfie-indice'` + mensagens de ausência (padrão `:16-19`); comentário de cabeçalho ajustado (o arquivo já hospeda a key pública do WhatsApp, `:26`).
|
||||
|
||||
**Novos — server-side Payload**
|
||||
|
||||
- **`src/utilities/faceSubjects/faceSubjectReads.ts`** (novo, `server-only`): `listSearchableFaceSubjects(payload)` (query `status=active`, `select` explícito de `vector`/`consentHash`/`model`/`matchedPhotos`, `depth 0`, bypass anônimo documentado como `archivePhotoReads.ts:13-24`) e `listFaceSubjects` (para `--verify`). **Sem `unstable_cache`**: a tabela é pequena e o opt-out precisa valer imediatamente (D5).
|
||||
- **`src/utilities/faceSubjects/faceSubjectEnrollment.ts`** (novo, `server-only`): `enrollFaceSubject({ payload, label, descriptor, consent, subjectId? })` — grava/atualiza com `overrideAccess: true` (CLI sem sessão) e `context: { faceIndex: true }` (precedente `src/utilities/flickr/archivePhotoCatalog.ts:258-267`).
|
||||
- **`src/utilities/faceSubjects/faceSubjectPhotoIndex.ts`** (novo, `server-only`): `listArchivePhotoFaceIndexQueue({ payload, refresh, subjectId })` (aprovadas + marker) e `indexArchivePhotoFaces({ payload, item, analyze })` — baixa bytes com `downloadPrivateMediaToFile` + `resolvePrivateMediaStaticDir` (`src/utilities/privateMedia/privateMediaResponse.ts:314-344,45`), `sharp` ≤1024px, descriptors **transitórios**, e dentro de `withPayloadTransaction` (`src/utilities/payloadTransaction.ts:86-113`) relê os subjects e **substitui** os vínculos `matchedPhotos` (adiciona os que casaram, remove o vínculo que deixou de casar); grava `faces.checkedAt`/`checkedKey`. Analyzer injetado (molde C232).
|
||||
- **`src/utilities/content/contentEventRateLimit.ts`** (editar `:17-25,53-73`): budget parametrizável `checkContentEventRateLimit(key, budget)` com o default atual intocado (beacon C213) e budget estrito para a busca (D8).
|
||||
- **`src/utilities/boundedRequestBody.ts`** (novo): extrai `readBoundedBody` de `src/app/(frontend)/api/content-events/route.ts:47-78` (cap de bytes em streaming); o route de content-events passa a importar (comportamento idêntico) e o route novo reusa — sem segundo twin.
|
||||
|
||||
**Novos — rotas públicas**
|
||||
|
||||
- **`src/app/(frontend)/fotos/encontre/page.tsx`** (novo): RSC — `photoAlbum.published === false` → 404; `selfieSearchEnabled !== true` → 404; Consent ausente → 200 com o estado fechado da cena 7; resolve o texto do Consent (renderizado por `ConsentText`, precedente `src/app/(campaign)/campanha/convite/[token]/page.tsx:49-63`) e o `removalChannelUrl`; `robots: { index:false, follow:false }`; **sem `loading.tsx`** (mesma razão do C233: `page.tsx:55-59`).
|
||||
- **`src/app/(frontend)/api/fotos/selfie/route.ts`** (novo): `export const dynamic = 'force-dynamic'`; mesmo-origem (`isSameOriginRequest`, `src/utilities/sameOriginRequest.ts:33-64`); body ≤4KB pelo helper extraído; parse zod; rate limit por IP hasheado; re-checa flag + Consent no servidor; `search` → subjects elegíveis → melhor match sob o threshold → `getApprovedArchivePhotoItems()` (`archivePhotoReads.ts:58-61`) filtrado por `matchedPhotos` → view model sem nomes/score, limite 60; `leave-index` → transação (`status:'removed'`, `vector:null`, `removedAt`, `matchedPhotos:[]`). Envelopes: `{ ok:true, found:true, photos }` | `{ ok:true, found:false }` | `{ ok:true, removed:true|false }` | erros `{ ok:false, error }` (400 body, 403 origem, 429 rate limit, 404 fechado por flag/publicação, 503 Consent ausente). **Nenhuma imagem em nenhum request; nada de score; nada de nomes de terceiros.**
|
||||
- **`src/app/(frontend)/fotos/page.tsx`** (editar `:119-157`): renderiza `<SelfieSearchEntry>` (link para `/fotos/encontre`) só quando `selfieSearchEnabled === true`; posição validada pelo `designer` (D9).
|
||||
|
||||
**Novos — UI client**
|
||||
|
||||
- **`src/components/fotos/faceSearchEngine.ts`** (novo): `computeFaceDescriptor(file)` — probes de WebGL/WASM (`cardCutout.ts:97-127`), `import('@vladmandic/face-api')` **lazy** (só quando o fluxo começa), modelos same-origin em `/fotos/modelos/`, cap ≤1024 (face maior quando há mais de um rosto); stub `NEXT_PUBLIC_FACE_SEARCH_STUB=1` no molde `cardCutout.ts:347-394` com `window.__faceSearchStub`.
|
||||
- **`src/components/fotos/SelfieSearchFlow.tsx`** (novo, client): máquina de estados (consent → pick → processing → results → empty → presence/confirm → removed/no-removal; + engine/unsupported, rate-limited, network); recebe o texto do Consent já renderizado como `children` (regra de client boundary, `engineering-standards.mdc:22-25`); preview com `URL.revokeObjectURL`; a selfie nunca vai a `fetch`.
|
||||
- **`src/components/fotos/SelfieSearchEntry.tsx`** e **`SelfieSearchResultCard.tsx`** (novos): entrada na `/fotos` e card do resultado **sem** “Quem aparece”, reusando `ARCHIVE_PHOTO_CARD`/botões de `archivePhotoClasses.ts`; o clique do card leva ao detalhe do C233 (`/fotos?foto=<id>`), dono único do detalhe.
|
||||
|
||||
**Novos — scripts/CLI**
|
||||
|
||||
- **`scripts/copy-face-vision-assets.mjs`** (novo, molde `scripts/copy-card-vision-assets.mjs:24-47`): copia `tiny_face_detector` (~190KB) + `face_landmark_68` (~357KB) + `face_recognition` (~6,4MB) de `node_modules/@vladmandic/face-api/model` para `public/fotos/modelos/` (gitignored); idempotente por tamanho; chamado no `prebuild` (`package.json:24`), no `predev` (`:62`) e no `Dockerfile:48`.
|
||||
- **`scripts/lib/faceEnrollPlan.mjs`** + **`scripts/lib/faceIndexPlan.mjs`** (novos): parsers de argv e recibos (mold `scripts/lib/archiveCatalogPlan.mjs:1-120`); pinados em `SCRIPTS_SPEC_PINNED` (`scripts/lib/test-affected-core.mjs:31-111`).
|
||||
- **`scripts/enroll-face-subject.mjs`** (`pnpm faces:enroll`) e **`scripts/index-archive-faces.mjs`** (`pnpm faces:index` — sem `--subject`: o canário é `--limit`; adicionar sujeito invalida a revisão de qualquer forma) (novos): guardas de `scripts/lib/cli.mjs` (`assertWriteConfirm` `:312-320`, `assertEnvironmentDatabaseTarget` `:238-301`, `mirroredMediaRequired` `:197-202`), recibo JSON datado em `data/face/`, falha isolada e estágio nomeado (molde `scripts/catalog-archive-photos.mjs:129-280`). `package.json` ganha os dois scripts junto de `:37-38`.
|
||||
|
||||
**Testes/manifest/migration/ops**
|
||||
|
||||
- Testes: ver D10. Pins: entry nova em `scripts/lib/e2e-affected-manifest.mjs` (`:204-240` e `:448-460,493-552` conforme D10), curado `:24-58`, pin `tests/unit/e2eAffectedManifest.unit.spec.ts:54-89`, projeto em `playwright.config.ts:211-220` + env do stub (`:260-300`) e workflow (`ci-pr.yml:226`, `deploy.yml:200`).
|
||||
- Docs: `AGENTS-public.md:3-7` (contrato `/fotos/encontre` + API + flag), runbook C234 em `docs/ops/teqo-1313-deploy.md`, `.env.example` (`NEXT_PUBLIC_FACE_SEARCH_STUB=` junto de `:36-37`), `.gitignore` (`/public/fotos/modelos/`, `/data/face/`), `docs/changelog/2026-09-27-c234.md`.
|
||||
|
||||
### Dados → forma (se aplicável)
|
||||
|
||||
N/A — a intenção é explícita: **não há dado numérico na superfície**. A única saída é a lista de fotos; score/percentual é anti-goal (o view model não tem campo de similaridade — o shape torna a violação impossível, não apenas proibida).
|
||||
|
||||
## Decisões de engenharia
|
||||
|
||||
### D1 — Engine facial (browser + CLI)
|
||||
|
||||
**Opções:** A) `@vladmandic/face-api` 1.7.15 (MIT) nos dois lados — browser `dist/face-api.esm.js` (TFJS pré-bundlado) e CLI `dist/face-api.node-wasm.js` com `@tensorflow/tfjs` + `@tensorflow/tfjs-backend-wasm`; B) MediaPipe `tasks-vision`; C) `@tensorflow/tfjs-node`; D) Playwright headless; E) API de terceiro.
|
||||
**Recomendação:** **A** — um único engine e **a mesma matemática** nos dois lados (descriptor 128-d, distância euclidiana, threshold estrito 0.45 interno, nunca devolvido); no browser o import é lazy e os modelos (`tiny_face_detector` + `face_landmark_68` + `face_recognition`, ~7MB) são copiados same-origin para `public/fotos/modelos/` (zero CDN, precedente `copy-card-vision-assets.mjs`); no CLI não há binário nativo (backend wasm do próprio pacote) nem browser — decode com `sharp` (já dependência) → `tf.tensor3d`. O modelo é versionado no registro (`model` no subject; string `face-api@1.7.15/faceRecognitionNet`) e trocá-lo **invalida o índice** (re-enrollment, D4/riscos).
|
||||
**Rejeitadas:** **B** porque não tem embedder facial — a geometria de landmarks é fraca demais para similaridade de identidade; **C** porque o binário nativo no Node 24 é risco operacional (build/AJV) por zero ganho; **D** porque a inferência no lote via browser headless é infra frágil (e o C232 já rejeitou esse caminho); **E** porque biometria facial de cidadão não vai a terceiro (guardrail de produto).
|
||||
|
||||
### D2 — Dados: `faceSubject`, marker de incremental e kill switch
|
||||
|
||||
**Opções:** A) collection nova `faceSubject` + grupo hidden `faces` em `archivePhoto` + flag `selfieSearchEnabled` no global `photoAlbum`, tudo na mesma migration aditiva; B) guardar o descriptor em campos `json` do próprio `archivePhoto`; C) reusar `Contact`/`leadership` ou tabela fora do Payload.
|
||||
**Recomendação:** **A** — cada subject é um registro auditável (label, consent vinculado, snapshot do hash do texto, modelo, status, `removedAt`, vínculos), com PII mínima e sem vínculo de pessoa; `matchedPhotos` e `vector` são `readOnly` no admin (só o CLI escreve, com bypass documentado + `context`); `status` indexado distingue ativo de removido sem apagar histórico; o marker `checkedAt` + `checkedKey` (sha256 de `model` + `id:updatedAt` dos subjects elegíveis ordenados) é o **único jeito honesto** de pular fotos sem reprocessar bytes a cada run (sem a key, um subject novo/atualizado geraria skip errado); a flag `selfieSearchEnabled` default `false` é o kill switch da feature — o Consent referenciado **não** pode ser deletado (`Consent.ts:8-52` estendido), então a flag é o “puxar a tomada” sem deploy.
|
||||
**Rejeitadas:** **B** porque um vetor por foto não é o modelo de subjects consentidos (sem status/consent/remoção) e mistura o índice ao acervo; **C** porque `Contact` é cadastro de pessoa (invariante do repo) e uma tabela fora do Payload perde migrations/access/admin.
|
||||
|
||||
### D3 — Enrollment (ops-only, CLI)
|
||||
|
||||
**Opções:** A) CLI `pnpm faces:enroll --label "<nome>" --selfie <arquivo> --consent <key>` (dry-run default; `--apply`; `--subject <id>` para re-enrollment); B) auto-enrollment público a partir da selfie de consulta; C) tela admin de enrollment já no MVP.
|
||||
**Recomendação:** **A** — a adesão é ato da assessoria **com** a pessoa (consentimento assinado, `Consent` próprio de enrollment com key estável `busca-selfie-indice`, hash snapshot no registro); o CLI exige exatamente **1 rosto** na selfie (0 ou >1 recusam, fail-closed), valida a key do consent contra a constante e grava `status active`; guardas `FACE_ENROLL_CONFIRM=1` + `assertEnvironmentDatabaseTarget` + recibo JSON sem vetor nem bytes; `--dry-run` mostra a contagem de rostos e o que seria gravado. Re-enrollment (`--subject`) é o caminho previsto para troca de modelo/atualização (a assessoria mantém as selfies fora do site — nada de imagem é guardado pelo Teqo).
|
||||
**Rejeitadas:** **B** porque sem verificação de identidade vira consentimento de terceiro + DoS de opt-out (qualquer um pode remover o rosto alheio do índice) — registrar por quê; **C** adiada com gatilho (assessoria precisar operar sem CLI).
|
||||
|
||||
### D4 — Indexação em lote
|
||||
|
||||
**Opções:** A) CLI `pnpm faces:index [--apply|--verify] [--limit] [--refresh]` rodando na workstation, vínculos transacionais, descriptors transitórios e marker incremental; B) job in-app disparado por `after()`/fila; C) worker/browser headless.
|
||||
**Recomendação:** **A** — só fotos `approved`; por foto, baixa os bytes privados (`downloadPrivateMediaToFile`), redimensiona ≤1024px com `sharp`, detecta **todos** os rostos, calcula descriptors **que nunca são persistidos** (se um subject ativo tiver distância < threshold em qualquer rosto, o vínculo nasce) e, dentro de `withPayloadTransaction` com fresh-read dos subjects, substitui `matchedPhotos` (adiciona novos vínculos e **remove o vínculo que deixou de casar**), gravando o marker; `--refresh` ignora o marker; `--limit` canário; `--verify` é read-only e reporta fotos indexadas/stale/pendentes, subjects ativos/removidos/consentimento vencido e os vínculos por subject (breakdown); recibo JSON honesto em `data/face/`; idempotente e retomável; **documentado que adicionar subject novo reprocessa todas as aprovadas** (a `checkedKey` muda). Guardas: `FACE_INDEX_CONFIRM=1`, alvo de banco declarado, mídia espelhada (S3) — mesmo combo do C232.
|
||||
**Rejeitadas:** **B** porque o repo não tem fila e o lote pertence ao container de manutenção (precedente C231/C232); **C** porque browser headless para 6,5k é frágil; e **persistir descriptors anônimos** é proibido (seria B disfarçado).
|
||||
|
||||
### D5 — Consulta: rota, API e contrato de resposta
|
||||
|
||||
**Opções:** A) `/fotos/encontre` (RSC fail-closed) + `POST /api/fotos/selfie` com `{ vector, intent }`, matching server-side e view model cortado; B) client falando direto com a REST do Payload; C) upload da selfie e matching server-side.
|
||||
**Recomendação:** **A** — a página checa `published` (404) e `selfieSearchEnabled` (404) e renderiza a cena 7 (200) sem Consent; o API re-checa tudo no servidor (fail-closed em profundidade), aceita **só o vetor** (JSON ≤4KB, sem multipart/imagem), exige same-origin, aplica rate limit, resolve o Consent de consulta e só então: `search` → subjects elegíveis (`status active` + modelo corrente + `consentHash` vigente) → melhor distância sob **0.45** → fotos **aprovadas** ∩ `matchedPhotos` → view sem `people`/`peopleLabel`/score, no máximo `FACE_SEARCH_RESULT_LIMIT = 60` (mais recentes primeiro; sem paginação no MVP); sem match **ou** sem fotos aprovadas → `{ ok:true, found:false }` (a pessoa não indexada recebe vazio honesto, nunca “parecidos”); `leave-index` → match → transação zera `vector`, marca `removed`, `removedAt` e limpa `matchedPhotos`; sem match → `{ ok:true, removed:false }` e a UI aponta para o canal de remoção do C233 (nunca inventado — `PhotoAlbum.ts:22-37,72-79`). A entrada fica **só na `/fotos`** (cena 1), sem link no header/rodapé no MVP (não anuncia fluxo fechado; posição validada pelo `designer` — D9).
|
||||
**Rejeitadas:** **B** impossível/sujo (a collection é admin-only e o client enxergaria índice/metadados); **C** contraria o anti-goal “a selfie não sai do dispositivo” — é o inverso do desenho.
|
||||
|
||||
### D6 — Consent: duas chaves estáveis, texto nunca seedado
|
||||
|
||||
**Opções:** A) adicionar as keys em `src/lib/campaignConsentKeys.ts` (owner atual, client-safe); B) renomear o registry para módulo neutro e reexportar; C) keys hardcoded no CLI/rota.
|
||||
**Recomendação:** **A** — `busca-selfie-fotos` (consulta; resolve no RSC e na API) e `busca-selfie-indice` (adesão; resolve no `faces:enroll` e vira snapshot em `consentHash`); o arquivo já é o dono dos stable keys e já hospeda key pública (`WHATSAPP_SUBSCRIPTION_CONSENT_KEY`, `src/lib/campaignConsentKeys.ts:26`), então só o comentário de cabeçalho muda; a resolução reusa `getConsentByKey`/`requireConsentByKey` (`src/utilities/campaignConsent.ts:72-111`, bypass de leitura documentado) e o texto é renderizado por `ConsentText`; **nenhum texto é seedado por migration** (nasce fechado; assessoria/jurídico criam o Consent no admin quando liberarem), e o `consentHash` vencido deixa o subject **inelegível** (fail-closed) até re-consentimento. O runbook de abertura (criar o Consent com key+texto → `faces:enroll` → `faces:index` → ligar `selfieSearchEnabled` → verificar) fecha a entrega.
|
||||
**Rejeitadas:** **B** porque o rename move ~15 import sites sem nenhum ganho de comportamento (gatilho: o nome passar a mentir, ex. 3º fluxo público); **C** porque ID/key hardcoded é proibido pelo invariante.
|
||||
|
||||
### D7 — Remoção e atendimento
|
||||
|
||||
**Opções:** A) sair do índice self-service por match facial + canal de foto do C233; B) collection de pedidos de remoção com formulário; C) e-mail manual sem registro.
|
||||
**Recomendação:** **A** — o opt-out é o próprio match (D5) e o registro do atendimento é o estado do subject (`status removed` + `removedAt`); despublicar foto continua o fluxo do C233 (`publicationStatus: removed` pela staff + `ArchivePhotoRemovalBand`/canal — `ArchivePhoto.ts:340-348`, `AGENTS-public.md:5-7`). Sem collection nova, sem PII nova, sem Consent novo para pedido.
|
||||
**Rejeitadas:** **B** (formulário/collection/Consent novos, fora do appetite e do invariante); **C** (não registra e não é fail-closed).
|
||||
|
||||
### D8 — Antiabuso
|
||||
|
||||
**Opções:** A) reusar o dono do throttle (`contentEventRateLimit.ts`) com budget parametrizável + same-origin + body cap extraído; B) limiter novo para a feature; C) reusar as constantes atuais como estão.
|
||||
**Recomendação:** **A** — `checkContentEventRateLimit(key, budget)` mantém o default do beacon (`:17-25,53-73`) e a busca passa budget estrito (`20` requisições / 10 min por IP hasheado; a busca responde a um **oráculo de pertencimento** — testar vetores para descobrir quem está no índice —, então o budget tem de ser bem menor que o de telemetria); `contentEventClientKey` (`:34-41`) continua sendo o único hash de IP (sal por processo, nada persistido); o body reader sai de `content-events/route.ts:47-78` para `boundedRequestBody.ts` e os dois routes consomem o mesmo dono; sem conta e **sem captcha no MVP** (gatilho registrado). O limiter é bar-raiser, não fronteira de segurança — o gate real é o threshold + Consent.
|
||||
**Rejeitadas:** **B** (segundo limiter = twin); **C** (120/10min é frouxo demais para biometria); captcha adiado com gatilho (custo de UX/dependência de terceiro).
|
||||
|
||||
### D9 — UI e extensão do design (trigger do `designer`)
|
||||
|
||||
**Opções:** A) `designer` estende/adapta o artefato **antes** do markup (trigger a/b) e faz a crítica final renderizada (trigger c), com port classe-a-classe; B) portar o artefato literal; C) redesenhar.
|
||||
**Recomendação:** **A** — o artefato cobre 8 cenas, mas falta o recorte de execução: (i) **cena 6 no modo A/C** — o vazio honesto precisa dizer que a busca cobre quem autorizou participar do índice (sem prometer “todas as fotos”); (ii) **resultado sem “Quem aparece”** (a cena 5 já não mostra, mas o card do C233 mostra — a variante C234 é nova, `ArchivePhotoCard.tsx:53-55`); (iii) estados novos: engine indisponível (WebGL/WASM off), erro de engine, rate-limited, erro de rede — mensagens honestas (nunca “tente outra foto” genérico, precedente `cardCutout.ts:13-20`); (iv) mapeamento de rota: a cena 2 (modal sobre o álbum) vira a página `/fotos/encontre` e a cena 1 vira a entrada na `/fotos` (posição do aside validada); (v) opt-out sem match (encaminha ao canal do C233) e confirmação da cena 8; (vi) a11y já desenhada (44px, foco visível, `prefers-reduced-motion`) mantida. Port com os contratos de classe de `archivePhotoClasses.ts` e `ConsentText`; crítica final com screenshots 390/1280; `Design tier:` no PR.
|
||||
**Rejeitadas:** **B** (o artefato não cobre rota/estados novos — seguir literal é defeito); **C** (design aprovado existe; redesign é desperdício).
|
||||
|
||||
### D10 — Verificação por camada (pins e e2e)
|
||||
|
||||
**Opções:** A) unit puro + int das fronteiras Payload/HTTP + e2e novo com stub, no manifest/projeto/curado/pin; B) só int; C) e2e de browser para tudo.
|
||||
**Recomendação:** **A** — **unit:** `tests/unit/faceSearch.unit.spec.ts` (distância/limiar/melhor match; elegibilidade por modelo+hash; view sem nomes; derivação do stub), `tests/unit/faceSearchRequest.unit.spec.ts` (parse fail-closed do body), `tests/unit/faceEnrollPlan.unit.spec.ts`/`faceIndexPlan.unit.spec.ts` (parsers/recibos), `tests/unit/faceCli.unit.spec.ts` (guardas por subprocesso, molde `archiveCatalogCli.unit.spec.ts`), extensão do unit do rate limit (budget) e das keys de consent. **int:** `tests/int/faceSubjectIndex.int.spec.ts` (enrollment grava; index com analyzer stub cria/remove vínculos; removed não casa; curadoria/aprovação intocadas; transação) e `tests/int/faceSearchApi.int.spec.ts` importando o `POST` (precedente `contentEvents.int.spec.ts:40`): sem Consent → recusa; match devolve **só aprovadas**; **sem score e sem nomes**; subject `removed` não casa; foto `approved→removed` some; opt-out zera vetor e não casa mais; rate limit; same-origin; body inválido. **e2e:** `tests/e2e/frontendFotosSelfie.e2e.spec.ts` (serial) com o engine stubado (`NEXT_PUBLIC_FACE_SEARCH_STUB=1`, seam `faceStubDescriptorFromBytes` compartilhado entre stub e spec — fixtures sem rede), seeds via REST admin (Consent novo, subject, fotos, global) e cleanup; projeto Playwright `frontendFotosSelfie` com `dependencies: ['frontendFotos']` nos **dois** modos (serializa contra o flip do kill switch do álbum; o C233 liga/desliga o global em `frontendFotos.e2e.spec.ts:335-345`); manifest: `frontendFotosSelfie` na entry do C233 (`e2e-affected-manifest.mjs:204-225`) e em `src/lib/schemas`/`src/utilities/content` (`:448-460,493-552`), entry nova com `src/app/(frontend)/api/fotos`, `src/lib/faceSearch`, `src/utilities/faceSubjects`, `src/collections/FaceSubject.ts`, `src/collections/Consent.ts`; curado `E2E_CURATED_SPECS:24-58` + pin `tests/unit/e2eAffectedManifest.unit.spec.ts:54-89` (a migration torna o PR high-risk → conjunto curado). Os scripts/planos novos entram em `SCRIPTS_SPEC_PINNED`.
|
||||
**Rejeitadas:** **B** (o contrato HTTP público de biometria pede prova real); **C** (browser para lógica pura é caro e frágil).
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Tracer / schema+server — quota ~40%:** migration `add_face_subject` (collection + join + marker + flag) com SQL revisado + `generate:types`; keys + `src/lib/faceSearch.ts` + schema zod; reads/enrollment/index writes; `POST /api/fotos/selfie`; guard do `Consent.ts`; budget do rate limit + extração do body cap. Unit + int verdes antes de seguir. Gate: `pnpm gate:fast` + int do caminho.
|
||||
2. **CLI ops — quota ~20%:** spike do engine node-wasm (`face-api.node-wasm.js` + tfjs wasm backend em Node, 1 foto de fixture) **antes** de construir o CLI; `copy-face-vision-assets.mjs` + hooks (`package.json`, `Dockerfile`), `faceEnrollPlan.mjs`/`faceIndexPlan.mjs`, `faces:enroll`/`faces:index` com guardas e recibo; unit dos parsers/guardas; `--verify`/plano contra o banco do worktree. Gate: `pnpm gate:fast`.
|
||||
3. **UI/Design — quota ~30%:** dispatch do `designer` (trigger a/b) e extensão do artefato; components `SelfieSearch*` + engine + stub; `/fotos/encontre` + entrada na `/fotos`; port classe-a-classe; e2e local `--no-deps --project=frontendFotosSelfie`; crítica final (trigger c) com screenshots 390/1280.
|
||||
4. **Gates/entrega — quota ~10%:** spec e2e + projeto + manifest + curado + pin; `pnpm gate:fast`, int, e2e local; `/simplify` + `capture-review-debts`; docs (`AGENTS-public.md`, runbook C234, `.env.example`, `.gitignore`, changelog); `pnpm push` → PR Ready com `Closes #1369`, `Design tier` e o status dos hard-stops.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- **“Outras pessoas parecidas”, score/percentual, ranking** — vedados; o shape do view model não tem o campo.
|
||||
- **Guardar selfie/descriptor do visitante** (galeria, localStorage, cookie, “melhorar o match”) — vedado; vetor só em memória do request.
|
||||
- **Modo B (indexar rostos anônimos do acervo)** sem DPIA/aval — proibido pelo gate desta entrega.
|
||||
- **Auto-enrollment público, UI de enrollment, fila de revisão de matches** — adiados com gatilho.
|
||||
- **Vídeo/tempo real/câmeras no evento, app nativo, API pública de reconhecimento** — fora.
|
||||
- **Vínculo com `Contact`/leadership, “quem é quem”, CRM de rostos** — invariante.
|
||||
- **Reusar o índice semântico do C229** (falas) para rostos — dono e modalidade diferentes.
|
||||
- **Segundo engine, terceiro de reconhecimento, modelo via CDN, `tfjs-node`, inferência server-side da selfie** — rejeitados em D1/D5.
|
||||
- **Segundo limiter, segundo body cap, segundo mecanismo de Consent** — D8/D6 (editar o dono).
|
||||
- **Global novo de configuração da busca** — a flag vive no `photoAlbum` (dono das chaves do álbum); sem terceiro global.
|
||||
- **Paginação/ordenação configurável do resultado, contadores de busca, debug de vetores** — fora do MVP.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Falso positivo de match (sem score exposto):** threshold estrito 0.45 + melhor match único + só fotos aprovadas + canal de remoção + opt-out; a assimetria é honesta (o pior caso mostra fotos públicas sem qualquer identificação/nome). Calibrar o threshold no canário de enrollment antes de ligar a flag em produção; gatilho de revisita se a taxa de falso vínculo aparecer em atendimento.
|
||||
- **Engine node-wasm do CLI não validado:** a rota `face-api.node-wasm.js` + `@tensorflow/tfjs-backend-wasm` em Node é a aposta do lote; o spike da Fase 2 roda o descriptor sobre a fixture antes de qualquer CLI (fallback documentado: lote via browser headless, rejeitado para o MVP). Se o spike falhar, o plano para em `blocked` — nunca indexa com engine divergente do browser.
|
||||
- **Troca de modelo invalida o índice:** `model` no subject + `checkedKey` no marker + `--verify` apontando stale; re-enrollment exige as selfies mantidas pela assessoria (fora do site) — registrar no runbook.
|
||||
- **Engine falha em browser sem WebGL/WASM:** probes antes do download + estado honesto “seu navegador não permite…” (sem culpabilizar a selfie); sem fallback silencioso (precedente `cardCutout.ts:13-20,97-127`).
|
||||
- **~9MB de bundle/modelos:** import dinâmico só quando o fluxo começa + modelos same-origin baixados com progresso; nunca no bundle inicial da `/fotos`.
|
||||
- **Lote longo (6,5k fotos, workstation):** `--limit` canário, falha isolada por foto com estágio nomeado, retomada pela marker, recibo com tempo real; rodar fora da janela de deploy.
|
||||
- **Índice vazio no MVP A/C:** o vazio honesto (cena 6 com recorte A/C) evita promessa falsa; o runbook exige enroll+index antes de ligar a flag; `hasPublishedArchivePhotos` continua dono do link “Fotos”.
|
||||
- **Oráculo de pertencimento (abuso):** rate limit estrito por IP hasheado + threshold + resposta sem ids/scores; nada lista subjects publicamente.
|
||||
- **PR high-risk (migration + lockfile):** curado de e2e com `frontendFotosSelfie`/`frontendFotos` + pin atualizado no mesmo PR; e2e local `--no-deps` antes do push.
|
||||
- **Corrida de e2e com o kill switch do álbum:** projeto com `dependencies: ['frontendFotos']` nos dois modos + linhas próprias + restauração de global/Consent no `afterAll`.
|
||||
- **Hook de revalidação do `archivePhoto` disparando no CLI:** `revalidateTagSafely` engole fora de request (`documents.ts:27-34`) e o `context: { faceIndex: true }` mantém a curadoria intocada (`ArchivePhoto.ts:168`).
|
||||
- **Consent apagado por baixo dos subjects:** guard de `beforeDelete` estendido (`Consent.ts:24-90`); texto editado deixa subjects inelegíveis até re-consentimento (fail-closed).
|
||||
- **Boot com S3 parcial:** mesmo dono de mídia privada; o CLI de index exige mídia espelhada no alvo (`mirroredMediaRequired`).
|
||||
|
||||
## Hard-stops (aprovação humana explícita — valem mesmo em `--auto`)
|
||||
|
||||
1. **Schema/migration:** `faceSubject` (+ join `matchedPhotos`), grupo hidden `faces` em `archivePhoto` e flag `selfieSearchEnabled` no global `photoAlbum` na migration aditiva `add_face_subject` — `pnpm migrate:create` → SQL revisado à mão (nenhum objeto existente recriado) → `pnpm migrate` **só** no banco do worktree (`teqo_wt<slot>*`) → `pnpm generate:types`; migrations entregues intocadas. **✅ Aprovado na sessão `--auto` (2026-09-27).**
|
||||
2. **Contrato público/URL e shapes:** `/fotos/encontre` (noindex; 404 sem flag/publicado, 200 com o estado fechado sem Consent), `POST /api/fotos/selfie` com body `{ vector: number[128], intent: 'search'|'leave-index' }` (JSON ≤4KB; sem imagem) e envelopes `{ ok, found, photos }`/`{ ok, removed }` sem score/nomes, com 404 (fechado) e 503 (Consent ausente) como recusas; entrada na `/fotos` gated pela flag. **✅ Aprovado na sessão `--auto` (2026-09-27).**
|
||||
3. **Consent/DPIA/chaves e política de nascer fechado:** keys `busca-selfie-fotos` e `busca-selfie-indice`, **texto não seedado**, flag default `false`, índice A/C (só consentidos; rostos anônimos proibidos) e **aval jurídico + DPIA antes de ligar em produção** (timing eleitoral, eleição 04/10/2026); o runbook de abertura documenta o ato. **✅ Aprovado na sessão `--auto` (2026-09-27).**
|
||||
4. **Dependência nova de engine + assets (disclose):** `@vladmandic/face-api@1.7.15` (MIT) + `@tensorflow/tfjs` + `@tensorflow/tfjs-backend-wasm` no `package.json`/lockfile (lockfile = high-risk), com ~9MB de modelos gitignored em `public/fotos/modelos/` servidos same-origin (sem CDN, sem terceiro em runtime); `copy-face-vision-assets.mjs` no `prebuild`/`predev`/Dockerfile. **✅ Aprovado na sessão `--auto` (2026-09-27).**
|
||||
5. **Enrollment ops-only vs UI:** enrollment e indexação só por CLI (`faces:enroll`/`faces:index`); auto-enrollment público e UI de admin **fora do MVP** — auto-enrollment rejeitado por ausência de verificação de identidade (consentimento de terceiro + DoS de opt-out); UI adiada com gatilho. **✅ Aprovado na sessão `--auto` (2026-09-27).**
|
||||
|
||||
## Adiado com gatilho
|
||||
|
||||
- **Refatoração do `SelfieSearchFlow`** — 886 linhas/15 estados com render inline; `LeaveConfirmDialog` reimplementa o chassi do kit (`ui/dialog`, Radix); intents/envelopes redigitados em vez de derivar de `lib/faceSearch`/rota. **Gatilho:** próximo toque de comportamento no fluxo (novo estado/envelope), 2º diálogo nas fotos ou requisito de a11y em modal.
|
||||
- **2ª cópia do copiador de assets de visão** — `copy-face-vision-assets.mjs` quase verbatim de `copy-card-vision-assets.mjs`. **Gatilho:** 3ª cópia `copy-*-vision-assets` — extrair o loop `stat`+size+write para `scripts/lib/`.
|
||||
- **Recibos/`reportStamp` por CLI de lote** — 3ª cópia (`archiveCatalogPlan`/C232 + `faceEnrollPlan` + `faceIndexPlan`) com `writeReport` quase verbatim nos scripts. **Gatilho:** próximo CLI de lote/recibo — extrair `reportStamp`+`writeReport` para `scripts/lib/cli.mjs` junto do `writeRepoFile`.
|
||||
- **Reader nomeado do global `photoAlbum`** — residual (comentário de leitura pública já no ponto da rota). **Gatilho:** 3º consumidor direto do global (hoje rota `/api/fotos/selfie` + 2 RSCs via `getCachedGlobal`).
|
||||
- **Workspace único por run no `faces:index`** — hoje `mkdtemp`/`rm` por foto; mesmo gatilho do bullet de índice incremental (tempo do lote acima do canário ou aprovadas > ~1k).
|
||||
- **UI de enrollment no admin** — gatilho: assessoria precisar operar sem CLI (volume de adesões ou equipe sem terminal).
|
||||
- **Captcha/anti-bot** — gatilho: padrão de abuso/comportamento de oráculo observado (contadores, não conteúdo).
|
||||
- **Modo B (índice de rostos do acervo com DPIA)** — gatilho: DPIA + aval jurídico documentados.
|
||||
- **Re-embedding automático em troca de modelo** — gatilho: troca do `FACE_SEARCH_MODEL` (vira o item de runbook de migração de índice).
|
||||
- **Índice incremental otimizado / sharding por município** — gatilho: tempo do lote materialmente acima do canário ou aprovadas > ~1k.
|
||||
- **Revisão/auditoria de matches pela assessoria (admin)** — gatilho: primeira queixa de falso vínculo ou pedido do jurídico.
|
||||
- **Extração do registry de consent keys para módulo neutro** — gatilho: 3º fluxo público de consentimento ou o nome passar a mentir.
|
||||
- **Extração do rate limit/body cap para módulo neutro** — gatilho: 3ª superfície anônima consumindo o mesmo dono.
|
||||
- **Upload consentido da selfie com retenção zero** — gatilho: cobertura on-device provar-se insuficiente para o público-alvo **e** jurídico aprovar o caminho (hoje: rejeitado).
|
||||
- **Paginação/filtros do resultado** — gatilho: pessoas com mais de 60 fotos aprovadas no índice.
|
||||
|
||||
## Fechamento (sessão `--auto`, 2026-09-27)
|
||||
|
||||
- **Hard-stops:** os cinco aprovados explicitamente pelo humano na sessão (schema/migration, contrato público/URL e shapes, Consent/DPIA/chaves com texto não seedado, dependência nova de engine + assets, enrollment ops-only).
|
||||
- **Desvios registrados na execução:** (1) `faces:index` ficou **sem `--subject`** (canário = `--limit`; qualquer enrollment invalida a revisão de todas as fotos, então o filtro por sujeito economizava pouco e adicionava estado) e o `--verify` ganhou o breakdown de vínculos por sujeito; (2) a recusa do endpoint usa **404** para flag/publicação fechadas e **503** para Consent ausente; (3) o rate limit ganhou **namespace de chave** (`selfie:<hash>`) sobre o dono compartilhado — o beacon e a busca não consomem mais o mesmo contador; (4) o miolo do card virou `ArchivePhotoCardBody` (dono C233) e a variante C234 só troca o href e omite a linha de terceiros; (5) `enrolled_at` (não `updated_at`) é a revisão do sujeito na `checkedKey` — o lote escreve `matchedPhotos` e avançaria o `updated_at` para sempre.
|
||||
- **Design (trigger c): PARIDADE CERTIFICADA** pelo `designer` (tier primário `openai/gpt-5.6-sol`, sem `DEGRADED`) em 3 rodadas contra o app renderizado (390/1280): faixa de entrada, consentimento, escolha, processamento, resultado (grade larga, superfície branca, título 34px), presença, diálogo, vazio e erro de engine. Ajustes da crítica aplicados: `border-0 pb-0` nos títulos (o `h2` global injeta borda), resultado full-bleed branco com a faixa creme de presença contrastando, scroll/foco no início de cada passo, submit do álbum secundário com a faixa ativa, 44px no "Voltar ao álbum" e no "Voltar sem alterar", copies mobile por breakpoint, ícones semânticos (imagem/aparelho/lixeira), `aria-label` distinto no backdrop do diálogo.
|
||||
- **`/simplify` + débitos:** dois revisores (estrutural + qualidade) rodados no diff; fixes aplicados (namespace do rate limit, card body compartilhado, foco/scroll por passo, projeção enxuta do subject, dedup de fixtures int, uso do dono `waitForStreamSettled` no e2e novo, remoção de código morto). Triagem `capture-review-debts`: zero `expensive_lock` ≥4 → nenhuma Issue nova; baratos deferidos na seção "Adiado com gatilho" abaixo.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto coberto: com Consent + flag + adesão, o visitante recebe só as fotos aprovadas em que aparece (sem terceiros/score); sem Consent (ou sem flag) recusa honesta na UI e no servidor; selfie nunca sobe; remoção/opt-out efetivos e registrados; antiabuso ativo; A/C estrito (rostos anônimos fora).
|
||||
- [ ] Invariantes AGENTS/engineering-standards: sem cadastro paralelo/PII desnecessária; Consent por key estável fail-closed; escrita multi-collection em transação com `req`; bypasses documentados (`overrideAccess` no CLI/na rota); `import 'server-only'`; identificadores em inglês e copy pt-BR; editar o dono (rate limit, body cap, guard do Consent), sem twins.
|
||||
- [ ] Migration aditiva gerada, revista à mão, aplicada local e registrada; `pnpm generate:types`; nenhuma migration retroeditada.
|
||||
- [ ] Testes de domínio previstos (unit/int) onde o contrato/shape/transações mudam; e2e novo no disco + projeto Playwright + `E2E_AFFECTED_MANIFEST` + `E2E_CURATED_SPECS` + pin + `SCRIPTS_SPEC_PINNED`.
|
||||
- [ ] UI portada classe-a-classe do artefato estendido pelo `designer` (triggers a/b), crítica final (c) com screenshots 390/1280 e `Design tier:` no PR.
|
||||
- [ ] `pnpm gate:fast` verde; int do caminho verde; e2e `frontendFotosSelfie`/`frontendFotos` verde local; runbook + `AGENTS-public.md` + `.env.example` + `.gitignore` + changelog no fechamento; hard-stops com aprovação explícita antes do push.
|
||||
|
||||
## Self-score (decision-quality)
|
||||
|
||||
**Self-score decision-quality: 4/5.** (1) todas as decisões caras (engine, schema/marker/kill switch, enrollment ops, lote, contrato de consulta, consent, remoção, antiabuso, UI, verificação) têm Opções + Recomendação + Rejeitadas; (2) cabe no appetite: uma collection nova, uma migration aditiva, uma rota pública nova, reuso dos donos existentes (approval gate/media privada/transação/consent/guardas de CLI) e o lote é máquina, não código; (3) rabbit holes nomeados (parecidos/score, retenção de selfie, modo B, auto-enrollment, segundo limiter/engine, C229, CRM de rostos); (4) depth check: edita donos reais (`contentEventRateLimit`, body cap do beacon, guard do `Consent`, global `photoAlbum`, hook de revalidação do `ArchivePhoto`) e só cria módulos onde não há dono (`faceSubjects/`, `lib/faceSearch`, rotas); (5) outcome preservado — fail-closed, A/C, sem PII desnecessária, sem score, sem reescrever o aceite. O que impede o 5: o **threshold 0.45** e a qualidade do engine são aposta não medida (calibração só no canário), e a **operação de re-enrollment na troca de modelo** depende das selfies mantidas pela assessoria fora do site — ambos registrados como risco com gatilho, não escondidos.
|
||||
|
|
@ -60,7 +60,7 @@
|
|||
color: white;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
box-shadow: 0 8px 18px rgb(228 16 47 / 18%);
|
||||
box-shadow: 0 4px 0 #7f1616;
|
||||
}
|
||||
.secondary {
|
||||
display: inline-flex;
|
||||
|
|
@ -76,6 +76,22 @@
|
|||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.primary:focus-visible,
|
||||
.secondary:focus-visible {
|
||||
outline: 3px solid var(--brand-yellow);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.revision-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
border-radius: 999px;
|
||||
background: #eef4fb;
|
||||
padding: 4px 9px;
|
||||
color: #184e92;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
|
|
@ -189,6 +205,20 @@
|
|||
<rect x="3" y="11" width="18" height="10" rx="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</symbol>
|
||||
<symbol id="wifi-off" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="m2 2 20 20M8.5 8.5A12.4 12.4 0 0 1 21 9M3 9a16 16 0 0 1 2.8-1.6M6.5 13a8 8 0 0 1 6.9-1.7M17.5 13a8 8 0 0 1 .5.4M9.5 16.5a4 4 0 0 1 5 0M12 20h.01"
|
||||
/>
|
||||
</symbol>
|
||||
<symbol id="clock" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 7v5l3 2" />
|
||||
</symbol>
|
||||
<symbol id="refresh" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M20 6v5h-5M4 18v-5h5M18.5 9A7 7 0 0 0 6.4 6.4L4 9M5.5 15A7 7 0 0 0 17.6 17.6L20 15"
|
||||
/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
||||
<main class="mx-auto flex max-w-[1480px] flex-col gap-16 px-4 py-10 md:px-8">
|
||||
|
|
@ -198,6 +228,9 @@
|
|||
>C234 · gate hi-fi</span
|
||||
><span class="rounded-full border border-border bg-white px-3 py-1 text-xs font-bold"
|
||||
>Fluxo sensível · LGPD fail-closed</span
|
||||
><span
|
||||
class="rounded-full border border-[#184e92]/20 bg-[#eef4fb] px-3 py-1 text-xs font-extrabold text-[#184e92]"
|
||||
>Estendido para a execução · 27 set 2026</span
|
||||
>
|
||||
</div>
|
||||
<h1 class="display mt-4 text-3xl font-black tracking-[-.03em]">
|
||||
|
|
@ -207,14 +240,35 @@
|
|||
A pessoa parte do álbum, entende o uso de biometria, escolhe a própria selfie e recebe
|
||||
apenas fotos públicas aprovadas em que aparece. A selfie permanece no dispositivo.
|
||||
</p>
|
||||
<div class="mt-5 rounded-xl border border-[#184e92]/15 bg-[#f9faff] p-4 text-sm leading-6">
|
||||
<p class="font-extrabold text-[#184e92]">Deltas de execução desta revisão</p>
|
||||
<p class="mt-1 text-muted-foreground">
|
||||
A entrada agora respeita o hero e a grade reais do C233; o fluxo é uma página própria em
|
||||
<b class="text-foreground">/fotos/encontre</b>; resultado, vazio A/C, falhas do
|
||||
processamento e opt-out sem reconhecimento ganham estados explícitos. Cenas revisadas
|
||||
mantêm o número aprovado e recebem selo azul; cenas novas começam em 09.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- CENA 01 -->
|
||||
<!-- Port literal da entrada: rounded-2xl border border-[#184e92]/15 bg-[#f9faff] px-6 py-5;
|
||||
CTA: inline-flex min-h-11 items-center justify-center gap-2 rounded-[10px] bg-(--pt-red)
|
||||
px-4 font-[family-name:var(--font-exo2)] text-sm font-extrabold text-white
|
||||
shadow-[0_4px_0_#7f1616] + ARCHIVE_PHOTO_FOCUS. -->
|
||||
<section aria-labelledby="s1">
|
||||
<p class="text-xs font-extrabold tracking-[.08em] text-[#e4102f] uppercase">Cena 01</p>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<p class="text-xs font-extrabold tracking-[.08em] text-[#e4102f] uppercase">Cena 01</p>
|
||||
<span class="revision-badge">Revisada · C234 execução</span>
|
||||
</div>
|
||||
<h2 id="s1" class="display mt-1 text-xl font-black">
|
||||
Desktop ~1280 · entrada no contexto da /fotos
|
||||
Entrada real na /fotos · flag ligada
|
||||
</h2>
|
||||
<p class="mt-2 max-w-3xl text-sm leading-6 text-muted-foreground">
|
||||
O hero do C233 permanece inteiro. A entrada ocupa uma faixa própria entre o hero e as
|
||||
facetas: descoberta forte, sem competir com o título do álbum nem transformar o hero em
|
||||
duas colunas.
|
||||
</p>
|
||||
<div class="stage mt-4 overflow-x-auto rounded-2xl border border-border bg-white">
|
||||
<div class="min-h-[660px] min-w-[1180px] bg-white">
|
||||
<header class="bg-[#ae1603]">
|
||||
|
|
@ -226,59 +280,164 @@
|
|||
class="absolute top-[-29.5px] left-[-22.4px] h-auto w-[189px] max-w-none"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-sm font-bold text-white">Fotos</span>
|
||||
<nav class="flex items-center gap-6 text-sm font-bold text-white">
|
||||
<span>Início</span><span class="border-b-2 border-[#ffeb00] py-2">Fotos</span
|
||||
><span>Conteúdos</span>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<div class="mx-auto max-w-6xl px-8 py-9">
|
||||
<nav class="text-sm text-muted-foreground">
|
||||
Início <span class="px-1">/</span> Fotos
|
||||
</nav>
|
||||
<div class="mt-6 grid grid-cols-[1fr_420px] items-stretch gap-6">
|
||||
<div class="rounded-2xl bg-[#fff8f2] p-8">
|
||||
<p class="text-xs font-black tracking-[.1em] text-[#e4102f] uppercase">
|
||||
Álbum público
|
||||
<section
|
||||
class="relative isolate overflow-hidden border-b-4 border-[#ffeb00] bg-[radial-gradient(circle_at_78%_0%,rgb(255_230_7/13%),transparent_28%),linear-gradient(112deg,#e4102f_0%,#b8102a_58%,#184e92_100%)] text-white"
|
||||
>
|
||||
<div class="mx-auto flex min-h-[230px] max-w-6xl items-center px-8 py-10">
|
||||
<div class="max-w-2xl">
|
||||
<p class="text-xs font-black tracking-[.12em] text-[#ffeb00] uppercase">
|
||||
Memória da nossa caminhada
|
||||
</p>
|
||||
<h3 class="display mt-3 text-[38px] leading-[1.02] font-black tracking-[-.03em]">
|
||||
Procure uma foto por data, lugar ou atividade.
|
||||
<h3 class="display mt-3 text-[42px] leading-none font-black tracking-[-.03em]">
|
||||
Fotos que contam onde a gente esteve.
|
||||
</h3>
|
||||
<p class="mt-4 max-w-lg text-base leading-6 text-muted-foreground">
|
||||
Use os filtros para navegar pelos registros aprovados da caminhada.
|
||||
<p class="mt-4 max-w-xl text-base leading-6 text-white/90">
|
||||
Procure uma agenda, um município ou uma pessoa pública e abra cada registro com
|
||||
data e contexto.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<main class="mx-auto max-w-6xl px-8 py-8">
|
||||
<aside
|
||||
class="grid grid-cols-[1fr_auto] items-center gap-6 rounded-2xl border border-[#184e92]/15 bg-[#f9faff] px-6 py-5"
|
||||
>
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="mt-7 flex h-12 items-center gap-3 rounded-[10px] border border-black/20 bg-white px-4 text-sm text-muted-foreground"
|
||||
class="grid size-11 shrink-0 place-items-center rounded-xl bg-[#184e92] text-white"
|
||||
>
|
||||
<svg class="icon"><use href="#image" /></svg>Buscar no álbum…
|
||||
<svg class="icon size-5"><use href="#spark" /></svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="display text-xl font-black">Encontre você nas fotos</h3>
|
||||
<p class="mt-1 text-sm leading-6 text-muted-foreground">
|
||||
Use uma selfie sua. A imagem não sai deste dispositivo e a busca cobre quem
|
||||
autorizou participar do índice.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<aside
|
||||
class="relative overflow-hidden rounded-2xl bg-[linear-gradient(145deg,#184e92,#0d315f)] p-7 text-white"
|
||||
<button class="primary shrink-0">
|
||||
Começar busca por selfie <svg class="icon"><use href="#chev" /></svg>
|
||||
</button>
|
||||
</aside>
|
||||
<div class="mt-8 rounded-2xl border border-border bg-white p-5">
|
||||
<div
|
||||
class="flex h-12 items-center gap-3 rounded-[10px] border border-black/20 px-4 text-sm text-muted-foreground"
|
||||
>
|
||||
<div
|
||||
class="absolute -right-12 -bottom-12 size-56 rounded-full border-[30px] border-[#ffeb00]/15"
|
||||
></div>
|
||||
<div
|
||||
class="relative grid size-12 place-items-center rounded-xl bg-white/12 text-[#ffeb00]"
|
||||
<svg class="icon"><use href="#image" /></svg>Buscar no álbum…
|
||||
</div>
|
||||
<div class="mt-4 flex gap-2">
|
||||
<span
|
||||
class="flex min-h-11 items-center rounded-full border border-border px-3 text-xs font-bold"
|
||||
>Data</span
|
||||
>
|
||||
<svg class="icon size-6"><use href="#spark" /></svg>
|
||||
</div>
|
||||
<h3 class="display relative mt-5 text-2xl font-black">Encontre você nas fotos</h3>
|
||||
<p class="relative mt-2 text-sm leading-6 text-white/80">
|
||||
Use uma selfie sua. Ela não sai deste dispositivo e não vira cadastro.
|
||||
</p>
|
||||
<button class="primary relative mt-7 w-full">
|
||||
Começar busca por selfie <svg class="icon"><use href="#chev" /></svg>
|
||||
</button>
|
||||
<p class="relative mt-3 text-center text-xs text-white/65">
|
||||
Sem conta · você controla a remoção
|
||||
</p>
|
||||
</aside>
|
||||
<span
|
||||
class="flex min-h-11 items-center rounded-full border border-border px-3 text-xs font-bold"
|
||||
>Município</span
|
||||
>
|
||||
<span
|
||||
class="flex min-h-11 items-center rounded-full border border-border px-3 text-xs font-bold"
|
||||
>Atividade</span
|
||||
>
|
||||
<span
|
||||
class="flex min-h-11 items-center rounded-full border border-border px-3 text-xs font-bold"
|
||||
>Pessoa pública</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-7 grid grid-cols-4 gap-4">
|
||||
<div class="photo-placeholder aspect-[4/3] rounded-xl"></div>
|
||||
<div class="mt-7 grid grid-cols-3 gap-4">
|
||||
<div class="photo-placeholder aspect-[4/3] rounded-xl"></div>
|
||||
<div class="photo-placeholder aspect-[4/3] rounded-xl"></div>
|
||||
<div class="photo-placeholder aspect-[4/3] rounded-xl"></div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 grid gap-6 lg:grid-cols-[390px_1fr]">
|
||||
<div>
|
||||
<p class="text-sm font-extrabold">Mobile 390 · flag ligada</p>
|
||||
<div
|
||||
class="stage mt-3 w-[390px] overflow-hidden rounded-[30px] border border-border bg-white"
|
||||
>
|
||||
<header class="flex h-14 items-center justify-between bg-[#ae1603] px-5 text-white">
|
||||
<span class="display font-black">Jorge Solla</span
|
||||
><span class="text-sm font-bold">Fotos</span>
|
||||
</header>
|
||||
<div
|
||||
class="border-b-4 border-[#ffeb00] bg-[linear-gradient(125deg,#e4102f,#184e92)] px-5 py-8 text-white"
|
||||
>
|
||||
<p class="text-xs font-black tracking-[.12em] text-[#ffeb00] uppercase">
|
||||
Nossa caminhada
|
||||
</p>
|
||||
<h3 class="display mt-3 text-[30px] leading-[.98] font-black">
|
||||
Encontre um momento.
|
||||
</h3>
|
||||
<p class="mt-3 text-sm leading-5 text-white/90">
|
||||
Busque pela agenda, pelo lugar ou por quem apareceu.
|
||||
</p>
|
||||
</div>
|
||||
<main class="px-5 py-6">
|
||||
<aside class="rounded-2xl border border-[#184e92]/15 bg-[#f9faff] p-4">
|
||||
<div class="flex gap-3">
|
||||
<div
|
||||
class="grid size-10 shrink-0 place-items-center rounded-xl bg-[#184e92] text-white"
|
||||
>
|
||||
<svg class="icon"><use href="#spark" /></svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="display text-lg font-black">Encontre você nas fotos</h3>
|
||||
<p class="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
Sua selfie fica neste aparelho. A busca é para quem aderiu ao índice.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="primary mt-4 w-full">Começar busca por selfie</button>
|
||||
</aside>
|
||||
<div
|
||||
class="mt-6 flex h-12 items-center gap-3 rounded-[10px] border border-black/20 px-4 text-sm text-muted-foreground"
|
||||
>
|
||||
<svg class="icon"><use href="#image" /></svg>Buscar no álbum…
|
||||
</div>
|
||||
<div class="mt-5 grid grid-cols-2 gap-3">
|
||||
<div class="photo-placeholder aspect-square rounded-lg"></div>
|
||||
<div class="photo-placeholder aspect-square rounded-lg"></div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<div class="max-w-2xl">
|
||||
<p class="text-sm font-extrabold">Flag desligada · álbum normal, sem reserva vazia</p>
|
||||
<div class="mt-3 rounded-2xl border border-border bg-white p-6">
|
||||
<div
|
||||
class="rounded-xl border border-dashed border-[#184e92]/30 bg-[#f9faff] p-4 text-sm leading-6 text-muted-foreground"
|
||||
>
|
||||
<b class="text-foreground">Regra visual:</b> a faixa inteira não existe. Hero e
|
||||
facetas se encostam pelo espaçamento normal do C233; não aparece placeholder, aviso
|
||||
de indisponibilidade nem link no header/rodapé.
|
||||
</div>
|
||||
<div
|
||||
class="mt-5 flex h-12 items-center gap-3 rounded-[10px] border border-black/20 px-4 text-sm text-muted-foreground"
|
||||
>
|
||||
<svg class="icon"><use href="#image" /></svg>Buscar no álbum…
|
||||
</div>
|
||||
<div class="mt-4 flex gap-2">
|
||||
<span
|
||||
class="flex min-h-11 items-center rounded-full border border-border px-3 text-xs font-bold"
|
||||
>Data</span
|
||||
><span
|
||||
class="flex min-h-11 items-center rounded-full border border-border px-3 text-xs font-bold"
|
||||
>Município</span
|
||||
><span
|
||||
class="flex min-h-11 items-center rounded-full border border-border px-3 text-xs font-bold"
|
||||
>Atividade</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -286,72 +445,101 @@
|
|||
|
||||
<!-- CENA 02 -->
|
||||
<section aria-labelledby="s2">
|
||||
<p class="text-xs font-extrabold tracking-[.08em] text-[#e4102f] uppercase">Cena 02</p>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<p class="text-xs font-extrabold tracking-[.08em] text-[#e4102f] uppercase">Cena 02</p>
|
||||
<span class="revision-badge">Revisada · página /fotos/encontre</span>
|
||||
</div>
|
||||
<h2 id="s2" class="display mt-1 text-xl font-black">
|
||||
Desktop ~1280 · aviso de biometria e consentimento explícito
|
||||
Desktop ~1280 · aviso e consentimento na página própria, não em modal
|
||||
</h2>
|
||||
<div
|
||||
class="stage relative mt-4 min-h-[720px] overflow-hidden rounded-2xl border border-border bg-[linear-gradient(rgb(24_10_9/55%),rgb(24_10_9/55%)),#fff8f2] p-8"
|
||||
>
|
||||
<div
|
||||
class="mx-auto max-w-[570px] rounded-2xl bg-white p-7 shadow-[0_28px_90px_rgb(0_0_0/26%)]"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="grid size-12 place-items-center rounded-xl bg-[#eef4fb] text-[#184e92]">
|
||||
<svg class="icon size-6"><use href="#shield" /></svg>
|
||||
<div class="stage mt-4 overflow-x-auto rounded-2xl border border-border bg-white">
|
||||
<div class="min-h-[720px] min-w-[1180px] bg-[#fff8f2]">
|
||||
<header class="bg-[#ae1603]">
|
||||
<div class="mx-auto flex h-16 max-w-6xl items-center justify-between px-8 text-white">
|
||||
<div class="relative h-[52px] w-[144px] overflow-hidden">
|
||||
<img
|
||||
src="../../public/campaign-kit/jorge-solla-negativo.png"
|
||||
alt="Jorge Solla"
|
||||
class="absolute top-[-29.5px] left-[-22.4px] h-auto w-[189px] max-w-none"
|
||||
/>
|
||||
</div>
|
||||
<a href="#" class="flex min-h-11 items-center gap-2 text-sm font-bold"
|
||||
><svg class="icon"><use href="#arrow" /></svg>Voltar ao álbum</a
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
aria-label="Fechar"
|
||||
class="grid size-10 place-items-center rounded-full bg-muted"
|
||||
>
|
||||
<svg class="icon"><use href="#x" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-5 text-xs font-black tracking-[.1em] text-[#184e92] uppercase">
|
||||
Antes da sua selfie
|
||||
</p>
|
||||
<h3 class="display mt-2 text-2xl font-black">Sua face é um dado biométrico sensível</h3>
|
||||
<p class="mt-3 text-sm leading-6 text-muted-foreground">
|
||||
Com sua autorização, comparamos a sua selfie com o índice permitido do álbum para
|
||||
mostrar somente fotos públicas aprovadas em que você aparece.
|
||||
</p>
|
||||
<div class="mt-5 grid gap-3 rounded-xl bg-[#f9faff] p-4 text-sm">
|
||||
<div class="flex gap-3">
|
||||
<svg class="icon mt-0.5 shrink-0 text-[#184e92]"><use href="#device" /></svg>
|
||||
<p>
|
||||
<b>A selfie não sai do dispositivo.</b><br /><span class="text-muted-foreground"
|
||||
>A imagem é processada aqui e não é guardada na galeria do site.</span
|
||||
</header>
|
||||
<div class="mx-auto max-w-[650px] px-8 py-10">
|
||||
<p class="mb-4 text-center text-sm font-bold text-muted-foreground">
|
||||
Encontre você nas fotos
|
||||
</p>
|
||||
<div class="rounded-2xl border border-border bg-white p-7">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div
|
||||
class="grid size-12 place-items-center rounded-xl bg-[#eef4fb] text-[#184e92]"
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<svg class="icon mt-0.5 shrink-0 text-[#184e92]"><use href="#lock" /></svg>
|
||||
<p>
|
||||
<b>Você pode desistir e pedir remoção.</b><br /><span
|
||||
class="text-muted-foreground"
|
||||
>O resultado não mostra nomes de terceiros nem percentual de semelhança.</span
|
||||
<svg class="icon size-6"><use href="#shield" /></svg>
|
||||
</div>
|
||||
<span
|
||||
class="rounded-full bg-[#eef4fb] px-3 py-1 text-xs font-extrabold text-[#184e92]"
|
||||
>Passo 1 de 3</span
|
||||
>
|
||||
</div>
|
||||
<p class="mt-5 text-xs font-black tracking-[.1em] text-[#184e92] uppercase">
|
||||
Antes da sua selfie
|
||||
</p>
|
||||
<h3 class="display mt-2 text-2xl font-black">
|
||||
Sua face é um dado biométrico sensível
|
||||
</h3>
|
||||
<p class="mt-3 text-sm leading-6 text-muted-foreground">
|
||||
Com sua autorização, comparamos a sua selfie com o índice formado por pessoas que
|
||||
autorizaram participar, para mostrar somente fotos públicas aprovadas em que você
|
||||
aparece.
|
||||
</p>
|
||||
<div class="mt-5 grid gap-3 rounded-xl bg-[#f9faff] p-4 text-sm">
|
||||
<div class="flex gap-3">
|
||||
<svg class="icon mt-0.5 shrink-0 text-[#184e92]"><use href="#device" /></svg>
|
||||
<p>
|
||||
<b>A selfie não sai do dispositivo.</b><br /><span
|
||||
class="text-muted-foreground"
|
||||
>A imagem é processada aqui e não é guardada na galeria do site.</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<svg class="icon mt-0.5 shrink-0 text-[#184e92]"><use href="#lock" /></svg>
|
||||
<p>
|
||||
<b>Você pode desistir e pedir remoção.</b><br /><span
|
||||
class="text-muted-foreground"
|
||||
>O resultado não mostra nomes de terceiros nem percentual de
|
||||
semelhança.</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<label
|
||||
class="mt-5 flex cursor-pointer items-start gap-3 rounded-xl border border-black/15 p-4 text-sm leading-5"
|
||||
><span
|
||||
class="mt-0.5 grid size-5 shrink-0 place-items-center rounded border-2 border-[#184e92] bg-[#184e92] text-white"
|
||||
><svg class="icon size-3.5"><use href="#check" /></svg></span
|
||||
><span
|
||||
>Li e autorizo o uso temporário da minha biometria facial para procurar a mim
|
||||
mesmo neste álbum.</span
|
||||
></label
|
||||
>
|
||||
<p class="mt-3 text-xs leading-5 text-muted-foreground">
|
||||
Saiba como tratamos seus dados na
|
||||
<a href="#" class="font-bold text-[#184e92] underline underline-offset-2"
|
||||
>Política de Privacidade</a
|
||||
>.
|
||||
</p>
|
||||
<button class="primary mt-6 w-full">Concordar e escolher minha selfie</button>
|
||||
<a
|
||||
href="#"
|
||||
class="mt-3 flex min-h-11 w-full items-center justify-center text-sm font-bold text-muted-foreground"
|
||||
>Agora não · voltar ao álbum</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<label
|
||||
class="mt-5 flex cursor-pointer items-start gap-3 rounded-xl border border-black/15 p-4 text-sm leading-5"
|
||||
><span
|
||||
class="mt-0.5 grid size-5 shrink-0 place-items-center rounded border-2 border-[#184e92] bg-[#184e92] text-white"
|
||||
><svg class="icon size-3.5"><use href="#check" /></svg></span
|
||||
><span
|
||||
>Li e autorizo o uso temporário da minha biometria facial para procurar a mim mesmo
|
||||
neste álbum.</span
|
||||
></label
|
||||
>
|
||||
<p class="mt-3 text-xs leading-5 text-muted-foreground">
|
||||
Saiba como tratamos seus dados na
|
||||
<a href="#" class="font-bold text-[#184e92] underline underline-offset-2"
|
||||
>Política de Privacidade</a
|
||||
>.
|
||||
</p>
|
||||
<button class="primary mt-6 w-full">Concordar e escolher minha selfie</button>
|
||||
<button class="mt-3 w-full text-sm font-bold text-muted-foreground">Agora não</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -414,9 +602,12 @@
|
|||
|
||||
<!-- CENA 04 -->
|
||||
<section aria-labelledby="s4">
|
||||
<p class="text-xs font-extrabold tracking-[.08em] text-[#e4102f] uppercase">Cena 04</p>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<p class="text-xs font-extrabold tracking-[.08em] text-[#e4102f] uppercase">Cena 04</p>
|
||||
<span class="revision-badge">Confirmada · página, não modal</span>
|
||||
</div>
|
||||
<h2 id="s4" class="display mt-1 text-xl font-black">
|
||||
Mobile ~390 · processando sem promessas ou percentuais
|
||||
Mobile 390 · processando em /fotos/encontre, sem promessas ou percentuais
|
||||
</h2>
|
||||
<div
|
||||
class="stage mt-4 w-[390px] overflow-hidden rounded-[30px] border border-border bg-white"
|
||||
|
|
@ -437,7 +628,10 @@
|
|||
<div class="spinner !size-7 !border-[3px]"></div>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="display mt-7 text-2xl font-black">Procurando você nas fotos aprovadas…</h3>
|
||||
<p class="mt-7 text-xs font-black tracking-[.08em] text-[#184e92] uppercase">
|
||||
Passo 3 de 3
|
||||
</p>
|
||||
<h3 class="display mt-2 text-2xl font-black">Procurando você nas fotos aprovadas…</h3>
|
||||
<p class="mt-3 max-w-[290px] text-sm leading-6 text-muted-foreground">
|
||||
Mantenha esta tela aberta. Sua selfie continua somente neste aparelho.
|
||||
</p>
|
||||
|
|
@ -450,8 +644,13 @@
|
|||
</section>
|
||||
|
||||
<!-- CENA 05 -->
|
||||
<!-- Card de resultado porta ARCHIVE_PHOTO_CARD; imagem: aspect-square ... sm:aspect-[4/3];
|
||||
conteúdo: pt-3, título Exo 2 text-sm/font-black sm:text-base e meta muted. Sem terceiro parágrafo. -->
|
||||
<section aria-labelledby="s5">
|
||||
<p class="text-xs font-extrabold tracking-[.08em] text-[#e4102f] uppercase">Cena 05</p>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<p class="text-xs font-extrabold tracking-[.08em] text-[#e4102f] uppercase">Cena 05</p>
|
||||
<span class="revision-badge">Revisada · variante sem terceiros</span>
|
||||
</div>
|
||||
<h2 id="s5" class="display mt-1 text-xl font-black">
|
||||
Desktop ~1280 · resultado mínimo: só fotos aprovadas em que você aparece
|
||||
</h2>
|
||||
|
|
@ -487,33 +686,50 @@
|
|||
</div>
|
||||
<div class="mt-7 grid grid-cols-3 gap-x-4 gap-y-7">
|
||||
<article>
|
||||
<button class="focus-ring block w-full rounded-xl text-left">
|
||||
<a href="#" class="focus-ring block w-full rounded-xl text-left">
|
||||
<div class="photo-placeholder aspect-[4/3] rounded-xl"></div>
|
||||
<p class="display mt-3 font-black">Plenária pela saúde pública</p>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
12 set 2026 · Município selecionado · Plenária
|
||||
</p>
|
||||
</button>
|
||||
<div class="pt-3">
|
||||
<p class="display text-base font-black text-balance">
|
||||
Plenária pela saúde pública
|
||||
</p>
|
||||
<p class="mt-1 text-sm leading-5 text-muted-foreground">
|
||||
12 set 2026 · Município selecionado · Plenária
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
</article>
|
||||
<article>
|
||||
<button class="focus-ring block w-full rounded-xl text-left">
|
||||
<a href="#" class="focus-ring block w-full rounded-xl text-left">
|
||||
<div class="photo-placeholder aspect-[4/3] rounded-xl"></div>
|
||||
<p class="display mt-3 font-black">Caminhada no centro</p>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
28 ago 2026 · Município selecionado · Caminhada
|
||||
</p>
|
||||
</button>
|
||||
<div class="pt-3">
|
||||
<p class="display text-base font-black text-balance">Caminhada no centro</p>
|
||||
<p class="mt-1 text-sm leading-5 text-muted-foreground">
|
||||
28 ago 2026 · Município selecionado · Caminhada
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
</article>
|
||||
<article>
|
||||
<button class="focus-ring block w-full rounded-xl text-left">
|
||||
<a href="#" class="focus-ring block w-full rounded-xl text-left">
|
||||
<div class="photo-placeholder aspect-[4/3] rounded-xl"></div>
|
||||
<p class="display mt-3 font-black">Encontro com a comunidade</p>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
16 ago 2026 · Município selecionado · Reunião
|
||||
</p>
|
||||
</button>
|
||||
<div class="pt-3">
|
||||
<p class="display text-base font-black text-balance">
|
||||
Encontro com a comunidade
|
||||
</p>
|
||||
<p class="mt-1 text-sm leading-5 text-muted-foreground">
|
||||
16 ago 2026 · Município selecionado · Reunião
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
</article>
|
||||
</div>
|
||||
<div
|
||||
class="mt-5 rounded-xl border border-[#184e92]/15 bg-[#f9faff] px-4 py-3 text-xs leading-5 text-[#143c70]"
|
||||
>
|
||||
<b>Contrato visual da variante:</b> imagem 4:3, título e contexto do card C233. A
|
||||
linha “Quem aparece” não é reservada, ocultada nem substituída; ela não existe no
|
||||
resultado por selfie.
|
||||
</div>
|
||||
<div class="mt-10 flex items-center justify-between rounded-xl bg-[#fff8f2] p-5">
|
||||
<div>
|
||||
<p class="font-extrabold">Quer controlar sua presença no álbum?</p>
|
||||
|
|
@ -530,11 +746,61 @@
|
|||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
<p class="text-sm font-extrabold">
|
||||
Mobile 390 · a mesma variante em grade de duas colunas
|
||||
</p>
|
||||
<div
|
||||
class="stage mt-3 w-[390px] overflow-hidden rounded-[30px] border border-border bg-white"
|
||||
>
|
||||
<header class="flex h-14 items-center justify-between bg-[#ae1603] px-4 text-white">
|
||||
<button aria-label="Voltar">
|
||||
<svg class="icon"><use href="#arrow" /></svg></button
|
||||
><span class="text-sm font-bold">Resultado</span><span class="w-[18px]"></span>
|
||||
</header>
|
||||
<main class="px-5 py-6">
|
||||
<h3 class="display text-2xl font-black">Encontramos você nestas fotos.</h3>
|
||||
<p class="mt-2 text-sm leading-5 text-muted-foreground">
|
||||
Só registros públicos aprovados, sem nomes de terceiros.
|
||||
</p>
|
||||
<button class="secondary mt-4 w-full">Usar outra selfie</button>
|
||||
<div class="mt-6 grid grid-cols-2 gap-x-3 gap-y-5">
|
||||
<article>
|
||||
<a href="#" class="focus-ring block w-full rounded-xl text-left"
|
||||
><div class="photo-placeholder aspect-square rounded-lg"></div>
|
||||
<div class="pt-3">
|
||||
<p class="display text-sm font-black text-balance">
|
||||
Plenária pela saúde pública
|
||||
</p>
|
||||
<p class="mt-1 text-xs leading-4 text-muted-foreground">
|
||||
12 set 2026 · Município selecionado
|
||||
</p>
|
||||
</div></a
|
||||
>
|
||||
</article>
|
||||
<article>
|
||||
<a href="#" class="focus-ring block w-full rounded-xl text-left"
|
||||
><div class="photo-placeholder aspect-square rounded-lg"></div>
|
||||
<div class="pt-3">
|
||||
<p class="display text-sm font-black text-balance">Caminhada no centro</p>
|
||||
<p class="mt-1 text-xs leading-4 text-muted-foreground">
|
||||
28 ago 2026 · Município selecionado
|
||||
</p>
|
||||
</div></a
|
||||
>
|
||||
</article>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CENA 06 -->
|
||||
<section aria-labelledby="s6">
|
||||
<p class="text-xs font-extrabold tracking-[.08em] text-[#e4102f] uppercase">Cena 06</p>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<p class="text-xs font-extrabold tracking-[.08em] text-[#e4102f] uppercase">Cena 06</p>
|
||||
<span class="revision-badge">Revisada · índice A/C</span>
|
||||
</div>
|
||||
<h2 id="s6" class="display mt-1 text-xl font-black">
|
||||
Mobile ~390 · vazio honesto, sem “parecidos”
|
||||
</h2>
|
||||
|
|
@ -550,13 +816,13 @@
|
|||
<div class="grid size-14 place-items-center rounded-full bg-[#eef4fb] text-[#184e92]">
|
||||
<svg class="icon size-7"><use href="#image" /></svg>
|
||||
</div>
|
||||
<h3 class="display mt-5 text-2xl font-black">Não encontramos você</h3>
|
||||
<h3 class="display mt-5 text-2xl font-black">Não encontramos fotos suas</h3>
|
||||
<p class="mt-2 max-w-[310px] text-sm leading-6 text-muted-foreground">
|
||||
Talvez a luz ou o ângulo tenham dificultado a busca — ou ainda não haja uma foto
|
||||
pública aprovada sua no álbum.
|
||||
Esta busca funciona para quem autorizou participar do índice. Também pode não haver
|
||||
uma foto sua entre os registros públicos aprovados.
|
||||
</p>
|
||||
<p class="mt-3 text-sm font-bold">
|
||||
Não mostramos pessoas parecidas para preencher a tela.
|
||||
Não mostramos pessoas parecidas nem tentamos adivinhar quem você é.
|
||||
</p>
|
||||
<button class="primary mt-7 w-full">Tentar outra foto</button>
|
||||
<a href="#" class="mt-4 text-sm font-bold text-[#184e92] underline underline-offset-3"
|
||||
|
|
@ -601,7 +867,10 @@
|
|||
|
||||
<!-- CENA 08 -->
|
||||
<section aria-labelledby="s8">
|
||||
<p class="text-xs font-extrabold tracking-[.08em] text-[#e4102f] uppercase">Cena 08</p>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<p class="text-xs font-extrabold tracking-[.08em] text-[#e4102f] uppercase">Cena 08</p>
|
||||
<span class="revision-badge">Revisada · opt-out self-service</span>
|
||||
</div>
|
||||
<h2 id="s8" class="display mt-1 text-xl font-black">
|
||||
Mobile ~390 · opt-out e pedido de remoção com confirmação
|
||||
</h2>
|
||||
|
|
@ -618,8 +887,8 @@
|
|||
</p>
|
||||
<h3 class="display mt-1 text-2xl font-black">Você decide se quer aparecer</h3>
|
||||
<p class="mt-2 text-sm leading-6 text-muted-foreground">
|
||||
Escolha o pedido. A equipe registra o atendimento e mantém a proteção fechada até
|
||||
concluir.
|
||||
Para sair do índice, confirme sua identidade com uma selfie. Para retirar uma foto
|
||||
pública, use o canal da equipe responsável pelo acervo.
|
||||
</p>
|
||||
<label class="mt-5 flex gap-3 rounded-xl border-2 border-[#184e92] bg-[#f9faff] p-4"
|
||||
><span
|
||||
|
|
@ -632,15 +901,17 @@
|
|||
></span
|
||||
></label
|
||||
>
|
||||
<label class="mt-3 flex gap-3 rounded-xl border border-border p-4"
|
||||
><span class="mt-0.5 size-5 shrink-0 rounded-full border-2 border-stone-400"></span
|
||||
<a
|
||||
href="#"
|
||||
data-source="photoAlbum.removalChannelUrl"
|
||||
class="secondary mt-3 flex w-full items-start! justify-between! p-4! text-left"
|
||||
><span
|
||||
><b class="text-sm">Pedir remoção de uma foto</b
|
||||
><small class="mt-1 block text-xs leading-5 text-muted-foreground"
|
||||
>Informe qual imagem não deve continuar pública.</small
|
||||
>Abre o canal configurado pela equipe responsável pelo acervo.</small
|
||||
></span
|
||||
></label
|
||||
>
|
||||
><svg class="icon mt-0.5 shrink-0"><use href="#chev" /></svg
|
||||
></a>
|
||||
<button class="primary mt-6 w-full">Continuar</button
|
||||
><a href="#" class="mt-4 block text-center text-sm font-bold text-muted-foreground"
|
||||
>Cancelar</a
|
||||
|
|
@ -660,8 +931,8 @@
|
|||
</div>
|
||||
<h3 class="display mt-4 text-xl font-black">Confirmar saída do índice?</h3>
|
||||
<p class="mt-2 text-sm leading-6 text-muted-foreground">
|
||||
Vamos registrar seu pedido. Até o atendimento ser concluído, sua presença permanece
|
||||
protegida e não deve voltar ao índice.
|
||||
Vamos reconhecer você neste aparelho e, se houver correspondência, retirar sua
|
||||
presença do índice de busca.
|
||||
</p>
|
||||
<button
|
||||
class="mt-6 flex min-h-11 w-full items-center justify-center rounded-[10px] bg-[#b42318] px-4 text-sm font-extrabold text-white"
|
||||
|
|
@ -675,6 +946,223 @@
|
|||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CENA 09 -->
|
||||
<section aria-labelledby="s9">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<p class="text-xs font-extrabold tracking-[.08em] text-[#e4102f] uppercase">Cena 09</p>
|
||||
<span class="revision-badge">Nova · engine indisponível</span>
|
||||
</div>
|
||||
<h2 id="s9" class="display mt-1 text-xl font-black">
|
||||
Navegador sem os recursos necessários · mobile 390
|
||||
</h2>
|
||||
<div class="mt-4 grid items-start gap-8 xl:grid-cols-[390px_1fr]">
|
||||
<div class="stage w-[390px] overflow-hidden rounded-[30px] border border-border bg-white">
|
||||
<header class="flex h-14 items-center justify-between bg-[#ae1603] px-4 text-white">
|
||||
<button aria-label="Voltar">
|
||||
<svg class="icon"><use href="#arrow" /></svg></button
|
||||
><span class="text-sm font-bold">Encontre você</span><span class="w-[18px]"></span>
|
||||
</header>
|
||||
<div class="flex min-h-[570px] flex-col items-center justify-center px-6 text-center">
|
||||
<div class="grid size-14 place-items-center rounded-full bg-[#eef4fb] text-[#184e92]">
|
||||
<svg class="icon size-7"><use href="#device" /></svg>
|
||||
</div>
|
||||
<h3 class="display mt-5 text-2xl font-black">
|
||||
Este navegador não consegue fazer a busca por selfie
|
||||
</h3>
|
||||
<p class="mt-3 max-w-[310px] text-sm leading-6 text-muted-foreground">
|
||||
Para proteger sua imagem, o reconhecimento precisa rodar neste aparelho. Este
|
||||
navegador não oferece os recursos necessários.
|
||||
</p>
|
||||
<p class="mt-3 text-sm font-bold">
|
||||
O problema não é com a sua selfie. Nenhuma imagem foi enviada.
|
||||
</p>
|
||||
<a href="#" class="primary mt-7 w-full">Voltar ao álbum</a>
|
||||
<p class="mt-4 text-xs leading-5 text-muted-foreground">
|
||||
Você pode tentar novamente em outro navegador ou aparelho compatível.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-extrabold">Desktop 1280 · mesma copy, hierarquia adaptada</p>
|
||||
<div
|
||||
class="stage mt-3 min-h-[470px] overflow-hidden rounded-2xl border border-border bg-[#fff8f2]"
|
||||
>
|
||||
<header class="flex h-16 items-center justify-between bg-[#ae1603] px-8 text-white">
|
||||
<span class="display font-black">Jorge Solla</span
|
||||
><a href="#" class="text-sm font-bold">Voltar ao álbum</a>
|
||||
</header>
|
||||
<div class="mx-auto max-w-xl px-8 py-12 text-center">
|
||||
<div
|
||||
class="mx-auto grid size-14 place-items-center rounded-full bg-[#eef4fb] text-[#184e92]"
|
||||
>
|
||||
<svg class="icon size-7"><use href="#device" /></svg>
|
||||
</div>
|
||||
<h3 class="display mt-5 text-2xl font-black">
|
||||
Este navegador não consegue fazer a busca por selfie
|
||||
</h3>
|
||||
<p class="mt-3 text-sm leading-6 text-muted-foreground">
|
||||
Para proteger sua imagem, o reconhecimento precisa rodar neste aparelho. Este
|
||||
navegador não oferece os recursos necessários.
|
||||
</p>
|
||||
<a href="#" class="primary mt-7">Voltar ao álbum</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CENA 10 -->
|
||||
<section aria-labelledby="s10">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<p class="text-xs font-extrabold tracking-[.08em] text-[#e4102f] uppercase">Cena 10</p>
|
||||
<span class="revision-badge">Nova · erro do engine</span>
|
||||
</div>
|
||||
<h2 id="s10" class="display mt-1 text-xl font-black">
|
||||
Processamento local interrompido · mobile 390
|
||||
</h2>
|
||||
<div
|
||||
class="stage mt-4 w-[390px] overflow-hidden rounded-[30px] border border-border bg-white"
|
||||
>
|
||||
<header class="flex h-14 items-center justify-between bg-[#ae1603] px-4 text-white">
|
||||
<button aria-label="Voltar">
|
||||
<svg class="icon"><use href="#arrow" /></svg></button
|
||||
><span class="text-sm font-bold">Encontre você</span><span class="w-[18px]"></span>
|
||||
</header>
|
||||
<div class="flex min-h-[570px] flex-col items-center justify-center px-6 text-center">
|
||||
<div class="grid size-14 place-items-center rounded-full bg-red-50 text-[#b42318]">
|
||||
<svg class="icon size-7"><use href="#alert" /></svg>
|
||||
</div>
|
||||
<h3 class="display mt-5 text-2xl font-black">Não foi possível processar a busca</h3>
|
||||
<p class="mt-3 max-w-[310px] text-sm leading-6 text-muted-foreground">
|
||||
O reconhecimento neste aparelho parou antes de concluir. Sua selfie não foi enviada
|
||||
nem guardada.
|
||||
</p>
|
||||
<button class="primary mt-7 w-full">
|
||||
<svg class="icon"><use href="#refresh" /></svg>Tentar novamente</button
|
||||
><a
|
||||
href="#"
|
||||
class="mt-4 flex min-h-11 items-center text-sm font-bold text-[#184e92] underline underline-offset-3"
|
||||
>Voltar ao álbum</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CENA 11 -->
|
||||
<section aria-labelledby="s11">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<p class="text-xs font-extrabold tracking-[.08em] text-[#e4102f] uppercase">Cena 11</p>
|
||||
<span class="revision-badge">Nova · limite de tentativas</span>
|
||||
</div>
|
||||
<h2 id="s11" class="display mt-1 text-xl font-black">
|
||||
Resposta 429 sem linguagem técnica · mobile 390
|
||||
</h2>
|
||||
<div
|
||||
class="stage mt-4 w-[390px] overflow-hidden rounded-[30px] border border-border bg-white"
|
||||
>
|
||||
<header class="flex h-14 items-center justify-between bg-[#ae1603] px-4 text-white">
|
||||
<button aria-label="Voltar">
|
||||
<svg class="icon"><use href="#arrow" /></svg></button
|
||||
><span class="text-sm font-bold">Encontre você</span><span class="w-[18px]"></span>
|
||||
</header>
|
||||
<div class="flex min-h-[570px] flex-col items-center justify-center px-6 text-center">
|
||||
<div class="grid size-14 place-items-center rounded-full bg-amber-50 text-amber-800">
|
||||
<svg class="icon size-7"><use href="#clock" /></svg>
|
||||
</div>
|
||||
<h3 class="display mt-5 text-2xl font-black">Muitas tentativas em pouco tempo</h3>
|
||||
<p class="mt-3 max-w-[305px] text-sm leading-6 text-muted-foreground">
|
||||
Para proteger este recurso, a busca foi pausada por alguns minutos.
|
||||
</p>
|
||||
<p class="mt-3 text-sm font-bold">Espere alguns minutos antes de tentar novamente.</p>
|
||||
<a href="#" class="primary mt-7 w-full">Voltar ao álbum</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CENA 12 -->
|
||||
<section aria-labelledby="s12">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<p class="text-xs font-extrabold tracking-[.08em] text-[#e4102f] uppercase">Cena 12</p>
|
||||
<span class="revision-badge">Nova · falha de rede</span>
|
||||
</div>
|
||||
<h2 id="s12" class="display mt-1 text-xl font-black">
|
||||
Falha de conexão depois do processamento local · mobile 390
|
||||
</h2>
|
||||
<div
|
||||
class="stage mt-4 w-[390px] overflow-hidden rounded-[30px] border border-border bg-white"
|
||||
>
|
||||
<header class="flex h-14 items-center justify-between bg-[#ae1603] px-4 text-white">
|
||||
<button aria-label="Voltar">
|
||||
<svg class="icon"><use href="#arrow" /></svg></button
|
||||
><span class="text-sm font-bold">Encontre você</span><span class="w-[18px]"></span>
|
||||
</header>
|
||||
<div class="flex min-h-[570px] flex-col items-center justify-center px-6 text-center">
|
||||
<div class="grid size-14 place-items-center rounded-full bg-[#eef4fb] text-[#184e92]">
|
||||
<svg class="icon size-7"><use href="#wifi-off" /></svg>
|
||||
</div>
|
||||
<h3 class="display mt-5 text-2xl font-black">Não foi possível enviar a busca</h3>
|
||||
<p class="mt-3 max-w-[310px] text-sm leading-6 text-muted-foreground">
|
||||
A conexão falhou ao enviar somente os dados necessários para procurar as fotos. Sua
|
||||
selfie continua neste aparelho.
|
||||
</p>
|
||||
<button class="primary mt-7 w-full">
|
||||
<svg class="icon"><use href="#refresh" /></svg>Tentar enviar novamente</button
|
||||
><a
|
||||
href="#"
|
||||
class="mt-4 flex min-h-11 items-center text-sm font-bold text-[#184e92] underline underline-offset-3"
|
||||
>Voltar ao álbum</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CENA 13 -->
|
||||
<section aria-labelledby="s13">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<p class="text-xs font-extrabold tracking-[.08em] text-[#e4102f] uppercase">Cena 13</p>
|
||||
<span class="revision-badge">Nova · opt-out sem match</span>
|
||||
</div>
|
||||
<h2 id="s13" class="display mt-1 text-xl font-black">
|
||||
Minha presença · pessoa não encontrada no índice · mobile 390
|
||||
</h2>
|
||||
<div
|
||||
class="stage mt-4 w-[390px] overflow-hidden rounded-[30px] border border-border bg-white"
|
||||
>
|
||||
<header class="flex h-14 items-center justify-between bg-[#ae1603] px-4 text-white">
|
||||
<button aria-label="Voltar">
|
||||
<svg class="icon"><use href="#arrow" /></svg></button
|
||||
><span class="text-sm font-bold">Minha presença</span><span class="w-[18px]"></span>
|
||||
</header>
|
||||
<div class="flex min-h-[600px] flex-col items-center justify-center px-6 text-center">
|
||||
<div class="grid size-14 place-items-center rounded-full bg-[#eef4fb] text-[#184e92]">
|
||||
<svg class="icon size-7"><use href="#shield" /></svg>
|
||||
</div>
|
||||
<h3 class="display mt-5 text-2xl font-black">Não encontramos sua presença no índice</h3>
|
||||
<p class="mt-3 max-w-[310px] text-sm leading-6 text-muted-foreground">
|
||||
Não foi possível reconhecer uma participação sua no índice com esta selfie. Nenhuma
|
||||
alteração foi feita.
|
||||
</p>
|
||||
<button class="primary mt-7 w-full">Tentar outra selfie</button>
|
||||
<div class="mt-6 w-full border-t border-border pt-5">
|
||||
<p class="text-sm leading-6 text-muted-foreground">
|
||||
Quer retirar uma foto pública mesmo assim?
|
||||
</p>
|
||||
<a href="#" data-source="photoAlbum.removalChannelUrl" class="secondary mt-3 w-full"
|
||||
>Pedir remoção de uma foto</a
|
||||
>
|
||||
<p class="mt-2 text-xs leading-5 text-muted-foreground">
|
||||
Este botão usa o canal configurado no álbum; o artefato não inventa endereço.
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="#"
|
||||
class="mt-4 flex min-h-11 items-center text-sm font-bold text-muted-foreground"
|
||||
>Voltar ao álbum</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
"license": "UNLICENSED",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"prebuild": "node scripts/copy-card-vision-assets.mjs",
|
||||
"prebuild": "node scripts/copy-card-vision-assets.mjs && node scripts/copy-face-vision-assets.mjs",
|
||||
"build": "cross-env NODE_OPTIONS=\"--no-deprecation --conditions=react-server\" payload migrate && cross-env NODE_OPTIONS=\"--no-deprecation --max-old-space-size=8000\" next build",
|
||||
"db:start": "node scripts/db-start.mjs",
|
||||
"db:stop": "docker compose -p teqo down",
|
||||
|
|
@ -36,6 +36,8 @@
|
|||
"reels:ingest": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/reels-ingest.mjs",
|
||||
"flickr:import": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/ingest-flickr-archive.mjs",
|
||||
"archive:catalog": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/catalog-archive-photos.mjs",
|
||||
"faces:enroll": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/enroll-face-subject.mjs",
|
||||
"faces:index": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/index-archive-faces.mjs",
|
||||
"db:seed:minimal": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-minimal.mjs",
|
||||
"campaign:staging:test-account": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/bootstrap-staging-test-account.mjs",
|
||||
"db:check:push-chain": "node scripts/check-push-chain.mjs",
|
||||
|
|
@ -124,8 +126,11 @@
|
|||
"@payloadcms/ui": "3.82.0",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@simplewebauthn/server": "^13.3.2",
|
||||
"@tensorflow/tfjs": "4.22.0",
|
||||
"@tensorflow/tfjs-backend-wasm": "4.22.0",
|
||||
"@vercel/analytics": "^1.6.1",
|
||||
"@vercel/speed-insights": "^1.3.1",
|
||||
"@vladmandic/face-api": "1.7.15",
|
||||
"ai": "^7.0.47",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
|
|
|||
|
|
@ -218,6 +218,17 @@ export default defineConfig({
|
|||
dependencies: isProdMode ? [] : ['frontend'],
|
||||
use: { ...devices['Desktop Chrome'], channel: 'chromium' },
|
||||
},
|
||||
{
|
||||
// C234 — the selfie search page (`/fotos/encontre`) and its anonymous
|
||||
// endpoint. Owns its faceSubject/Consent rows and flips the SAME
|
||||
// `photoAlbum` global as the C233 spec, so it serializes behind it in
|
||||
// BOTH modes (the admin project precedent: a shared global cannot run
|
||||
// concurrently across projects).
|
||||
name: 'frontendFotosSelfie',
|
||||
testMatch: /frontendFotosSelfie\.e2e\.spec\.ts/,
|
||||
dependencies: ['frontendFotos'],
|
||||
use: { ...devices['Desktop Chrome'], channel: 'chromium' },
|
||||
},
|
||||
{
|
||||
name: 'admin',
|
||||
testMatch: /admin\.e2e\.spec\.ts/,
|
||||
|
|
@ -267,6 +278,13 @@ export default defineConfig({
|
|||
* the same flag in its "Build for e2e" step.
|
||||
*/
|
||||
NEXT_PUBLIC_CARDS_CUTOUT_STUB: process.env.NEXT_PUBLIC_CARDS_CUTOUT_STUB ?? '1',
|
||||
/*
|
||||
* C234 — the selfie-search engine (face-api + ~7 MB of models) is
|
||||
* replaced by a deterministic descriptor in tests: no model download,
|
||||
* no inference. The real engine is exercised by the CLI canary and
|
||||
* manually (same contract as S15).
|
||||
*/
|
||||
NEXT_PUBLIC_FACE_SEARCH_STUB: process.env.NEXT_PUBLIC_FACE_SEARCH_STUB ?? '1',
|
||||
/*
|
||||
* C122 — the fake service-account key lets the agenda Google mirror
|
||||
* derive real states (synced/disabled/paused) in the server process.
|
||||
|
|
|
|||
306
pnpm-lock.yaml
306
pnpm-lock.yaml
|
|
@ -68,12 +68,21 @@ importers:
|
|||
'@simplewebauthn/server':
|
||||
specifier: ^13.3.2
|
||||
version: 13.3.2
|
||||
'@tensorflow/tfjs':
|
||||
specifier: 4.22.0
|
||||
version: 4.22.0(seedrandom@3.0.5)
|
||||
'@tensorflow/tfjs-backend-wasm':
|
||||
specifier: 4.22.0
|
||||
version: 4.22.0(@tensorflow/tfjs-core@4.22.0)
|
||||
'@vercel/analytics':
|
||||
specifier: ^1.6.1
|
||||
version: 1.6.1(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(react@19.2.4)
|
||||
'@vercel/speed-insights':
|
||||
specifier: ^1.3.1
|
||||
version: 1.3.1(next@15.4.11(@babel/core@7.29.0)(@playwright/test@1.58.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.77.4))(react@19.2.4)
|
||||
'@vladmandic/face-api':
|
||||
specifier: 1.7.15
|
||||
version: 1.7.15
|
||||
ai:
|
||||
specifier: ^7.0.47
|
||||
version: 7.0.47(zod@4.3.6)
|
||||
|
|
@ -3306,6 +3315,47 @@ packages:
|
|||
'@tailwindcss/postcss@4.1.18':
|
||||
resolution: {integrity: sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==}
|
||||
|
||||
'@tensorflow/tfjs-backend-cpu@4.22.0':
|
||||
resolution: {integrity: sha512-1u0FmuLGuRAi8D2c3cocHTASGXOmHc/4OvoVDENJayjYkS119fcTcQf4iHrtLthWyDIPy3JiPhRrZQC9EwnhLw==}
|
||||
engines: {yarn: '>= 1.3.2'}
|
||||
peerDependencies:
|
||||
'@tensorflow/tfjs-core': 4.22.0
|
||||
|
||||
'@tensorflow/tfjs-backend-wasm@4.22.0':
|
||||
resolution: {integrity: sha512-/IYhReRIp4jg/wYW0OwbbJZG8ON87mbz0PgkiP3CdcACRSvUN0h8rvC0O3YcDtkTQtFWF/tcXq/KlVDyV49wmA==}
|
||||
peerDependencies:
|
||||
'@tensorflow/tfjs-core': 4.22.0
|
||||
|
||||
'@tensorflow/tfjs-backend-webgl@4.22.0':
|
||||
resolution: {integrity: sha512-H535XtZWnWgNwSzv538czjVlbJebDl5QTMOth4RXr2p/kJ1qSIXE0vZvEtO+5EC9b00SvhplECny2yDewQb/Yg==}
|
||||
engines: {yarn: '>= 1.3.2'}
|
||||
peerDependencies:
|
||||
'@tensorflow/tfjs-core': 4.22.0
|
||||
|
||||
'@tensorflow/tfjs-converter@4.22.0':
|
||||
resolution: {integrity: sha512-PT43MGlnzIo+YfbsjM79Lxk9lOq6uUwZuCc8rrp0hfpLjF6Jv8jS84u2jFb+WpUeuF4K33ZDNx8CjiYrGQ2trQ==}
|
||||
peerDependencies:
|
||||
'@tensorflow/tfjs-core': 4.22.0
|
||||
|
||||
'@tensorflow/tfjs-core@4.22.0':
|
||||
resolution: {integrity: sha512-LEkOyzbknKFoWUwfkr59vSB68DMJ4cjwwHgicXN0DUi3a0Vh1Er3JQqCI1Hl86GGZQvY8ezVrtDIvqR1ZFW55A==}
|
||||
engines: {yarn: '>= 1.3.2'}
|
||||
|
||||
'@tensorflow/tfjs-data@4.22.0':
|
||||
resolution: {integrity: sha512-dYmF3LihQIGvtgJrt382hSRH4S0QuAp2w1hXJI2+kOaEqo5HnUPG0k5KA6va+S1yUhx7UBToUKCBHeLHFQRV4w==}
|
||||
peerDependencies:
|
||||
'@tensorflow/tfjs-core': 4.22.0
|
||||
seedrandom: ^3.0.5
|
||||
|
||||
'@tensorflow/tfjs-layers@4.22.0':
|
||||
resolution: {integrity: sha512-lybPj4ZNj9iIAPUj7a8ZW1hg8KQGfqWLlCZDi9eM/oNKCCAgchiyzx8OrYoWmRrB+AM6VNEeIT+2gZKg5ReihA==}
|
||||
peerDependencies:
|
||||
'@tensorflow/tfjs-core': 4.22.0
|
||||
|
||||
'@tensorflow/tfjs@4.22.0':
|
||||
resolution: {integrity: sha512-0TrIrXs6/b7FLhLVNmfh8Sah6JgjBPH4mZ8JGb7NU6WW+cx00qK5BcAZxw7NCzxj6N8MRAIfHq+oNbPUNG5VAg==}
|
||||
hasBin: true
|
||||
|
||||
'@testing-library/dom@10.4.1':
|
||||
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
|
||||
engines: {node: '>=18'}
|
||||
|
|
@ -3387,6 +3437,9 @@ packages:
|
|||
'@types/deep-eql@4.0.2':
|
||||
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
||||
|
||||
'@types/emscripten@0.0.34':
|
||||
resolution: {integrity: sha512-QSb9ojDincskc+uKMI0KXp8e1NALFINCrMlp8VGKGcTSxeEyRTTKyjWw75NYrCZHUsVEEEpr1tYHpbtaC++/sQ==}
|
||||
|
||||
'@types/estree-jsx@1.0.5':
|
||||
resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==}
|
||||
|
||||
|
|
@ -3414,6 +3467,9 @@ packages:
|
|||
'@types/lodash@4.17.23':
|
||||
resolution: {integrity: sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==}
|
||||
|
||||
'@types/long@4.0.2':
|
||||
resolution: {integrity: sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==}
|
||||
|
||||
'@types/mdast@4.0.4':
|
||||
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
|
||||
|
||||
|
|
@ -3423,12 +3479,21 @@ packages:
|
|||
'@types/ndarray@1.0.14':
|
||||
resolution: {integrity: sha512-oANmFZMnFQvb219SSBIhI1Ih/r4CvHDOzkWyJS/XRqkMrGH5/kaPSA1hQhdIBzouaE+5KpE/f5ylI9cujmckQg==}
|
||||
|
||||
'@types/node-fetch@2.6.13':
|
||||
resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==}
|
||||
|
||||
'@types/node@20.3.3':
|
||||
resolution: {integrity: sha512-wheIYdr4NYML61AjC8MKj/2jrR/kDQri/CIpVoZwldwhnIrD/j9jIU5bJ8yBKuB2VhpFV7Ab6G2XkBjv9r9Zzw==}
|
||||
|
||||
'@types/node@22.19.9':
|
||||
resolution: {integrity: sha512-PD03/U8g1F9T9MI+1OBisaIARhSzeidsUjQaf51fOxrfjeiKN9bLVO06lHuHYjxdnqLWJijJHfqXPSJri2EM2A==}
|
||||
|
||||
'@types/offscreencanvas@2019.3.0':
|
||||
resolution: {integrity: sha512-esIJx9bQg+QYF0ra8GnvfianIY8qWB0GBx54PK5Eps6m+xTj86KLavHv6qDhzKcu5UUOgNfJ2pWaIIV7TRUd9Q==}
|
||||
|
||||
'@types/offscreencanvas@2019.7.3':
|
||||
resolution: {integrity: sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==}
|
||||
|
||||
'@types/parse-json@4.0.2':
|
||||
resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==}
|
||||
|
||||
|
|
@ -3448,6 +3513,9 @@ packages:
|
|||
'@types/react@19.2.14':
|
||||
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
|
||||
|
||||
'@types/seedrandom@2.4.34':
|
||||
resolution: {integrity: sha512-ytDiArvrn/3Xk6/vtylys5tlY6eo7Ane0hvcx++TKo6RxQXuVfW0AF/oeWqAj9dN29SyhtawuXstgmPlwNcv/A==}
|
||||
|
||||
'@types/shpjs@3.4.7':
|
||||
resolution: {integrity: sha512-/6PjggpFsq9NFxar6ZpXsnYZ+nQJR8Cv03Gne1enIJuMZ/eFVOpu0orHxL9D7RT3ciJElzF2H6l+49US23ydUw==}
|
||||
|
||||
|
|
@ -3897,6 +3965,10 @@ packages:
|
|||
'@vitest/utils@4.0.18':
|
||||
resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==}
|
||||
|
||||
'@vladmandic/face-api@1.7.15':
|
||||
resolution: {integrity: sha512-WDMmK3CfNLo8jylWqMoQgf4nIst3M0fzx1dnac96wv/dvMTN4DxC/Pq1DGtduDk1lktCamQ3MIDXFnvrdHTXDw==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
'@vue/compiler-core@3.5.40':
|
||||
resolution: {integrity: sha512-39E8IgOhTbVDnoJFMKc2DvYnypcZwUqgUhQkccva/0m6FUwtIKSGV7n1hpVmYcFaoRAwf9pBcwnKlCEsN63ZEQ==}
|
||||
|
||||
|
|
@ -3912,6 +3984,9 @@ packages:
|
|||
'@vue/shared@3.5.40':
|
||||
resolution: {integrity: sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg==}
|
||||
|
||||
'@webgpu/types@0.1.38':
|
||||
resolution: {integrity: sha512-7LrhVKz2PRh+DD7+S+PVaFd5HxaWQvoMqBbsV9fNJO1pjUs1P8bM2vQVNfk+3URTqbuTI7gkXi0rfsN0IadoBA==}
|
||||
|
||||
'@workflow/serde@4.1.0':
|
||||
resolution: {integrity: sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ==}
|
||||
|
||||
|
|
@ -3998,6 +4073,9 @@ packages:
|
|||
app-module-path@2.2.0:
|
||||
resolution: {integrity: sha512-gkco+qxENJV+8vFcDiiFhuoSvRXb2a/QPqpSoWhVz829VNJfOTnELbBmPmNKFxf3xdNnw4DWCkzkDaavcX/1YQ==}
|
||||
|
||||
argparse@1.0.10:
|
||||
resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==}
|
||||
|
||||
argparse@2.0.1:
|
||||
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
||||
|
||||
|
|
@ -4073,6 +4151,9 @@ packages:
|
|||
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
asynckit@0.4.0:
|
||||
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
|
||||
|
||||
atomic-sleep@1.0.0:
|
||||
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
|
@ -4269,6 +4350,9 @@ packages:
|
|||
client-only@0.0.1:
|
||||
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
|
||||
|
||||
cliui@7.0.4:
|
||||
resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==}
|
||||
|
||||
cliui@8.0.1:
|
||||
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
|
@ -4311,6 +4395,10 @@ packages:
|
|||
colorette@2.0.20:
|
||||
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
|
||||
|
||||
combined-stream@1.0.8:
|
||||
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
comma-separated-tokens@2.0.3:
|
||||
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
|
||||
|
||||
|
|
@ -4368,6 +4456,9 @@ packages:
|
|||
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
core-js@3.29.1:
|
||||
resolution: {integrity: sha512-+jwgnhg6cQxKYIIjGtAHq2nwUOolo9eoFZ4sHfUH09BLXBgxnH4gA0zEd+t+BO2cNB8idaBtZFcFTRjQJRJmAw==}
|
||||
|
||||
cors@2.8.6:
|
||||
resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
|
@ -4540,6 +4631,10 @@ packages:
|
|||
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
delayed-stream@1.0.0:
|
||||
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
|
||||
depd@2.0.0:
|
||||
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
|
@ -5158,6 +5253,10 @@ packages:
|
|||
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
form-data@4.0.6:
|
||||
resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
formatly@0.3.0:
|
||||
resolution: {integrity: sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==}
|
||||
engines: {node: '>=18.3.0'}
|
||||
|
|
@ -5358,6 +5457,10 @@ packages:
|
|||
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
hasown@2.0.4:
|
||||
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
hast-util-to-jsx-runtime@2.3.6:
|
||||
resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
|
||||
|
||||
|
|
@ -5954,6 +6057,9 @@ packages:
|
|||
resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
long@4.0.0:
|
||||
resolution: {integrity: sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==}
|
||||
|
||||
longest-streak@3.1.0:
|
||||
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
|
||||
|
||||
|
|
@ -6181,10 +6287,18 @@ packages:
|
|||
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
|
||||
engines: {node: '>=8.6'}
|
||||
|
||||
mime-db@1.52.0:
|
||||
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
mime-db@1.54.0:
|
||||
resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
mime-types@2.1.35:
|
||||
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
mime-types@3.0.2:
|
||||
resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==}
|
||||
engines: {node: '>=18'}
|
||||
|
|
@ -6312,6 +6426,15 @@ packages:
|
|||
engines: {node: '>=10.5.0'}
|
||||
deprecated: Use your platform's native DOMException instead
|
||||
|
||||
node-fetch@2.6.13:
|
||||
resolution: {integrity: sha512-StxNAxh15zr77QvvkmveSQ8uCQ4+v5FkvNTj0OESmiHu+VRi/gXArXtkWMElOsOUNLtUEvI4yS+rdtOHZTwlQA==}
|
||||
engines: {node: 4.x || >=6.0.0}
|
||||
peerDependencies:
|
||||
encoding: ^0.1.0
|
||||
peerDependenciesMeta:
|
||||
encoding:
|
||||
optional: true
|
||||
|
||||
node-fetch@3.3.2:
|
||||
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
|
|
@ -6948,6 +7071,9 @@ packages:
|
|||
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
regenerator-runtime@0.13.11:
|
||||
resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==}
|
||||
|
||||
regexp.prototype.flags@1.5.4:
|
||||
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
|
@ -7101,6 +7227,9 @@ packages:
|
|||
secure-json-parse@4.1.0:
|
||||
resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==}
|
||||
|
||||
seedrandom@3.0.5:
|
||||
resolution: {integrity: sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==}
|
||||
|
||||
semver@6.3.1:
|
||||
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
||||
hasBin: true
|
||||
|
|
@ -7235,6 +7364,9 @@ packages:
|
|||
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
||||
engines: {node: '>= 10.x'}
|
||||
|
||||
sprintf-js@1.0.3:
|
||||
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
|
||||
|
||||
ssf@0.11.2:
|
||||
resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
|
@ -7512,6 +7644,9 @@ packages:
|
|||
resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
tr46@0.0.3:
|
||||
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
|
||||
|
||||
tr46@6.0.0:
|
||||
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
|
||||
engines: {node: '>=20'}
|
||||
|
|
@ -7876,6 +8011,9 @@ packages:
|
|||
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
webidl-conversions@3.0.1:
|
||||
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
|
||||
|
||||
webidl-conversions@8.0.1:
|
||||
resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
|
||||
engines: {node: '>=20'}
|
||||
|
|
@ -7892,6 +8030,9 @@ packages:
|
|||
resolution: {integrity: sha512-9CcxtEKsf53UFwkSUZjG+9vydAsFO4lFHBpJUtjBcoJOCJpKnSJNwCw813zrYJHpCJ7sgfbtOe0V5Ku7Pa1XMQ==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
|
||||
whatwg-url@5.0.0:
|
||||
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
|
||||
|
||||
which-boxed-primitive@1.1.1:
|
||||
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
|
@ -8010,10 +8151,18 @@ packages:
|
|||
engines: {node: '>= 14.6'}
|
||||
hasBin: true
|
||||
|
||||
yargs-parser@20.2.9:
|
||||
resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
yargs-parser@21.1.1:
|
||||
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
yargs@16.2.2:
|
||||
resolution: {integrity: sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
yargs@17.7.2:
|
||||
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
|
||||
engines: {node: '>=12'}
|
||||
|
|
@ -11180,6 +11329,73 @@ snapshots:
|
|||
postcss: 8.5.6
|
||||
tailwindcss: 4.1.18
|
||||
|
||||
'@tensorflow/tfjs-backend-cpu@4.22.0(@tensorflow/tfjs-core@4.22.0)':
|
||||
dependencies:
|
||||
'@tensorflow/tfjs-core': 4.22.0
|
||||
'@types/seedrandom': 2.4.34
|
||||
seedrandom: 3.0.5
|
||||
|
||||
'@tensorflow/tfjs-backend-wasm@4.22.0(@tensorflow/tfjs-core@4.22.0)':
|
||||
dependencies:
|
||||
'@tensorflow/tfjs-backend-cpu': 4.22.0(@tensorflow/tfjs-core@4.22.0)
|
||||
'@tensorflow/tfjs-core': 4.22.0
|
||||
'@types/emscripten': 0.0.34
|
||||
|
||||
'@tensorflow/tfjs-backend-webgl@4.22.0(@tensorflow/tfjs-core@4.22.0)':
|
||||
dependencies:
|
||||
'@tensorflow/tfjs-backend-cpu': 4.22.0(@tensorflow/tfjs-core@4.22.0)
|
||||
'@tensorflow/tfjs-core': 4.22.0
|
||||
'@types/offscreencanvas': 2019.3.0
|
||||
'@types/seedrandom': 2.4.34
|
||||
seedrandom: 3.0.5
|
||||
|
||||
'@tensorflow/tfjs-converter@4.22.0(@tensorflow/tfjs-core@4.22.0)':
|
||||
dependencies:
|
||||
'@tensorflow/tfjs-core': 4.22.0
|
||||
|
||||
'@tensorflow/tfjs-core@4.22.0':
|
||||
dependencies:
|
||||
'@types/long': 4.0.2
|
||||
'@types/offscreencanvas': 2019.7.3
|
||||
'@types/seedrandom': 2.4.34
|
||||
'@webgpu/types': 0.1.38
|
||||
long: 4.0.0
|
||||
node-fetch: 2.6.13
|
||||
seedrandom: 3.0.5
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
|
||||
'@tensorflow/tfjs-data@4.22.0(@tensorflow/tfjs-core@4.22.0)(seedrandom@3.0.5)':
|
||||
dependencies:
|
||||
'@tensorflow/tfjs-core': 4.22.0
|
||||
'@types/node-fetch': 2.6.13
|
||||
node-fetch: 2.6.13
|
||||
seedrandom: 3.0.5
|
||||
string_decoder: 1.3.0
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
|
||||
'@tensorflow/tfjs-layers@4.22.0(@tensorflow/tfjs-core@4.22.0)':
|
||||
dependencies:
|
||||
'@tensorflow/tfjs-core': 4.22.0
|
||||
|
||||
'@tensorflow/tfjs@4.22.0(seedrandom@3.0.5)':
|
||||
dependencies:
|
||||
'@tensorflow/tfjs-backend-cpu': 4.22.0(@tensorflow/tfjs-core@4.22.0)
|
||||
'@tensorflow/tfjs-backend-webgl': 4.22.0(@tensorflow/tfjs-core@4.22.0)
|
||||
'@tensorflow/tfjs-converter': 4.22.0(@tensorflow/tfjs-core@4.22.0)
|
||||
'@tensorflow/tfjs-core': 4.22.0
|
||||
'@tensorflow/tfjs-data': 4.22.0(@tensorflow/tfjs-core@4.22.0)(seedrandom@3.0.5)
|
||||
'@tensorflow/tfjs-layers': 4.22.0(@tensorflow/tfjs-core@4.22.0)
|
||||
argparse: 1.0.10
|
||||
chalk: 4.1.2
|
||||
core-js: 3.29.1
|
||||
regenerator-runtime: 0.13.11
|
||||
yargs: 16.2.2
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
- seedrandom
|
||||
|
||||
'@testing-library/dom@10.4.1':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.0
|
||||
|
|
@ -11283,6 +11499,8 @@ snapshots:
|
|||
|
||||
'@types/deep-eql@4.0.2': {}
|
||||
|
||||
'@types/emscripten@0.0.34': {}
|
||||
|
||||
'@types/estree-jsx@1.0.5':
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
|
|
@ -11307,6 +11525,8 @@ snapshots:
|
|||
|
||||
'@types/lodash@4.17.23': {}
|
||||
|
||||
'@types/long@4.0.2': {}
|
||||
|
||||
'@types/mdast@4.0.4':
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
|
@ -11315,12 +11535,21 @@ snapshots:
|
|||
|
||||
'@types/ndarray@1.0.14': {}
|
||||
|
||||
'@types/node-fetch@2.6.13':
|
||||
dependencies:
|
||||
'@types/node': 22.19.9
|
||||
form-data: 4.0.6
|
||||
|
||||
'@types/node@20.3.3': {}
|
||||
|
||||
'@types/node@22.19.9':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/offscreencanvas@2019.3.0': {}
|
||||
|
||||
'@types/offscreencanvas@2019.7.3': {}
|
||||
|
||||
'@types/parse-json@4.0.2': {}
|
||||
|
||||
'@types/pg@8.10.2':
|
||||
|
|
@ -11341,6 +11570,8 @@ snapshots:
|
|||
dependencies:
|
||||
csstype: 3.2.3
|
||||
|
||||
'@types/seedrandom@2.4.34': {}
|
||||
|
||||
'@types/shpjs@3.4.7':
|
||||
dependencies:
|
||||
'@types/geojson': 7946.0.16
|
||||
|
|
@ -11721,6 +11952,8 @@ snapshots:
|
|||
'@vitest/pretty-format': 4.0.18
|
||||
tinyrainbow: 3.0.3
|
||||
|
||||
'@vladmandic/face-api@1.7.15': {}
|
||||
|
||||
'@vue/compiler-core@3.5.40':
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.7
|
||||
|
|
@ -11753,6 +11986,8 @@ snapshots:
|
|||
|
||||
'@vue/shared@3.5.40': {}
|
||||
|
||||
'@webgpu/types@0.1.38': {}
|
||||
|
||||
'@workflow/serde@4.1.0': {}
|
||||
|
||||
accepts@2.0.0:
|
||||
|
|
@ -11832,6 +12067,10 @@ snapshots:
|
|||
|
||||
app-module-path@2.2.0: {}
|
||||
|
||||
argparse@1.0.10:
|
||||
dependencies:
|
||||
sprintf-js: 1.0.3
|
||||
|
||||
argparse@2.0.1: {}
|
||||
|
||||
aria-hidden@1.2.6:
|
||||
|
|
@ -11942,6 +12181,8 @@ snapshots:
|
|||
|
||||
async-function@1.0.0: {}
|
||||
|
||||
asynckit@0.4.0: {}
|
||||
|
||||
atomic-sleep@1.0.0: {}
|
||||
|
||||
available-typed-arrays@1.0.7:
|
||||
|
|
@ -12134,6 +12375,12 @@ snapshots:
|
|||
|
||||
client-only@0.0.1: {}
|
||||
|
||||
cliui@7.0.4:
|
||||
dependencies:
|
||||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
wrap-ansi: 7.0.0
|
||||
|
||||
cliui@8.0.1:
|
||||
dependencies:
|
||||
string-width: 4.2.3
|
||||
|
|
@ -12178,6 +12425,10 @@ snapshots:
|
|||
|
||||
colorette@2.0.20: {}
|
||||
|
||||
combined-stream@1.0.8:
|
||||
dependencies:
|
||||
delayed-stream: 1.0.0
|
||||
|
||||
comma-separated-tokens@2.0.3: {}
|
||||
|
||||
commander@11.1.0: {}
|
||||
|
|
@ -12212,6 +12463,8 @@ snapshots:
|
|||
|
||||
cookie@1.1.1: {}
|
||||
|
||||
core-js@3.29.1: {}
|
||||
|
||||
cors@2.8.6:
|
||||
dependencies:
|
||||
object-assign: 4.1.1
|
||||
|
|
@ -12364,6 +12617,8 @@ snapshots:
|
|||
has-property-descriptors: 1.0.2
|
||||
object-keys: 1.1.1
|
||||
|
||||
delayed-stream@1.0.0: {}
|
||||
|
||||
depd@2.0.0: {}
|
||||
|
||||
dependency-tree@11.5.0:
|
||||
|
|
@ -13178,6 +13433,14 @@ snapshots:
|
|||
dependencies:
|
||||
is-callable: 1.2.7
|
||||
|
||||
form-data@4.0.6:
|
||||
dependencies:
|
||||
asynckit: 0.4.0
|
||||
combined-stream: 1.0.8
|
||||
es-set-tostringtag: 2.1.0
|
||||
hasown: 2.0.4
|
||||
mime-types: 2.1.35
|
||||
|
||||
formatly@0.3.0:
|
||||
dependencies:
|
||||
fd-package-json: 2.0.0
|
||||
|
|
@ -13371,6 +13634,10 @@ snapshots:
|
|||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
|
||||
hasown@2.0.4:
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
|
||||
hast-util-to-jsx-runtime@2.3.6:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
|
|
@ -13923,6 +14190,8 @@ snapshots:
|
|||
chalk: 5.6.2
|
||||
is-unicode-supported: 1.3.0
|
||||
|
||||
long@4.0.0: {}
|
||||
|
||||
longest-streak@3.1.0: {}
|
||||
|
||||
loose-envify@1.4.0:
|
||||
|
|
@ -14387,8 +14656,14 @@ snapshots:
|
|||
braces: 3.0.3
|
||||
picomatch: 2.3.1
|
||||
|
||||
mime-db@1.52.0: {}
|
||||
|
||||
mime-db@1.54.0: {}
|
||||
|
||||
mime-types@2.1.35:
|
||||
dependencies:
|
||||
mime-db: 1.52.0
|
||||
|
||||
mime-types@3.0.2:
|
||||
dependencies:
|
||||
mime-db: 1.54.0
|
||||
|
|
@ -14515,6 +14790,10 @@ snapshots:
|
|||
|
||||
node-domexception@1.0.0: {}
|
||||
|
||||
node-fetch@2.6.13:
|
||||
dependencies:
|
||||
whatwg-url: 5.0.0
|
||||
|
||||
node-fetch@3.3.2:
|
||||
dependencies:
|
||||
data-uri-to-buffer: 4.0.1
|
||||
|
|
@ -15324,6 +15603,8 @@ snapshots:
|
|||
get-proto: 1.0.1
|
||||
which-builtin-type: 1.2.1
|
||||
|
||||
regenerator-runtime@0.13.11: {}
|
||||
|
||||
regexp.prototype.flags@1.5.4:
|
||||
dependencies:
|
||||
call-bind: 1.0.8
|
||||
|
|
@ -15531,6 +15812,8 @@ snapshots:
|
|||
|
||||
secure-json-parse@4.1.0: {}
|
||||
|
||||
seedrandom@3.0.5: {}
|
||||
|
||||
semver@6.3.1: {}
|
||||
|
||||
semver@7.7.4: {}
|
||||
|
|
@ -15777,6 +16060,8 @@ snapshots:
|
|||
|
||||
split2@4.2.0: {}
|
||||
|
||||
sprintf-js@1.0.3: {}
|
||||
|
||||
ssf@0.11.2:
|
||||
dependencies:
|
||||
frac: 1.1.2
|
||||
|
|
@ -16056,6 +16341,8 @@ snapshots:
|
|||
dependencies:
|
||||
tldts: 7.0.23
|
||||
|
||||
tr46@0.0.3: {}
|
||||
|
||||
tr46@6.0.0:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
|
@ -16440,6 +16727,8 @@ snapshots:
|
|||
|
||||
web-streams-polyfill@3.3.3: {}
|
||||
|
||||
webidl-conversions@3.0.1: {}
|
||||
|
||||
webidl-conversions@8.0.1: {}
|
||||
|
||||
whatwg-mimetype@3.0.0: {}
|
||||
|
|
@ -16454,6 +16743,11 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- '@noble/hashes'
|
||||
|
||||
whatwg-url@5.0.0:
|
||||
dependencies:
|
||||
tr46: 0.0.3
|
||||
webidl-conversions: 3.0.1
|
||||
|
||||
which-boxed-primitive@1.1.1:
|
||||
dependencies:
|
||||
is-bigint: 1.1.0
|
||||
|
|
@ -16572,8 +16866,20 @@ snapshots:
|
|||
|
||||
yaml@2.9.0: {}
|
||||
|
||||
yargs-parser@20.2.9: {}
|
||||
|
||||
yargs-parser@21.1.1: {}
|
||||
|
||||
yargs@16.2.2:
|
||||
dependencies:
|
||||
cliui: 7.0.4
|
||||
escalade: 3.2.0
|
||||
get-caller-file: 2.0.5
|
||||
require-directory: 2.1.1
|
||||
string-width: 4.2.3
|
||||
y18n: 5.0.8
|
||||
yargs-parser: 20.2.9
|
||||
|
||||
yargs@17.7.2:
|
||||
dependencies:
|
||||
cliui: 8.0.1
|
||||
|
|
|
|||
56
scripts/copy-face-vision-assets.mjs
Normal file
56
scripts/copy-face-vision-assets.mjs
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* C234 — copies the `@vladmandic/face-api` model weights into
|
||||
* `public/fotos/modelos/` so the selfie search runs 100% same-origin (no CDN
|
||||
* executing in the visitor's browser; the e2e suite never touches the network).
|
||||
*
|
||||
* Same contract as `copy-card-vision-assets.mjs` (S15): the models are a
|
||||
* gitignored build artifact — this script runs in `pnpm dev` (preflight), in
|
||||
* `pnpm build` (npm `prebuild`) and in the Docker builder before `next build`.
|
||||
* Idempotent and cheap: a file already present with the same byte size is
|
||||
* skipped. The list must match the nets `faceSearchEngine.ts` (browser) and
|
||||
* `scripts/lib/faceEngine.mjs` (CLI) load.
|
||||
*/
|
||||
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
|
||||
export const FACE_MODEL_FILES = [
|
||||
'tiny_face_detector_model-weights_manifest.json',
|
||||
'tiny_face_detector_model.bin',
|
||||
'face_landmark_68_model-weights_manifest.json',
|
||||
'face_landmark_68_model.bin',
|
||||
'face_recognition_model-weights_manifest.json',
|
||||
'face_recognition_model.bin',
|
||||
]
|
||||
|
||||
export const copyFaceVisionAssets = async (repoRoot) => {
|
||||
const sourceDir = path.join(repoRoot, 'node_modules/@vladmandic/face-api/model')
|
||||
const targetDir = path.join(repoRoot, 'public/fotos/modelos')
|
||||
await mkdir(targetDir, { recursive: true })
|
||||
|
||||
for (const name of FACE_MODEL_FILES) {
|
||||
const source = path.join(sourceDir, name)
|
||||
const target = path.join(targetDir, name)
|
||||
|
||||
try {
|
||||
const [sourceStat, targetStat] = await Promise.all([
|
||||
stat(source),
|
||||
stat(target).catch(() => null),
|
||||
])
|
||||
if (targetStat && targetStat.size === sourceStat.size) continue
|
||||
await writeFile(target, await readFile(source))
|
||||
console.log(`[copy-face-vision-assets] ${name}`)
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`[copy-face-vision-assets] failed to copy ${name}: ${error instanceof Error ? error.message : error}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
|
||||
if (isDirectRun) {
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
await copyFaceVisionAssets(repoRoot)
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ import { spawn } from 'node:child_process'
|
|||
import { createRequire } from 'node:module'
|
||||
|
||||
import { copyCardVisionAssets } from './copy-card-vision-assets.mjs'
|
||||
import { copyFaceVisionAssets } from './copy-face-vision-assets.mjs'
|
||||
import './guard-dev-db.mjs'
|
||||
import { dieWithLabel, loadCliEnv, nextDevArgs, resolveDevPort } from './lib/cli.mjs'
|
||||
|
||||
|
|
@ -27,6 +28,7 @@ loadCliEnv()
|
|||
// server serves it same-origin before Next boots.
|
||||
try {
|
||||
await copyCardVisionAssets(process.cwd())
|
||||
await copyFaceVisionAssets(process.cwd())
|
||||
} catch (error) {
|
||||
die(error.message)
|
||||
}
|
||||
|
|
|
|||
224
scripts/enroll-face-subject.mjs
Normal file
224
scripts/enroll-face-subject.mjs
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
/**
|
||||
* C234 — enrolls one person in the selfie-search index (A/C scope of the gate,
|
||||
* PR #1370): the assessoria runs this WITH the person, after the signed
|
||||
* adherence consent, and the row is the only place a consented descriptor
|
||||
* exists. The selfie is read from disk, the descriptor is computed locally by
|
||||
* the same face-api nets the browser runs (only ONE face is accepted) and the
|
||||
* image is never stored or uploaded anywhere.
|
||||
*
|
||||
* Modes:
|
||||
* pnpm faces:enroll --label "<nome>" --selfie <arquivo>
|
||||
* plan/dry-run (default) — detects the face,
|
||||
* resolves the consent and reports; no write
|
||||
* pnpm faces:enroll ... --apply writes the faceSubject row; requires
|
||||
* FACE_ENROLL_CONFIRM=1 outside local dev and
|
||||
* the declared TEQO_ENV matching the database
|
||||
*
|
||||
* Options: --subject <id> (re-enrollment: replaces the descriptor, advances
|
||||
* `enrolledAt` and clears the derived links), --out <dir> (receipts, default
|
||||
* `data/face`), --help.
|
||||
*
|
||||
* Guards: fails closed when the index Consent (stable key) is not configured,
|
||||
* when the selfie has 0 or more than 1 detectable face, and on a non-local
|
||||
* target without the intent flag + declared environment. The receipt never
|
||||
* carries the descriptor nor the image.
|
||||
*/
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import { FACE_INDEX_CONSENT_KEY } from '../src/lib/campaignConsentKeys.ts'
|
||||
import { FACE_SEARCH_MODEL } from '../src/lib/faceSearch.ts'
|
||||
|
||||
import {
|
||||
TEQO_ENV_DATABASE_BY_ENV,
|
||||
assertEnvironmentDatabaseTarget,
|
||||
assertWriteConfirm,
|
||||
databaseTarget,
|
||||
dieWithLabel,
|
||||
loadCliEnv,
|
||||
writeRepoFile,
|
||||
} from './lib/cli.mjs'
|
||||
import { createNodeFaceEngine } from './lib/faceEngine.mjs'
|
||||
import {
|
||||
FACE_ENROLL_DEFAULT_OUT_DIR,
|
||||
faceEnrollReportStamp,
|
||||
formatFaceEnrollReport,
|
||||
parseFaceEnrollCliArgs,
|
||||
} from './lib/faceEnrollPlan.mjs'
|
||||
|
||||
loadCliEnv()
|
||||
|
||||
const die = dieWithLabel('faces:enroll')
|
||||
|
||||
const WRITE_CONFIRM_FLAG = 'FACE_ENROLL_CONFIRM'
|
||||
|
||||
const envLines = Object.entries(TEQO_ENV_DATABASE_BY_ENV)
|
||||
.map(([environment, database]) => `${environment} (${database})`)
|
||||
.join(' | ')
|
||||
|
||||
const HELP = `
|
||||
Uso: pnpm faces:enroll --label "<nome>" --selfie <arquivo> [opções]
|
||||
|
||||
Inscreve uma pessoa no índice da busca por selfie (/fotos/encontre). A selfie é
|
||||
lida do disco e processada localmente (mesmos modelos do navegador): exatamente
|
||||
1 rosto é exigido. O vetor fica no servidor; a imagem nunca é guardada.
|
||||
|
||||
Modos:
|
||||
(sem flag) plano/dry-run — detecta o rosto e resolve o consentimento; sem escrita
|
||||
--apply grava a pessoa no índice (exige ${WRITE_CONFIRM_FLAG}=1 fora do dev local)
|
||||
|
||||
Opções:
|
||||
--label <nome> nome interno da pessoa (nunca aparece no site)
|
||||
--selfie <arquivo> foto com um único rosto
|
||||
--subject <id> re-enrollment: substitui o vetor e limpa os vínculos derivados
|
||||
--out <dir> diretório dos recibos (default ${FACE_ENROLL_DEFAULT_OUT_DIR})
|
||||
--help esta ajuda
|
||||
|
||||
Ambiente:
|
||||
DATABASE_URL alvo (obrigatório em todos os modos)
|
||||
|
||||
--apply em alvo não-local/produção exige ${WRITE_CONFIRM_FLAG}=1 e TEQO_ENV=${envLines}
|
||||
casando o banco exato. O consentimento de adesão (chave ${FACE_INDEX_CONSENT_KEY}) precisa
|
||||
existir no admin; sem ele o fluxo falha fechado.
|
||||
`
|
||||
|
||||
const writeReport = async (options, report, suffix = '') => {
|
||||
const stamp = faceEnrollReportStamp(report.runAt)
|
||||
const relativePath = join(options.out, 'reports', `face-enroll-${stamp}${suffix}.json`)
|
||||
const body = JSON.stringify(report, null, 2)
|
||||
await writeRepoFile({
|
||||
label: 'faces:enroll',
|
||||
root: process.cwd(),
|
||||
relativePath,
|
||||
body,
|
||||
})
|
||||
return join(process.cwd(), relativePath)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseFaceEnrollCliArgs(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 ? 'apply' : 'plan'
|
||||
const runAt = new Date().toISOString()
|
||||
const startedAt = Date.now()
|
||||
console.log(`[faces:enroll] alvo: ${databaseTarget()} | modo: ${mode}`)
|
||||
|
||||
if (mode === 'apply') {
|
||||
assertWriteConfirm({
|
||||
label: 'faces:enroll',
|
||||
flag: WRITE_CONFIRM_FLAG,
|
||||
command: 'pnpm faces:enroll --label "<nome>" --selfie <arquivo> --apply',
|
||||
})
|
||||
assertEnvironmentDatabaseTarget()
|
||||
}
|
||||
|
||||
try {
|
||||
// Readability probe only: the bytes are decoded by `prepareFaceImage`
|
||||
// below; this gives the operator a friendly refusal before the engine.
|
||||
await readFile(options.selfie)
|
||||
} catch (error) {
|
||||
die(`não foi possível ler a selfie ${options.selfie}: ${error?.message ?? error}`)
|
||||
}
|
||||
|
||||
const engine = await createNodeFaceEngine(process.cwd()).catch((error) => {
|
||||
die(error.message)
|
||||
})
|
||||
|
||||
const { prepareFaceImage } =
|
||||
await import('../src/utilities/faceSubjects/faceSubjectPhotoIndex.ts')
|
||||
let descriptors
|
||||
try {
|
||||
descriptors = await engine.detectPixels(await prepareFaceImage(options.selfie))
|
||||
} catch (error) {
|
||||
die(`engine falhou ao processar a selfie: ${error?.message ?? error}`)
|
||||
}
|
||||
|
||||
const baseReport = {
|
||||
runAt,
|
||||
mode,
|
||||
target: databaseTarget(),
|
||||
model: FACE_SEARCH_MODEL,
|
||||
label: options.label.trim(),
|
||||
subjectId: options.subject,
|
||||
subjectAction: null,
|
||||
faceCount: descriptors.length,
|
||||
allowed: descriptors.length === 1,
|
||||
reason:
|
||||
descriptors.length === 1
|
||||
? null
|
||||
: descriptors.length === 0
|
||||
? 'nenhum rosto detectado — use uma selfie nítida, de frente e bem iluminada'
|
||||
: 'mais de um rosto na selfie — use uma foto com apenas uma pessoa',
|
||||
consentKey: FACE_INDEX_CONSENT_KEY,
|
||||
consentHash: null,
|
||||
durationMs: 0,
|
||||
}
|
||||
|
||||
if (!baseReport.allowed) {
|
||||
const report = { ...baseReport, durationMs: Date.now() - startedAt }
|
||||
const reportPath = await writeReport(options, report)
|
||||
for (const line of formatFaceEnrollReport(report)) console.log(line)
|
||||
console.log(`\n[faces:enroll] recibo JSON: ${reportPath}`)
|
||||
die('enrollment recusado — detalhe no recibo acima.')
|
||||
}
|
||||
|
||||
const config = (await import('../src/payload.config.ts')).default
|
||||
const payload = await getPayload({ config })
|
||||
const { getConsentByKey } = await import('../src/utilities/campaignConsent.ts')
|
||||
const consent = await getConsentByKey(payload, FACE_INDEX_CONSENT_KEY)
|
||||
if (!consent) {
|
||||
const report = { ...baseReport, durationMs: Date.now() - startedAt }
|
||||
await writeReport(options, report)
|
||||
for (const line of formatFaceEnrollReport(report)) console.log(line)
|
||||
die(
|
||||
`consentimento de adesão não configurado (chave ${FACE_INDEX_CONSENT_KEY}) — o fluxo falha fechado.`,
|
||||
)
|
||||
}
|
||||
|
||||
const consentHash = consent.contentHash
|
||||
if (mode === 'plan') {
|
||||
const report = {
|
||||
...baseReport,
|
||||
consentHash,
|
||||
durationMs: Date.now() - startedAt,
|
||||
}
|
||||
const reportPath = await writeReport(options, report)
|
||||
for (const line of formatFaceEnrollReport(report)) console.log(line)
|
||||
console.log(`\n[faces:enroll] recibo JSON: ${reportPath}`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const { enrollFaceSubject } =
|
||||
await import('../src/utilities/faceSubjects/faceSubjectEnrollment.ts')
|
||||
const enrolled = await enrollFaceSubject({
|
||||
payload,
|
||||
label: baseReport.label,
|
||||
descriptor: descriptors[0],
|
||||
consent: { id: consent.id, contentHash: consentHash },
|
||||
subjectId: options.subject,
|
||||
})
|
||||
|
||||
const report = {
|
||||
...baseReport,
|
||||
subjectId: enrolled.id,
|
||||
subjectAction: enrolled.created ? 'created' : 'updated',
|
||||
consentHash,
|
||||
durationMs: Date.now() - startedAt,
|
||||
}
|
||||
const reportPath = await writeReport(options, report)
|
||||
for (const line of formatFaceEnrollReport(report)) console.log(line)
|
||||
console.log(`\n[faces:enroll] recibo JSON: ${reportPath}`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
die(error?.message || String(error))
|
||||
})
|
||||
266
scripts/index-archive-faces.mjs
Normal file
266
scripts/index-archive-faces.mjs
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
/**
|
||||
* C234 — indexes the approved photo archive for the selfie search: each photo
|
||||
* is downloaded from the private storage, prepared small and handed to the SAME
|
||||
* face-api engine the browser runs; the descriptors are compared against the
|
||||
* enrolled subjects and thrown away — only the photo↔subject links and the
|
||||
* photo's revision marker are persisted (A/C scope of the gate, PR #1370: no
|
||||
* anonymous face index anywhere). Adding/re-enrolling a subject changes the
|
||||
* revision, so the next run reprocesses every approved photo.
|
||||
*
|
||||
* Modes:
|
||||
* pnpm faces:index plan/dry-run (default) — the queue; no engine, no write
|
||||
* pnpm faces:index --apply processes the queue; requires
|
||||
* FACE_INDEX_CONFIRM=1 outside local dev, the
|
||||
* declared TEQO_ENV and the complete S3_* set
|
||||
* when the target media is remote
|
||||
* pnpm faces:index --verify read-only inventory (indexed/stale/consent)
|
||||
*
|
||||
* Options: --limit <n> (canary), --refresh (ignores the marker), --out <dir>
|
||||
* (receipts, default `data/face`), --help.
|
||||
*
|
||||
* Runbook: on the workstation, against the stack env — see
|
||||
* docs/ops/teqo-1313-deploy.md.
|
||||
*/
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import { FACE_INDEX_CONSENT_KEY } from '../src/lib/campaignConsentKeys.ts'
|
||||
import { FACE_SEARCH_MODEL } from '../src/lib/faceSearch.ts'
|
||||
|
||||
import {
|
||||
TEQO_ENV_DATABASE_BY_ENV,
|
||||
assertEnvironmentDatabaseTarget,
|
||||
assertWriteConfirm,
|
||||
databaseTarget,
|
||||
dieWithLabel,
|
||||
loadCliEnv,
|
||||
mirroredMediaRequired,
|
||||
writeRepoFile,
|
||||
} from './lib/cli.mjs'
|
||||
import { createNodeFaceEngine } from './lib/faceEngine.mjs'
|
||||
import {
|
||||
FACE_INDEX_DEFAULT_OUT_DIR,
|
||||
faceIndexReportStamp,
|
||||
formatFaceIndexInventory,
|
||||
formatFaceIndexReport,
|
||||
parseFaceIndexCliArgs,
|
||||
summarizeFaceIndexInventory,
|
||||
summarizeFaceIndexResults,
|
||||
} from './lib/faceIndexPlan.mjs'
|
||||
|
||||
loadCliEnv()
|
||||
|
||||
const die = dieWithLabel('faces:index')
|
||||
|
||||
const WRITE_CONFIRM_FLAG = 'FACE_INDEX_CONFIRM'
|
||||
|
||||
const envLines = Object.entries(TEQO_ENV_DATABASE_BY_ENV)
|
||||
.map(([environment, database]) => `${environment} (${database})`)
|
||||
.join(' | ')
|
||||
|
||||
const HELP = `
|
||||
Uso: pnpm faces:index [opções]
|
||||
|
||||
Indexa as fotos APROVADAS do acervo para a busca por selfie: por foto, detecta
|
||||
os rostos com os mesmos modelos do navegador, compara com os descritores das
|
||||
pessoas inscritas e grava só os vínculos (foto ↔ pessoa) + a revisão da foto.
|
||||
Os descritores dos rostos do acervo são descartados — não existe índice anônimo.
|
||||
|
||||
Modos (mutuamente exclusivos; o default é o plano):
|
||||
(sem flag) plano/dry-run — fila e revisão; sem engine nem escrita
|
||||
--apply processa a fila (engine local + escrita transacional)
|
||||
--verify inventário read-only (indexadas/desatualizadas/consentimento)
|
||||
|
||||
Opções:
|
||||
--limit <n> --apply: processa no máximo n fotos (canário)
|
||||
--refresh ignora o marcador de revisão e reprocessa tudo
|
||||
--out <dir> diretório dos recibos (default ${FACE_INDEX_DEFAULT_OUT_DIR})
|
||||
--help esta ajuda
|
||||
|
||||
Ambiente:
|
||||
DATABASE_URL alvo (obrigatório em todos os modos)
|
||||
|
||||
--apply em alvo não-local/produção exige ${WRITE_CONFIRM_FLAG}=1, TEQO_ENV=${envLines}
|
||||
casando o banco exato e as quatro envs S3_* quando a mídia for remota. O plano e o
|
||||
--verify são read-only.
|
||||
`
|
||||
|
||||
const writeReport = async (options, report, suffix = '') => {
|
||||
const stamp = faceIndexReportStamp(report.runAt)
|
||||
const relativePath = join(options.out, 'reports', `face-index-${stamp}${suffix}.json`)
|
||||
const body = JSON.stringify(report, null, 2)
|
||||
await writeRepoFile({
|
||||
label: 'faces:index',
|
||||
root: process.cwd(),
|
||||
relativePath,
|
||||
body,
|
||||
})
|
||||
return join(process.cwd(), relativePath)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseFaceIndexCliArgs(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 ? 'apply' : options.verify ? 'verify' : 'plan'
|
||||
const runAt = new Date().toISOString()
|
||||
console.log(`[faces:index] alvo: ${databaseTarget()} | modo: ${mode}`)
|
||||
|
||||
if (mode === 'apply') {
|
||||
assertWriteConfirm({
|
||||
label: 'faces:index',
|
||||
flag: WRITE_CONFIRM_FLAG,
|
||||
command: 'pnpm faces:index --apply',
|
||||
})
|
||||
assertEnvironmentDatabaseTarget()
|
||||
const { resolveS3StorageEnv } = await import('../src/utilities/mediaStorage.ts')
|
||||
const storage = resolveS3StorageEnv(process.env)
|
||||
if (mirroredMediaRequired({ s3Enabled: storage.enabled })) {
|
||||
die(
|
||||
'escrita em produção/remoto exige mídia espelhada: configure S3_BUCKET, S3_ENDPOINT, S3_ACCESS_KEY_ID e S3_SECRET_ACCESS_KEY; sem elas o acervo não está acessível no alvo.',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const config = (await import('../src/payload.config.ts')).default
|
||||
const { listArchivePhotoFaceIndexQueue, indexArchivePhotoFaces } =
|
||||
await import('../src/utilities/faceSubjects/faceSubjectPhotoIndex.ts')
|
||||
const { listFaceSubjects } = await import('../src/utilities/faceSubjects/faceSubjectReads.ts')
|
||||
const { getConsentByKey } = await import('../src/utilities/campaignConsent.ts')
|
||||
|
||||
const payload = await getPayload({ config })
|
||||
const startedAt = Date.now()
|
||||
|
||||
if (mode === 'verify') {
|
||||
const [queue, subjects, consent] = await Promise.all([
|
||||
listArchivePhotoFaceIndexQueue({ payload }),
|
||||
listFaceSubjects(payload),
|
||||
getConsentByKey(payload, FACE_INDEX_CONSENT_KEY),
|
||||
])
|
||||
const inventory = summarizeFaceIndexInventory({
|
||||
queue,
|
||||
subjects,
|
||||
currentConsentHash: consent?.contentHash ?? null,
|
||||
})
|
||||
const report = {
|
||||
runAt,
|
||||
mode,
|
||||
target: databaseTarget(),
|
||||
model: FACE_SEARCH_MODEL,
|
||||
...inventory,
|
||||
durationMs: Date.now() - startedAt,
|
||||
}
|
||||
const reportPath = await writeReport(options, report, '-verify')
|
||||
for (const line of formatFaceIndexInventory(inventory, FACE_SEARCH_MODEL)) console.log(line)
|
||||
console.log(`\n[faces:index] recibo JSON: ${reportPath}`)
|
||||
|
||||
const problems = []
|
||||
if (!consent) {
|
||||
problems.push(
|
||||
`consentimento de adesão ausente (chave ${FACE_INDEX_CONSENT_KEY}) — a busca responde vazio para todos`,
|
||||
)
|
||||
}
|
||||
if (inventory.staleConsentSubjects > 0) {
|
||||
problems.push(
|
||||
`${inventory.staleConsentSubjects} sujeito(s) com consentimento vencido — re-consentir e re-enrollar`,
|
||||
)
|
||||
}
|
||||
if (inventory.stale > 0) {
|
||||
problems.push(`${inventory.stale} foto(s) desatualizada(s) — rode o --apply`)
|
||||
}
|
||||
if (problems.length > 0) die(problems.join('; '))
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const queue = await listArchivePhotoFaceIndexQueue({
|
||||
payload,
|
||||
refresh: options.refresh,
|
||||
limit: mode === 'plan' ? undefined : (options.limit ?? undefined),
|
||||
})
|
||||
|
||||
if (mode === 'plan') {
|
||||
const report = {
|
||||
runAt,
|
||||
mode,
|
||||
target: databaseTarget(),
|
||||
model: FACE_SEARCH_MODEL,
|
||||
queue: {
|
||||
indexKey: queue.indexKey,
|
||||
totalApproved: queue.totalApproved,
|
||||
stale: queue.stale,
|
||||
eligibleSubjects: queue.eligibleSubjects,
|
||||
items: queue.items.length,
|
||||
},
|
||||
summary: { indexed: 0, failed: 0, linkedPhotos: 0, linksBySubject: [], failures: [] },
|
||||
preview: queue.items.slice(0, options.limit ?? 20).map((item) => ({ photoId: item.id })),
|
||||
durationMs: Date.now() - startedAt,
|
||||
}
|
||||
const reportPath = await writeReport(options, report)
|
||||
for (const line of formatFaceIndexReport(report)) console.log(line)
|
||||
console.log(`\n[faces:index] recibo JSON: ${reportPath}`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (queue.eligibleSubjects === 0) {
|
||||
die(
|
||||
'nenhuma pessoa elegível no índice — rode o faces:enroll antes (o lote não tem o que procurar).',
|
||||
)
|
||||
}
|
||||
|
||||
const engine = await createNodeFaceEngine(process.cwd()).catch((error) => {
|
||||
die(error.message)
|
||||
})
|
||||
|
||||
const results = []
|
||||
for (const item of queue.items) {
|
||||
const result = await indexArchivePhotoFaces({
|
||||
payload,
|
||||
item,
|
||||
indexKey: queue.indexKey,
|
||||
analyze: engine.detectPixels,
|
||||
})
|
||||
results.push({ photoId: item.id, ...result })
|
||||
const detail =
|
||||
result.status === 'failed'
|
||||
? ` [${result.stage}]: ${result.error}`
|
||||
: result.matchedSubjects.length > 0
|
||||
? ` → ${result.matchedSubjects.length} vínculo(s)`
|
||||
: ''
|
||||
console.log(`[faces:index] ${result.status}${detail} foto ${item.id}`)
|
||||
}
|
||||
|
||||
const summary = summarizeFaceIndexResults(results)
|
||||
const report = {
|
||||
runAt,
|
||||
mode,
|
||||
target: databaseTarget(),
|
||||
model: FACE_SEARCH_MODEL,
|
||||
queue: {
|
||||
indexKey: queue.indexKey,
|
||||
totalApproved: queue.totalApproved,
|
||||
stale: queue.stale,
|
||||
eligibleSubjects: queue.eligibleSubjects,
|
||||
items: queue.items.length,
|
||||
},
|
||||
summary,
|
||||
durationMs: Date.now() - startedAt,
|
||||
}
|
||||
const reportPath = await writeReport(options, report)
|
||||
for (const line of formatFaceIndexReport(report)) console.log(line)
|
||||
console.log(`\n[faces:index] recibo JSON: ${reportPath}`)
|
||||
|
||||
if (summary.indexed === 0 && results.length > 0) {
|
||||
die('nenhuma foto foi processada com sucesso — falhas listadas no recibo.')
|
||||
}
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
die(error?.message || String(error))
|
||||
})
|
||||
|
|
@ -55,6 +55,10 @@ export const E2E_CURATED_SPECS = [
|
|||
// media route) is a new contract and the migration makes every PR of this
|
||||
// delivery high-risk (curated only).
|
||||
'frontendFotos',
|
||||
// C234 — deliberate: the selfie search page/endpoint (Consent fail-closed,
|
||||
// only consented subjects, only approved photos) is a new contract and the
|
||||
// migration makes every PR of this delivery high-risk (curated only).
|
||||
'frontendFotosSelfie',
|
||||
]
|
||||
|
||||
/**
|
||||
|
|
@ -221,7 +225,29 @@ export const E2E_AFFECTED_MANIFEST = [
|
|||
// The canonical names of the "pessoa pública" facet.
|
||||
'src/lib/publicFigureCatalog.ts',
|
||||
],
|
||||
specs: ['frontendFotos'],
|
||||
specs: ['frontendFotos', 'frontendFotosSelfie'],
|
||||
},
|
||||
{
|
||||
// C234 — the selfie search: the `/fotos/encontre` page (already matched by
|
||||
// the C233 prefix above), the anonymous descriptor endpoint, the consented
|
||||
// subjects and the bounded-body/rate-limit owners it shares. The migration
|
||||
// makes every PR of this delivery high-risk (curated only).
|
||||
prefixes: [
|
||||
'src/app/(frontend)/api/fotos',
|
||||
'src/lib/faceSearch.ts',
|
||||
'src/utilities/faceSubjects',
|
||||
'src/collections/FaceSubject.ts',
|
||||
'src/collections/Consent.ts',
|
||||
'src/utilities/campaignConsent.ts',
|
||||
'src/lib/campaignConsentKeys.ts',
|
||||
],
|
||||
specs: ['frontendFotosSelfie'],
|
||||
},
|
||||
{
|
||||
// C234 — the bounded streaming body reader extracted from the beacon: both
|
||||
// anonymous JSON routes consume it, so a diff there wakes both specs.
|
||||
prefixes: ['src/utilities/boundedRequestBody.ts'],
|
||||
specs: ['frontendConteudos', 'frontendFotosSelfie'],
|
||||
},
|
||||
{
|
||||
// S21 — the campaign footer owns the conditional "Jingles" discovery link
|
||||
|
|
@ -457,7 +483,13 @@ export const E2E_AFFECTED_MANIFEST = [
|
|||
'src/app/(campaign)/campanha/actions/speech.ts',
|
||||
'src/app/(campaign)/campanha/actions/reels.ts',
|
||||
],
|
||||
specs: ['frontend', 'campaignNewsletter', 'campaignSpeechCut', 'campaignReel'],
|
||||
specs: [
|
||||
'frontend',
|
||||
'campaignNewsletter',
|
||||
'campaignSpeechCut',
|
||||
'campaignReel',
|
||||
'frontendFotosSelfie',
|
||||
],
|
||||
},
|
||||
{
|
||||
// Web Push client — the opt-in toast mounts on the campaign shell, so a
|
||||
|
|
@ -549,7 +581,7 @@ export const E2E_AFFECTED_MANIFEST = [
|
|||
// The role predicates drive the vertical gate and the assistant surfaces.
|
||||
'src/lib/campaignRoles',
|
||||
],
|
||||
specs: ['campaignSpeechAcervo', 'campaignSpeechCut', 'campaignReel'],
|
||||
specs: ['campaignSpeechAcervo', 'campaignSpeechCut', 'campaignReel', 'frontendFotosSelfie'],
|
||||
},
|
||||
{
|
||||
// C167 — the unlisted public page of a cut: 200 with the stored file and
|
||||
|
|
|
|||
84
scripts/lib/faceEngine.mjs
Normal file
84
scripts/lib/faceEngine.mjs
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
/**
|
||||
* C234 — the Node face engine of the maintenance CLIs: the SAME face-api nets
|
||||
* the visitor's browser loads (`tinyFaceDetector` + `faceLandmark68Net` +
|
||||
* `faceRecognitionNet`), with the same options, so a descriptor computed by the
|
||||
* enrollment/index CLI is comparable with the one computed on the device. The
|
||||
* wasm backend is the package's own Node pairing (`face-api.node-wasm.js` +
|
||||
* `@tensorflow/tfjs-backend-wasm`) — no native `tfjs-node` build, no headless
|
||||
* browser, no network.
|
||||
*
|
||||
* The engine only turns prepared pixels into descriptors; the caller owns the
|
||||
* image preparation (`prepareFaceImage`) and the distance math
|
||||
* (`src/lib/faceSearch.ts`). It is a manual smoke dependency, never in the
|
||||
* test suite (the suites inject a stub analyzer).
|
||||
*/
|
||||
import { createRequire } from 'node:module'
|
||||
import path from 'node:path'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
/**
|
||||
* Loads the nets once per process. Throws a named error when the wasm assets or
|
||||
* the models are missing — a broken install must fail before any DB write.
|
||||
*
|
||||
* @param {string} repoRoot
|
||||
*/
|
||||
export const createNodeFaceEngine = async (repoRoot) => {
|
||||
const { FACE_DETECTOR_INPUT_SIZE, FACE_DETECTOR_SCORE_THRESHOLD } =
|
||||
await import('../../src/lib/faceSearch.ts')
|
||||
|
||||
let faceapi
|
||||
try {
|
||||
// The tfjs runtime is required explicitly first: it is the exact instance
|
||||
// the node-wasm build and the wasm backend register against (same module
|
||||
// cache), and it keeps the dependency honest for the static checks.
|
||||
require('@tensorflow/tfjs')
|
||||
faceapi = require('@vladmandic/face-api/dist/face-api.node-wasm.js')
|
||||
const { setWasmPaths } = require('@tensorflow/tfjs-backend-wasm')
|
||||
setWasmPaths(path.join(repoRoot, 'node_modules/@tensorflow/tfjs-backend-wasm/dist/'))
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`engine indisponível (instale @vladmandic/face-api e @tensorflow/tfjs-backend-wasm): ${
|
||||
error instanceof Error ? error.message : error
|
||||
}`,
|
||||
)
|
||||
}
|
||||
|
||||
const tf = faceapi.tf
|
||||
await tf.setBackend('wasm')
|
||||
await tf.ready()
|
||||
|
||||
const modelDir = path.join(repoRoot, 'node_modules/@vladmandic/face-api/model')
|
||||
await faceapi.nets.tinyFaceDetector.loadFromDisk(modelDir)
|
||||
await faceapi.nets.faceLandmark68Net.loadFromDisk(modelDir)
|
||||
await faceapi.nets.faceRecognitionNet.loadFromDisk(modelDir)
|
||||
|
||||
const options = new faceapi.TinyFaceDetectorOptions({
|
||||
inputSize: FACE_DETECTOR_INPUT_SIZE,
|
||||
scoreThreshold: FACE_DETECTOR_SCORE_THRESHOLD,
|
||||
})
|
||||
|
||||
return {
|
||||
/**
|
||||
* Descriptors of every detected face, in detection order.
|
||||
* @param {{ pixels: Uint8Array, width: number, height: number }} image
|
||||
* @returns {Promise<number[][]>}
|
||||
*/
|
||||
detectPixels: async ({ pixels, width, height }) => {
|
||||
const tensor = tf.tensor3d(
|
||||
new Uint8Array(pixels.buffer, pixels.byteOffset, pixels.byteLength),
|
||||
[height, width, 3],
|
||||
'int32',
|
||||
)
|
||||
try {
|
||||
const results = await faceapi
|
||||
.detectAllFaces(tensor, options)
|
||||
.withFaceLandmarks()
|
||||
.withFaceDescriptors()
|
||||
return results.map((result) => Array.from(result.descriptor))
|
||||
} finally {
|
||||
tensor.dispose()
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
91
scripts/lib/faceEnrollPlan.mjs
Normal file
91
scripts/lib/faceEnrollPlan.mjs
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
/**
|
||||
* C234 — pure planning/reporting of `pnpm faces:enroll`: the argv parser and
|
||||
* the human lines + JSON receipt of the enrollment. No I/O of its own — the
|
||||
* engine, the Consent resolution and the Payload write arrive as parameters,
|
||||
* so the unit spec drives everything with fakes.
|
||||
*/
|
||||
|
||||
const USAGE =
|
||||
'uso: `pnpm faces:enroll --label "<nome>" --selfie <arquivo> [--subject <id>] [--apply]`'
|
||||
|
||||
export const FACE_ENROLL_DEFAULT_OUT_DIR = 'data/face'
|
||||
|
||||
export const faceEnrollReportStamp = (runAt) => String(runAt).replace(/[:.]/g, '-')
|
||||
|
||||
/**
|
||||
* `pnpm faces:enroll` argv parser — pure, so the unit spec pins it (the CLI
|
||||
* owns the exit and the help text). The default mode is the plan/dry-run;
|
||||
* `--label` and `--selfie` are required in both modes so the plan shows the
|
||||
* real operation, not an abstract one.
|
||||
*
|
||||
* @param {string[]} [argv]
|
||||
* @returns {{ apply: boolean, label: string | null, selfie: string | null, subject: number | null, out: string, help: boolean }}
|
||||
*/
|
||||
export const parseFaceEnrollCliArgs = (argv = process.argv.slice(2)) => {
|
||||
const options = {
|
||||
apply: false,
|
||||
label: null,
|
||||
selfie: null,
|
||||
subject: null,
|
||||
out: FACE_ENROLL_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 === '--label') options.label = value()
|
||||
else if (arg === '--selfie') options.selfie = value()
|
||||
else if (arg === '--subject') options.subject = Number(value())
|
||||
else if (arg === '--out') options.out = value()
|
||||
else throw new Error(`argumento desconhecido: ${arg} — ${USAGE}.`)
|
||||
}
|
||||
|
||||
if (options.help) return options
|
||||
if (!options.label?.trim()) throw new Error(`--label é obrigatório — ${USAGE}.`)
|
||||
if (!options.selfie?.trim()) throw new Error(`--selfie é obrigatório — ${USAGE}.`)
|
||||
if (options.subject !== null && (!Number.isSafeInteger(options.subject) || options.subject < 1)) {
|
||||
throw new Error('--subject deve ser um id >= 1.')
|
||||
}
|
||||
if (options.out.split(/[\\/]/).includes('..')) {
|
||||
throw new Error('--out não pode escapar do diretório do repo (sem "..").')
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
/**
|
||||
* The receipt + human lines of one enrollment: what was detected (face count —
|
||||
* never the descriptor), the subject row and the consent snapshot. A refusal
|
||||
* (0 or >1 faces) is a report with `allowed: false` and its reason.
|
||||
*
|
||||
* @param {{ runAt: string, mode: string, target: string, model: string, label: string, subjectId: number | null, subjectAction: 'created' | 'updated' | null, faceCount: number, allowed: boolean, reason?: string | null, consentKey: string, consentHash: string | null, durationMs: number }} report
|
||||
*/
|
||||
export const formatFaceEnrollReport = (report) => {
|
||||
const lines = [
|
||||
`[faces:enroll] modo: ${report.mode} | alvo: ${report.target} | modelo: ${report.model}`,
|
||||
`[faces:enroll] selfie: ${report.faceCount} rosto(s) detectado(s)`,
|
||||
]
|
||||
if (!report.allowed) {
|
||||
lines.push(`[faces:enroll] RECUSADO: ${report.reason ?? 'selfie fora do contrato'}`)
|
||||
return lines
|
||||
}
|
||||
if (report.subjectAction === null) {
|
||||
lines.push(`[faces:enroll] plano: ${report.label} (nada gravado; confirme com --apply)`)
|
||||
return lines
|
||||
}
|
||||
lines.push(
|
||||
`[faces:enroll] ${report.subjectAction === 'created' ? 'criada' : 'atualizada'}: #${report.subjectId} (${report.label})`,
|
||||
`[faces:enroll] consentimento: ${report.consentKey} · hash ${report.consentHash ?? '(ausente)'}`,
|
||||
`[faces:enroll] próximo passo: pnpm faces:index --apply (reprocessa as fotos aprovadas)`,
|
||||
)
|
||||
return lines
|
||||
}
|
||||
170
scripts/lib/faceIndexPlan.mjs
Normal file
170
scripts/lib/faceIndexPlan.mjs
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
/**
|
||||
* C234 — pure planning/reporting of `pnpm faces:index`: the argv parser, the
|
||||
* run summary, the `--verify` inventory and the human lines + JSON receipt. No
|
||||
* I/O of its own — the queue, the subjects and the results arrive as
|
||||
* parameters, so the unit spec drives everything with fakes.
|
||||
*/
|
||||
|
||||
const USAGE = 'uso: `pnpm faces:index [--apply|--verify] [--limit <n>] [--refresh] [--out <dir>]`'
|
||||
|
||||
export const FACE_INDEX_DEFAULT_OUT_DIR = 'data/face'
|
||||
|
||||
export const faceIndexReportStamp = (runAt) => String(runAt).replace(/[:.]/g, '-')
|
||||
|
||||
/**
|
||||
* `pnpm faces:index` argv parser — pure, so the unit spec pins it (the CLI owns
|
||||
* the exit and the help text). The default mode is the plan/dry-run.
|
||||
*
|
||||
* @param {string[]} [argv]
|
||||
* @returns {{ apply: boolean, verify: boolean, limit: number | null, refresh: boolean, out: string, help: boolean }}
|
||||
*/
|
||||
export const parseFaceIndexCliArgs = (argv = process.argv.slice(2)) => {
|
||||
const options = {
|
||||
apply: false,
|
||||
verify: false,
|
||||
limit: null,
|
||||
refresh: false,
|
||||
out: FACE_INDEX_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 === '--verify') options.verify = true
|
||||
else if (arg === '--refresh') options.refresh = true
|
||||
else if (arg === '--limit') options.limit = Number(value())
|
||||
else if (arg === '--out') options.out = value()
|
||||
else throw new Error(`argumento desconhecido: ${arg} — ${USAGE}.`)
|
||||
}
|
||||
|
||||
if (options.help) return options
|
||||
if (options.apply && options.verify) {
|
||||
throw new Error(`--apply e --verify são mutuamente exclusivos — ${USAGE}.`)
|
||||
}
|
||||
if (options.limit !== null && (!Number.isSafeInteger(options.limit) || options.limit < 1)) {
|
||||
throw new Error('--limit deve ser >= 1.')
|
||||
}
|
||||
if (options.out.split(/[\\/]/).includes('..')) {
|
||||
throw new Error('--out não pode escapar do diretório do repo (sem "..").')
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates one apply run: photos indexed (and how many subjects each linked),
|
||||
* photos refused by the engine/IO with stage+reason.
|
||||
*
|
||||
* @param {Array<{ photoId: number, status: string, matchedSubjects?: number[], stage?: string | null, error?: string | null }>} results
|
||||
*/
|
||||
export const summarizeFaceIndexResults = (results) => {
|
||||
const linkedBySubject = new Map()
|
||||
const failures = []
|
||||
let indexed = 0
|
||||
let failed = 0
|
||||
let linkedPhotos = 0
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === 'indexed') {
|
||||
indexed += 1
|
||||
const matched = result.matchedSubjects ?? []
|
||||
if (matched.length > 0) linkedPhotos += 1
|
||||
for (const id of matched) linkedBySubject.set(id, (linkedBySubject.get(id) ?? 0) + 1)
|
||||
} else if (result.status === 'failed') {
|
||||
failed += 1
|
||||
failures.push({
|
||||
photoId: result.photoId,
|
||||
stage: result.stage ?? null,
|
||||
error: result.error ?? null,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
indexed,
|
||||
failed,
|
||||
linkedPhotos,
|
||||
linksBySubject: [...linkedBySubject.entries()]
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([subjectId, photos]) => ({ subjectId, photos })),
|
||||
failures,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The `--verify` inventory: the honest gap is `stale` — approved photos whose
|
||||
* marker is not the current index revision (new enrollment, new model or never
|
||||
* indexed). Consent-edited subjects are reported separately: the query already
|
||||
* fails closed on them, but the operator must see they need re-consent.
|
||||
*
|
||||
* @param {{ queue: { indexKey: string, eligibleSubjects: number, totalApproved: number, stale: number }, subjects: Array<{ id: number, status: string | null, model: string | null, consentHash: string | null, matchedPhotoIds: number[] }>, currentConsentHash: string | null }} input
|
||||
*/
|
||||
export const summarizeFaceIndexInventory = ({ queue, subjects, currentConsentHash }) => {
|
||||
const active = subjects.filter((subject) => subject.status === 'active')
|
||||
const staleConsent = active.filter((subject) => subject.consentHash !== currentConsentHash)
|
||||
const linksBySubject = subjects
|
||||
.filter((subject) => subject.matchedPhotoIds.length > 0)
|
||||
.map((subject) => ({ subjectId: subject.id, photos: subject.matchedPhotoIds.length }))
|
||||
.sort((a, b) => a.subjectId - b.subjectId)
|
||||
|
||||
return {
|
||||
indexKey: queue.indexKey,
|
||||
totalApproved: queue.totalApproved,
|
||||
indexed: Math.max(0, queue.totalApproved - queue.stale),
|
||||
stale: queue.stale,
|
||||
// The eligibility predicate belongs to the batch owner; the inventory just
|
||||
// reports its count (a corrupted vector never counts as eligible).
|
||||
eligibleSubjects: queue.eligibleSubjects,
|
||||
activeSubjects: active.length,
|
||||
removedSubjects: subjects.length - active.length,
|
||||
staleConsentSubjects: staleConsent.length,
|
||||
linkedPhotos: linksBySubject.reduce((sum, link) => sum + link.photos, 0),
|
||||
linksBySubject,
|
||||
}
|
||||
}
|
||||
|
||||
/** One line per counted state of the inventory (pt-BR, operator-facing). */
|
||||
export const formatFaceIndexInventory = (inventory, model) => [
|
||||
`[faces:index] modelo: ${model}`,
|
||||
`[faces:index] fotos aprovadas: ${inventory.totalApproved}`,
|
||||
`[faces:index] indexadas na revisão atual: ${inventory.indexed}`,
|
||||
`[faces:index] desatualizadas (a processar): ${inventory.stale}`,
|
||||
`[faces:index] sujeitos: ${inventory.activeSubjects} ativo(s), ${inventory.removedSubjects} removido(s), ${inventory.eligibleSubjects} elegível(is)`,
|
||||
`[faces:index] sujeitos com consentimento vencido (inelegíveis até re-consentir): ${inventory.staleConsentSubjects}`,
|
||||
`[faces:index] vínculos foto↔sujeito: ${inventory.linkedPhotos}`,
|
||||
...inventory.linksBySubject.map(
|
||||
(link) => `[faces:index] sujeito #${link.subjectId}: ${link.photos} foto(s)`,
|
||||
),
|
||||
]
|
||||
|
||||
/** The receipt + human lines of one plan/apply run. */
|
||||
export const formatFaceIndexReport = (report) => {
|
||||
const lines = [
|
||||
`[faces:index] modo: ${report.mode} | alvo: ${report.target} | modelo: ${report.model}`,
|
||||
`[faces:index] fila: ${report.queue.items} foto(s) a processar (aprovadas: ${report.queue.totalApproved} | sujeitos elegíveis: ${report.queue.eligibleSubjects})`,
|
||||
]
|
||||
|
||||
if (report.mode !== 'plan') {
|
||||
lines.push(
|
||||
`[faces:index] processadas: ${report.summary.indexed} | vinculadas a alguém: ${report.summary.linkedPhotos} | falharam: ${report.summary.failed}`,
|
||||
)
|
||||
for (const link of report.summary.linksBySubject) {
|
||||
lines.push(`[faces:index] sujeito #${link.subjectId}: ${link.photos} foto(s)`)
|
||||
}
|
||||
}
|
||||
|
||||
for (const failure of report.summary?.failures ?? []) {
|
||||
lines.push(`[faces:index] ✗ foto ${failure.photoId} [${failure.stage}]: ${failure.error}`)
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
|
@ -76,6 +76,8 @@ export const SCRIPTS_SPEC_PINNED = [
|
|||
'scripts/lib/dossieResearch.mjs',
|
||||
'scripts/lib/dossieUnit.mjs',
|
||||
'scripts/lib/e2e-affected-manifest.mjs',
|
||||
'scripts/lib/faceEnrollPlan.mjs',
|
||||
'scripts/lib/faceIndexPlan.mjs',
|
||||
'scripts/lib/flickrApi.mjs',
|
||||
'scripts/lib/flickrPlan.mjs',
|
||||
'scripts/lib/github-api.mjs',
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
|
|||
import { getCardModel } from '@/lib/cardModels'
|
||||
import { contentEventRequestSchema } from '@/lib/schemas/contentEvent'
|
||||
import { getStateDeputyCard } from '@/lib/stateDeputyCatalog'
|
||||
import { readBoundedRequestBody } from '@/utilities/boundedRequestBody'
|
||||
import {
|
||||
checkContentEventRateLimit,
|
||||
contentEventClientKey,
|
||||
|
|
@ -37,43 +38,10 @@ const MAX_BODY_BYTES = 4 * 1024
|
|||
const silentResponse = (status: number): NextResponse =>
|
||||
new NextResponse(null, { status, headers: NO_STORE })
|
||||
|
||||
/**
|
||||
* Reads the body with a hard byte ceiling, streaming and aborting as soon as
|
||||
* the ceiling is crossed — a chunked body without `content-length` cannot make
|
||||
* the route buffer unbounded input. `null` means "refused".
|
||||
*/
|
||||
const readBoundedBody = async (request: Request): Promise<string | null> => {
|
||||
const body = request.body
|
||||
if (!body) return ''
|
||||
|
||||
const reader = body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let text = ''
|
||||
let bytes = 0
|
||||
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
bytes += value.byteLength
|
||||
if (bytes > MAX_BODY_BYTES) {
|
||||
await reader.cancel().catch(() => undefined)
|
||||
return null
|
||||
}
|
||||
text += decoder.decode(value, { stream: true })
|
||||
}
|
||||
return text + decoder.decode()
|
||||
} catch {
|
||||
await reader.cancel().catch(() => undefined)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const POST = async (request: Request): Promise<NextResponse> => {
|
||||
if (!isSameOriginRequest(request)) return silentResponse(403)
|
||||
|
||||
const rawBody = await readBoundedBody(request)
|
||||
const rawBody = await readBoundedRequestBody(request, MAX_BODY_BYTES)
|
||||
if (rawBody === null) return silentResponse(400)
|
||||
|
||||
let body: unknown
|
||||
|
|
|
|||
130
src/app/(frontend)/api/fotos/selfie/route.ts
Normal file
130
src/app/(frontend)/api/fotos/selfie/route.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import config from '@payload-config'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import { FACE_INDEX_CONSENT_KEY, FACE_SEARCH_CONSENT_KEY } from '@/lib/campaignConsentKeys'
|
||||
import {
|
||||
FACE_SEARCH_MODEL,
|
||||
FACE_SEARCH_RESULT_LIMIT,
|
||||
findFaceMatch,
|
||||
toFaceSearchPhotoView,
|
||||
} from '@/lib/faceSearch'
|
||||
import { faceSearchRequestSchema } from '@/lib/schemas/faceSearch'
|
||||
import { getApprovedArchivePhotoItems } from '@/utilities/archivePhotos/archivePhotoReads'
|
||||
import { readBoundedRequestBody } from '@/utilities/boundedRequestBody'
|
||||
import { getConsentByKey } from '@/utilities/campaignConsent'
|
||||
import {
|
||||
checkContentEventRateLimit,
|
||||
contentEventClientKey,
|
||||
} from '@/utilities/content/contentEventRateLimit'
|
||||
import { removeFaceSubjectFromIndex } from '@/utilities/faceSubjects/faceSubjectEnrollment'
|
||||
import {
|
||||
getFaceSubjectMatchedPhotoIds,
|
||||
listSearchableFaceSubjects,
|
||||
} from '@/utilities/faceSubjects/faceSubjectReads'
|
||||
import { isSameOriginRequest } from '@/utilities/sameOriginRequest'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/**
|
||||
* C234 — the anonymous endpoint of the selfie search. The browser computes the
|
||||
* face descriptor ON DEVICE (the selfie image never reaches this server) and
|
||||
* sends only the 128-float vector plus the intent; the answer is a list of
|
||||
* approved photos, never a score and never a third-party name.
|
||||
*
|
||||
* Fail-closed in layers: same-origin, bounded body, strict schema, a budget
|
||||
* much stricter than the beacon's (the endpoint answers a membership oracle),
|
||||
* the album's `selfieSearchEnabled` kill switch AND the query Consent resolved
|
||||
* server-side — the page closing is convenience, this is the gate. Scope A/C
|
||||
* of the gate (PR #1370): only subjects enrolled with consent answer; the
|
||||
* anonymous archive index (B) does not exist. `leave-index` is authorized by
|
||||
* the matched descriptor itself.
|
||||
*/
|
||||
|
||||
const NO_STORE = { 'Cache-Control': 'no-store' } as const
|
||||
|
||||
/** The descriptor JSON is ~2 KB; anything past 4 KB is not ours. */
|
||||
const MAX_BODY_BYTES = 4 * 1024
|
||||
|
||||
/** Requests per 10-minute window per hashed IP — strict on purpose. */
|
||||
const FACE_SEARCH_REQUESTS_PER_WINDOW = 20
|
||||
|
||||
const jsonResponse = (body: unknown, status = 200): NextResponse =>
|
||||
NextResponse.json(body, { status, headers: NO_STORE })
|
||||
|
||||
export const POST = async (request: Request): Promise<NextResponse> => {
|
||||
if (!isSameOriginRequest(request)) return jsonResponse({ ok: false, error: 'origin' }, 403)
|
||||
|
||||
const rawBody = await readBoundedRequestBody(request, MAX_BODY_BYTES)
|
||||
if (rawBody === null) return jsonResponse({ ok: false, error: 'body' }, 400)
|
||||
|
||||
let body: unknown
|
||||
try {
|
||||
body = JSON.parse(rawBody)
|
||||
} catch {
|
||||
return jsonResponse({ ok: false, error: 'body' }, 400)
|
||||
}
|
||||
|
||||
const parsed = faceSearchRequestSchema.safeParse(body)
|
||||
if (!parsed.success) return jsonResponse({ ok: false, error: 'body' }, 400)
|
||||
|
||||
// The registry/hash owner is shared with the beacon, but the budget window is
|
||||
// NOT: the caller namespaces its key so beacon events and selfie searches
|
||||
// never consume each other's counters.
|
||||
const clientKey = contentEventClientKey(request.headers)
|
||||
if (
|
||||
!checkContentEventRateLimit(clientKey && `selfie:${clientKey}`, FACE_SEARCH_REQUESTS_PER_WINDOW)
|
||||
) {
|
||||
return jsonResponse({ ok: false, error: 'rate-limited' }, 429)
|
||||
}
|
||||
|
||||
const payload = await getPayload({ config })
|
||||
// Direct read (no cache): the kill switch and the consent must be the state
|
||||
// of this moment — the endpoint is the gate, the page is convenience. The
|
||||
// album global's read access is public by contract, so no bypass is needed.
|
||||
const [album, searchConsent] = await Promise.all([
|
||||
payload.findGlobal({ slug: 'photoAlbum', depth: 0 }).catch(() => null),
|
||||
getConsentByKey(payload, FACE_SEARCH_CONSENT_KEY),
|
||||
])
|
||||
|
||||
if (album?.published === false || album?.selfieSearchEnabled !== true) {
|
||||
return jsonResponse({ ok: false, error: 'closed' }, 404)
|
||||
}
|
||||
if (!searchConsent) return jsonResponse({ ok: false, error: 'consent' }, 503)
|
||||
|
||||
const [subjects, indexConsent] = await Promise.all([
|
||||
listSearchableFaceSubjects(payload),
|
||||
getConsentByKey(payload, FACE_INDEX_CONSENT_KEY),
|
||||
])
|
||||
|
||||
const match = indexConsent
|
||||
? findFaceMatch({
|
||||
vector: parsed.data.vector,
|
||||
subjects,
|
||||
model: FACE_SEARCH_MODEL,
|
||||
consentHash: indexConsent.contentHash,
|
||||
})
|
||||
: null
|
||||
|
||||
if (parsed.data.intent === 'leave-index') {
|
||||
if (!match) return jsonResponse({ ok: true, removed: false })
|
||||
await removeFaceSubjectFromIndex({ payload, id: match.id })
|
||||
return jsonResponse({ ok: true, removed: true })
|
||||
}
|
||||
|
||||
if (!match) return jsonResponse({ ok: true, found: false })
|
||||
|
||||
const [matchedIds, approved] = await Promise.all([
|
||||
getFaceSubjectMatchedPhotoIds(payload, match.id),
|
||||
getApprovedArchivePhotoItems(),
|
||||
])
|
||||
const linked = new Set(matchedIds)
|
||||
const photos = approved
|
||||
.filter((item) => linked.has(item.id))
|
||||
.slice(0, FACE_SEARCH_RESULT_LIMIT)
|
||||
.map(toFaceSearchPhotoView)
|
||||
|
||||
if (photos.length === 0) return jsonResponse({ ok: true, found: false })
|
||||
|
||||
return jsonResponse({ ok: true, found: true, photos })
|
||||
}
|
||||
114
src/app/(frontend)/fotos/encontre/page.tsx
Normal file
114
src/app/(frontend)/fotos/encontre/page.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import config from '@payload-config'
|
||||
import type { Metadata } from 'next'
|
||||
import Link from 'next/link'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import { ConsentText } from '@/components/campaign/shared/ConsentText'
|
||||
import { ARCHIVE_PHOTO_FOCUS } from '@/components/fotos/archivePhotoClasses'
|
||||
import { ArchivePhotoPageHeader } from '@/components/fotos/ArchivePhotoPageHeader'
|
||||
import { SelfieSearchClosed } from '@/components/fotos/SelfieSearchClosed'
|
||||
import { SelfieSearchFlow } from '@/components/fotos/SelfieSearchFlow'
|
||||
import { isArchivePhotoRemovalChannelUrl } from '@/lib/archivePhotoCatalog'
|
||||
import {
|
||||
ARCHIVE_PHOTO_ALBUM_ENTRY_PATH,
|
||||
ARCHIVE_PHOTO_ALBUM_PATH,
|
||||
} from '@/lib/archivePhotoPublicCatalog'
|
||||
import { FACE_SEARCH_CONSENT_KEY } from '@/lib/campaignConsentKeys'
|
||||
import { getConsentByKey } from '@/utilities/campaignConsent'
|
||||
import { hasPublishedContentPieces } from '@/utilities/content/contentPieceReads'
|
||||
import { getCachedGlobal } from '@/utilities/globalReads'
|
||||
import { absoluteSitePath, resolveSiteMetadata } from '@/utilities/seo'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const ENTRY_TITLE = 'Encontre você nas fotos — Jorge Solla 1313'
|
||||
const ENTRY_DESCRIPTION =
|
||||
'Busque por uma selfie sua as fotos públicas aprovadas em que você aparece. A imagem não sai do seu dispositivo.'
|
||||
|
||||
const getCachedPhotoAlbum = getCachedGlobal('photoAlbum')
|
||||
|
||||
/**
|
||||
* C234 — the selfie search page (`/fotos/encontre`), a surface of the album:
|
||||
* the same album kill switch (`photoAlbum.published`) and the feature flag
|
||||
* (`selfieSearchEnabled`, default off) gate the route with the album's 404; the
|
||||
* query Consent resolved by stable key is the legal gate — without it the page
|
||||
* still exists but renders only the fail-closed state, and the API refuses
|
||||
* independently. Noindex on purpose: the canonical surface is the album.
|
||||
*/
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const [album, globalMetadata] = await Promise.all([
|
||||
getCachedPhotoAlbum(),
|
||||
getCachedGlobal('metadata')(),
|
||||
])
|
||||
if (album.published === false || album.selfieSearchEnabled !== true) notFound()
|
||||
|
||||
const { siteUrl, siteName, twitterCreator } = resolveSiteMetadata(globalMetadata)
|
||||
const canonicalUrl = absoluteSitePath(siteUrl, ARCHIVE_PHOTO_ALBUM_ENTRY_PATH)
|
||||
const title = `${ENTRY_TITLE} | ${siteName}`
|
||||
|
||||
return {
|
||||
title,
|
||||
description: ENTRY_DESCRIPTION,
|
||||
robots: { index: false, follow: false },
|
||||
...(canonicalUrl ? { alternates: { canonical: canonicalUrl } } : {}),
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
locale: 'pt-BR',
|
||||
...(canonicalUrl ? { url: canonicalUrl } : {}),
|
||||
siteName,
|
||||
title,
|
||||
description: ENTRY_DESCRIPTION,
|
||||
images: [],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary',
|
||||
title,
|
||||
description: ENTRY_DESCRIPTION,
|
||||
creator: twitterCreator,
|
||||
images: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default async function FotosEncontrePage() {
|
||||
const [album, showConteudos] = await Promise.all([
|
||||
getCachedPhotoAlbum(),
|
||||
hasPublishedContentPieces(),
|
||||
])
|
||||
if (album.published === false || album.selfieSearchEnabled !== true) notFound()
|
||||
|
||||
const payload = await getPayload({ config })
|
||||
const consent = await getConsentByKey(payload, FACE_SEARCH_CONSENT_KEY)
|
||||
const removalChannelUrl = isArchivePhotoRemovalChannelUrl(album.removalChannelUrl)
|
||||
? album.removalChannelUrl.trim()
|
||||
: null
|
||||
|
||||
return (
|
||||
<>
|
||||
<ArchivePhotoPageHeader showConteudos={showConteudos} />
|
||||
<main className="w-full">
|
||||
<div className="mx-auto w-full max-w-6xl px-5 pt-5 sm:px-8 sm:pt-7">
|
||||
<nav className="text-sm text-(--campaign-muted)">
|
||||
<Link
|
||||
href={ARCHIVE_PHOTO_ALBUM_PATH}
|
||||
className={`inline-flex min-h-11 items-center font-bold text-[#184e92] underline underline-offset-4 hover:text-[#143c70] ${ARCHIVE_PHOTO_FOCUS}`}
|
||||
>
|
||||
← Voltar ao álbum
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
{consent ? (
|
||||
<SelfieSearchFlow
|
||||
consentText={<ConsentText data={consent.text} />}
|
||||
removalChannelUrl={removalChannelUrl}
|
||||
/>
|
||||
) : (
|
||||
<div className="px-5 py-8 sm:px-8 sm:py-10">
|
||||
<SelfieSearchClosed />
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import {
|
|||
ArchivePhotoEmptyState,
|
||||
ArchivePhotoNoResults,
|
||||
} from '@/components/fotos/ArchivePhotoStates'
|
||||
import { SelfieSearchEntry } from '@/components/fotos/SelfieSearchEntry'
|
||||
import { isArchivePhotoRemovalChannelUrl } from '@/lib/archivePhotoCatalog'
|
||||
import {
|
||||
ARCHIVE_PHOTO_ALBUM_PATH,
|
||||
|
|
@ -126,7 +127,17 @@ export default async function FotosPage({
|
|||
<ArchivePhotoEmptyState />
|
||||
) : (
|
||||
<>
|
||||
<ArchivePhotoFilters params={params} facets={facets} activeFilters={activeFilters} />
|
||||
{album.selfieSearchEnabled === true ? (
|
||||
<div className="mb-8">
|
||||
<SelfieSearchEntry />
|
||||
</div>
|
||||
) : null}
|
||||
<ArchivePhotoFilters
|
||||
params={params}
|
||||
facets={facets}
|
||||
activeFilters={activeFilters}
|
||||
searchSecondary={album.selfieSearchEnabled === true}
|
||||
/>
|
||||
|
||||
<div className="mt-10 flex items-end justify-between gap-4 border-b border-(--campaign-line) pb-4">
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -165,6 +165,12 @@ const deriveArchivePhotoCatalogIndex: CollectionBeforeValidateHook = async ({
|
|||
}) => {
|
||||
if (!data) return data
|
||||
|
||||
// C234 — the face-index batch only ever writes the hidden `faces` group
|
||||
// (the marker), so it skips the search-text/município derivation entirely;
|
||||
// the curation marking below is already gated on `req.user` and never runs
|
||||
// for a session-less CLI.
|
||||
if (req.context?.faceIndex) return data
|
||||
|
||||
if (req.user && !req.context?.archivePhotoCatalog) {
|
||||
const changed = changedArchivePhotoCuratedFields({ data, originalDoc })
|
||||
if (changed.length > 0) {
|
||||
|
|
@ -276,6 +282,41 @@ const revalidateArchivePhotosListingAfterDelete: CollectionAfterDeleteHook = ({
|
|||
return doc
|
||||
}
|
||||
|
||||
/**
|
||||
* C234 — the batch state of the selfie-search index (`pnpm faces:index`). The
|
||||
* `checkedKey` is what lets the batch skip a photo honestly: it summarizes the
|
||||
* model plus every eligible subject, so a new enrollment (or a model change)
|
||||
* changes the key and the photo is reprocessed. Written only by the CLI; no
|
||||
* admin surface (hidden) and never edited by hand.
|
||||
*/
|
||||
const facesGroup: Field = {
|
||||
name: 'faces',
|
||||
type: 'group',
|
||||
label: 'Índice facial',
|
||||
admin: {
|
||||
hidden: true,
|
||||
description: 'Estado do lote da busca por selfie (C234). Nunca editado à mão.',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'checkedAt',
|
||||
type: 'date',
|
||||
label: 'Verificada em',
|
||||
admin: {
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'checkedKey',
|
||||
type: 'text',
|
||||
label: 'Chave do índice',
|
||||
admin: {
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export const ArchivePhoto: CollectionConfig = {
|
||||
slug: ARCHIVE_PHOTO_SLUG,
|
||||
labels: {
|
||||
|
|
@ -502,6 +543,7 @@ export const ArchivePhoto: CollectionConfig = {
|
|||
'Tudo o que a busca da lista encontra, normalizado (sem acentos, minúsculas): legenda, texto visível, temas, pessoas, município, álbuns e tags.',
|
||||
},
|
||||
},
|
||||
facesGroup,
|
||||
],
|
||||
upload: true,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ const blockConsentDeletionWithReferences: CollectionBeforeDeleteHook = async ({
|
|||
leaderships,
|
||||
supporters,
|
||||
voteSupporters,
|
||||
faceSubjects,
|
||||
] = await Promise.all([
|
||||
req.payload.count({
|
||||
collection: 'subscription',
|
||||
|
|
@ -65,6 +66,14 @@ const blockConsentDeletionWithReferences: CollectionBeforeDeleteHook = async ({
|
|||
overrideAccess: true,
|
||||
req,
|
||||
}),
|
||||
// C234 — face-subject references: intentional access bypass like every
|
||||
// count above (the hook must see every reference to decide).
|
||||
req.payload.count({
|
||||
collection: 'faceSubject',
|
||||
where: { consent: { equals: id } },
|
||||
overrideAccess: true,
|
||||
req,
|
||||
}),
|
||||
])
|
||||
|
||||
const references: Array<[number, string]> = [
|
||||
|
|
@ -75,6 +84,7 @@ const blockConsentDeletionWithReferences: CollectionBeforeDeleteHook = async ({
|
|||
[leaderships.totalDocs, 'lideranças'],
|
||||
[supporters.totalDocs, 'apoiadores'],
|
||||
[voteSupporters.totalDocs, 'apoiadores (intenção de voto)'],
|
||||
[faceSubjects.totalDocs, 'pessoas no índice de busca por selfie'],
|
||||
]
|
||||
const used = references.filter(([count]) => count > 0)
|
||||
if (used.length === 0) return
|
||||
|
|
|
|||
135
src/collections/FaceSubject.ts
Normal file
135
src/collections/FaceSubject.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
import { payloadAdminOnly } from '@/utilities/campaignAccess'
|
||||
|
||||
/**
|
||||
* C234 — one person who ADHERED to the selfie search index (A/C scope of the
|
||||
* gate, PR #1370): the enrollment is an ops act of the assessoria with the
|
||||
* person (signed consent, `pnpm faces:enroll`), never self-service and never
|
||||
* inferred from the archive.
|
||||
*
|
||||
* This is not a people registry: it has no link to `Contact`/leadership, no
|
||||
* public read and no surface of its own — it exists to (a) hold the consented
|
||||
* descriptor that lets the person find themselves and (b) carry the audit
|
||||
* trail of the adherence (`consent` + `consentHash` + `model` + status). The
|
||||
* descriptor is written only by the CLI and never leaves the server; the
|
||||
* matched photos are a derived link the batch replaces on every run. A
|
||||
* `removed` subject keeps its row (the attendance record) but not the vector.
|
||||
*/
|
||||
export const FaceSubject: CollectionConfig = {
|
||||
slug: 'faceSubject',
|
||||
labels: {
|
||||
singular: 'Pessoa no índice facial',
|
||||
plural: 'Pessoas no índice facial',
|
||||
},
|
||||
admin: {
|
||||
group: 'Comunicação',
|
||||
description:
|
||||
'Pessoas que consentiram entrar no índice da busca por selfie (/fotos/encontre). Operado pela CLI faces:enroll/faces:index; a remoção é registrada no status.',
|
||||
useAsTitle: 'label',
|
||||
defaultColumns: ['label', 'status', 'model', 'removedAt'],
|
||||
},
|
||||
access: {
|
||||
create: payloadAdminOnly,
|
||||
read: payloadAdminOnly,
|
||||
update: payloadAdminOnly,
|
||||
delete: payloadAdminOnly,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'label',
|
||||
type: 'text',
|
||||
label: 'Nome (interno)',
|
||||
required: true,
|
||||
admin: {
|
||||
description: 'Como a assessoria identifica a pessoa; nunca aparece no site.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'consent',
|
||||
type: 'relationship',
|
||||
relationTo: 'consent',
|
||||
label: 'Consentimento (adesão)',
|
||||
required: true,
|
||||
index: true,
|
||||
admin: {
|
||||
description: 'O Consent da adesão ao índice (chave estável busca-selfie-indice).',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'consentHash',
|
||||
type: 'text',
|
||||
label: 'Hash do consentimento',
|
||||
admin: {
|
||||
readOnly: true,
|
||||
description:
|
||||
'Snapshot do texto aceito no enrollment; se o texto mudar, a pessoa fica inelegível até re-consentir.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'model',
|
||||
type: 'text',
|
||||
label: 'Modelo',
|
||||
admin: {
|
||||
readOnly: true,
|
||||
description: 'Modelo do descriptor (troca de modelo invalida o índice: re-enrollment).',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'enrolledAt',
|
||||
type: 'date',
|
||||
label: 'Inscrita em',
|
||||
admin: {
|
||||
readOnly: true,
|
||||
description:
|
||||
'Muda a cada enrollment/re-enrollment; é o que marca o lote como stale (o updated_at muda também quando o lote escreve os vínculos).',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'vector',
|
||||
type: 'json',
|
||||
label: 'Descriptor',
|
||||
admin: {
|
||||
hidden: true,
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
type: 'select',
|
||||
label: 'Status',
|
||||
required: true,
|
||||
index: true,
|
||||
defaultValue: 'active',
|
||||
options: [
|
||||
{ label: 'Ativa', value: 'active' },
|
||||
{ label: 'Removida do índice', value: 'removed' },
|
||||
],
|
||||
admin: {
|
||||
description:
|
||||
'Removida: pedido de saída atendido — o vetor é apagado e a pessoa deixa de ser encontrada.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'removedAt',
|
||||
type: 'date',
|
||||
label: 'Removida em',
|
||||
admin: {
|
||||
readOnly: true,
|
||||
description: 'Data do atendimento da saída do índice.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'matchedPhotos',
|
||||
type: 'relationship',
|
||||
relationTo: 'archivePhoto',
|
||||
hasMany: true,
|
||||
label: 'Fotos vinculadas',
|
||||
admin: {
|
||||
readOnly: true,
|
||||
description:
|
||||
'Derivado do lote (pnpm faces:index): fotos aprovadas em que o descriptor foi encontrado.',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
@ -9,6 +9,49 @@ import {
|
|||
|
||||
import { ARCHIVE_PHOTO_CARD } from './archivePhotoClasses'
|
||||
|
||||
/**
|
||||
* C233 — the card body every photo grid shares (album and, without the
|
||||
* third-party line, the C234 selfie-search result): image through the
|
||||
* same-origin private proxy, curated title, meta line and the optional
|
||||
* "Quem aparece". The caller owns the link/href contract.
|
||||
*/
|
||||
export const ArchivePhotoCardBody = ({
|
||||
item,
|
||||
}: {
|
||||
item: Pick<
|
||||
ArchivePhotoPublicItem,
|
||||
'alt' | 'title' | 'metaLabel' | 'peopleLabel' | 'thumbnailPath'
|
||||
>
|
||||
}) => (
|
||||
<>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- private proxy path with on-demand headers */}
|
||||
<img
|
||||
src={item.thumbnailPath}
|
||||
alt={item.alt}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
// The CSS box owns the ratio (square on mobile, 4:3 from `sm`); the
|
||||
// attributes are the intrinsic hint for the pre-JS layout.
|
||||
width={ARCHIVE_PHOTO_THUMB_WIDTH}
|
||||
height={Math.round(ARCHIVE_PHOTO_THUMB_WIDTH * 0.75)}
|
||||
className="aspect-square w-full rounded-lg object-cover transition-[transform,box-shadow] duration-150 ease-out group-hover:-translate-y-0.5 group-hover:shadow-[0_12px_28px_rgb(32_27_26/16%)] motion-reduce:transition-none sm:aspect-[4/3] sm:rounded-xl"
|
||||
/>
|
||||
<div className="pt-3">
|
||||
<p className="font-[family-name:var(--font-exo2)] text-sm font-black text-balance sm:text-base">
|
||||
{item.title}
|
||||
</p>
|
||||
{item.metaLabel ? (
|
||||
<p className="mt-1 text-xs leading-4 text-(--campaign-muted) sm:text-sm sm:leading-5">
|
||||
{item.metaLabel}
|
||||
</p>
|
||||
) : null}
|
||||
{item.peopleLabel ? (
|
||||
<p className="mt-1 text-xs leading-4 sm:text-sm sm:leading-5">{item.peopleLabel}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
/**
|
||||
* C233 — one photo of the grid (artefato: cenas 01/02/07): the whole card is a
|
||||
* single link to the same filtered URL plus `?foto=<id>`, one tab stop, yellow
|
||||
|
|
@ -29,31 +72,7 @@ export const ArchivePhotoCard = ({
|
|||
className={`${ARCHIVE_PHOTO_CARD} group`}
|
||||
data-photo-id={item.id}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- private proxy path with on-demand headers */}
|
||||
<img
|
||||
src={item.thumbnailPath}
|
||||
alt={item.alt}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
// The CSS box owns the ratio (square on mobile, 4:3 from `sm`); the
|
||||
// attributes are the intrinsic hint for the pre-JS layout.
|
||||
width={ARCHIVE_PHOTO_THUMB_WIDTH}
|
||||
height={Math.round(ARCHIVE_PHOTO_THUMB_WIDTH * 0.75)}
|
||||
className="aspect-square w-full rounded-lg object-cover transition-[transform,box-shadow] duration-150 ease-out group-hover:-translate-y-0.5 group-hover:shadow-[0_12px_28px_rgb(32_27_26/16%)] motion-reduce:transition-none sm:aspect-[4/3] sm:rounded-xl"
|
||||
/>
|
||||
<div className="pt-3">
|
||||
<p className="font-[family-name:var(--font-exo2)] text-sm font-black text-balance sm:text-base">
|
||||
{item.title}
|
||||
</p>
|
||||
{item.metaLabel ? (
|
||||
<p className="mt-1 text-xs leading-4 text-(--campaign-muted) sm:text-sm sm:leading-5">
|
||||
{item.metaLabel}
|
||||
</p>
|
||||
) : null}
|
||||
{item.peopleLabel ? (
|
||||
<p className="mt-1 text-xs leading-4 sm:text-sm sm:leading-5">{item.peopleLabel}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<ArchivePhotoCardBody item={item} />
|
||||
</Link>
|
||||
</article>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
ARCHIVE_PHOTO_CHIP,
|
||||
ARCHIVE_PHOTO_FOCUS,
|
||||
ARCHIVE_PHOTO_PRIMARY_BUTTON,
|
||||
ARCHIVE_PHOTO_SECONDARY_BUTTON,
|
||||
} from './archivePhotoClasses'
|
||||
|
||||
const FACET_MENU_HEADINGS: Record<ArchivePhotoAlbumFacet, string> = {
|
||||
|
|
@ -75,10 +76,13 @@ export const ArchivePhotoFilters = ({
|
|||
params,
|
||||
facets,
|
||||
activeFilters,
|
||||
searchSecondary = false,
|
||||
}: {
|
||||
params: ArchivePhotoAlbumParams
|
||||
facets: ArchivePhotoAlbumFacets
|
||||
activeFilters: readonly ArchivePhotoAlbumActiveFilter[]
|
||||
/** C234 — with the selfie entry band above, the album search is the secondary action. */
|
||||
searchSecondary?: boolean
|
||||
}) => {
|
||||
const facetFilters = activeFilters.filter((filter): filter is FacetFilter => filter.facet !== 'q')
|
||||
const termFilter = activeFilters.find((filter) => filter.facet === 'q')
|
||||
|
|
@ -123,7 +127,10 @@ export const ArchivePhotoFilters = ({
|
|||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
<button type="submit" className={`${ARCHIVE_PHOTO_PRIMARY_BUTTON} min-w-28`}>
|
||||
<button
|
||||
type="submit"
|
||||
className={`${searchSecondary ? ARCHIVE_PHOTO_SECONDARY_BUTTON : ARCHIVE_PHOTO_PRIMARY_BUTTON} min-w-28`}
|
||||
>
|
||||
Buscar fotos
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
36
src/components/fotos/SelfieSearchClosed.tsx
Normal file
36
src/components/fotos/SelfieSearchClosed.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { TriangleAlert } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
import { ARCHIVE_PHOTO_ALBUM_PATH } from '@/lib/archivePhotoPublicCatalog'
|
||||
|
||||
import { ARCHIVE_PHOTO_SECONDARY_BUTTON } from './archivePhotoClasses'
|
||||
|
||||
/**
|
||||
* C234 — the fail-closed state of the selfie search (artefato: cena 07): the
|
||||
* query Consent is not configured, so the flow never opens and no selfie can be
|
||||
* captured or processed. Server-rendered on `/fotos/encontre` and reused by the
|
||||
* client flow when the flag/consent flips mid-session.
|
||||
*/
|
||||
export const SelfieSearchClosed = () => (
|
||||
<section className="mx-auto w-full max-w-[920px] rounded-2xl border border-amber-300 bg-amber-50 px-6 py-10 text-center">
|
||||
<div className="mx-auto grid size-12 place-items-center rounded-full border border-amber-300 bg-white text-amber-800">
|
||||
<TriangleAlert aria-hidden="true" className="size-6" strokeWidth={2} />
|
||||
</div>
|
||||
<p className="mt-5 text-xs font-black tracking-[0.1em] text-amber-800 uppercase">
|
||||
Proteção de dados
|
||||
</p>
|
||||
<h2 className="mt-2 border-0 pb-0 font-[family-name:var(--font-exo2)] text-2xl font-black text-amber-950">
|
||||
A busca por selfie está indisponível
|
||||
</h2>
|
||||
<p className="mx-auto mt-3 max-w-lg text-sm leading-6 text-amber-900">
|
||||
O texto de consentimento necessário ainda não está configurado. Por segurança, não podemos
|
||||
receber nem processar sua selfie agora.
|
||||
</p>
|
||||
<p className="mx-auto mt-3 max-w-lg text-sm font-bold text-amber-950">
|
||||
Nenhuma imagem foi capturada ou enviada.
|
||||
</p>
|
||||
<Link href={ARCHIVE_PHOTO_ALBUM_PATH} className={`${ARCHIVE_PHOTO_SECONDARY_BUTTON} mt-6`}>
|
||||
Voltar ao álbum de fotos
|
||||
</Link>
|
||||
</section>
|
||||
)
|
||||
44
src/components/fotos/SelfieSearchEntry.tsx
Normal file
44
src/components/fotos/SelfieSearchEntry.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { ChevronRight, Sparkles } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
import { ARCHIVE_PHOTO_ALBUM_ENTRY_PATH } from '@/lib/archivePhotoPublicCatalog'
|
||||
|
||||
import { ARCHIVE_PHOTO_PRIMARY_BUTTON } from './archivePhotoClasses'
|
||||
|
||||
/**
|
||||
* C234 — the entry band of the selfie search on `/fotos` (artefato: cena 01,
|
||||
* revisada): a full-width band between the hero and the facets, never a second
|
||||
* column in the hero. Rendered only while the feature flag is on and the album
|
||||
* has approved photos to find; the visual contract is the artifact's
|
||||
* `rounded-2xl border border-[#184e92]/15 bg-[#f9faff]` band.
|
||||
*/
|
||||
export const SelfieSearchEntry = () => (
|
||||
<aside className="grid grid-cols-1 items-center gap-4 rounded-2xl border border-[#184e92]/15 bg-[#f9faff] px-5 py-5 sm:grid-cols-[1fr_auto] sm:gap-6 sm:px-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="grid size-11 shrink-0 place-items-center rounded-xl bg-[#184e92] text-white">
|
||||
<Sparkles aria-hidden="true" className="size-5" strokeWidth={2} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="border-0 pb-0 font-[family-name:var(--font-exo2)] text-lg font-black sm:text-xl">
|
||||
Encontre você nas fotos
|
||||
</h2>
|
||||
<p className="mt-1 text-sm leading-6 text-(--campaign-muted)">
|
||||
<span className="sm:hidden">
|
||||
Sua selfie fica neste aparelho. A busca é para quem aderiu ao índice.
|
||||
</span>
|
||||
<span className="hidden sm:inline">
|
||||
Use uma selfie sua. A imagem não sai deste dispositivo e a busca cobre quem autorizou
|
||||
participar do índice.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href={ARCHIVE_PHOTO_ALBUM_ENTRY_PATH}
|
||||
className={`${ARCHIVE_PHOTO_PRIMARY_BUTTON} w-full shrink-0 sm:w-auto`}
|
||||
>
|
||||
Começar busca por selfie
|
||||
<ChevronRight aria-hidden="true" className="size-4" />
|
||||
</Link>
|
||||
</aside>
|
||||
)
|
||||
886
src/components/fotos/SelfieSearchFlow.tsx
Normal file
886
src/components/fotos/SelfieSearchFlow.tsx
Normal file
|
|
@ -0,0 +1,886 @@
|
|||
'use client'
|
||||
|
||||
import {
|
||||
Camera,
|
||||
ChevronLeft,
|
||||
Clock,
|
||||
ImageIcon,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
Smartphone,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
TriangleAlert,
|
||||
Upload,
|
||||
WifiOff,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
|
||||
import { ARCHIVE_PHOTO_ALBUM_PATH } from '@/lib/archivePhotoPublicCatalog'
|
||||
import type { FaceSearchPhotoView } from '@/lib/faceSearch'
|
||||
|
||||
import {
|
||||
ARCHIVE_PHOTO_FOCUS,
|
||||
ARCHIVE_PHOTO_PRIMARY_BUTTON,
|
||||
ARCHIVE_PHOTO_SECONDARY_BUTTON,
|
||||
} from './archivePhotoClasses'
|
||||
import { computeSelfieDescriptor, supportsFaceSearchEngine } from './faceSearchEngine'
|
||||
import { SelfieSearchClosed } from './SelfieSearchClosed'
|
||||
import { SelfieSearchResultCard } from './SelfieSearchResultCard'
|
||||
|
||||
type SelfieSearchIntent = 'search' | 'leave-index'
|
||||
|
||||
type FlowStep =
|
||||
| { kind: 'consent' }
|
||||
| { kind: 'pick'; intent: SelfieSearchIntent }
|
||||
| { kind: 'confirm-leave' }
|
||||
| { kind: 'processing'; intent: SelfieSearchIntent }
|
||||
| { kind: 'results'; photos: FaceSearchPhotoView[] }
|
||||
| { kind: 'empty' }
|
||||
| { kind: 'no-face' }
|
||||
| { kind: 'presence' }
|
||||
| { kind: 'removed' }
|
||||
| { kind: 'presence-missing' }
|
||||
| { kind: 'unsupported' }
|
||||
| { kind: 'engine-error' }
|
||||
| { kind: 'rate-limited' }
|
||||
| { kind: 'network' }
|
||||
| { kind: 'closed' }
|
||||
|
||||
type SelfieSearchResponse =
|
||||
| { ok: true; found: true; photos: FaceSearchPhotoView[] }
|
||||
| { ok: true; found: false }
|
||||
| { ok: true; removed: boolean }
|
||||
| { ok: false; error: string }
|
||||
|
||||
const ACCEPTED_IMAGE = 'image/*'
|
||||
const FOCUSABLE = 'a[href]:not([tabindex="-1"]), button:not([disabled]), input:not([disabled])'
|
||||
|
||||
/** The wizard steps center on a readable column; the result breaks out to the page grid. */
|
||||
const STEP_SHELL = 'mx-auto w-full max-w-[650px]'
|
||||
|
||||
/**
|
||||
* C234 — the selfie search flow (artefato: cenas 02–13). The browser computes
|
||||
* the descriptor on device and posts only the 128-float vector; the component
|
||||
* never puts the image in a request. Every terminal state of the artifact has
|
||||
* an exact counterpart here (empty, no-face, engine/unsupported, rate limit,
|
||||
* network, closed) and none of them shows a score, a percentage or a
|
||||
* third-party name. The consent text arrives rendered from the server (the
|
||||
* approved Consent reachText); the opt-out is authorized by the same face
|
||||
* match, and its no-match case points to the album's configured channel.
|
||||
*/
|
||||
export const SelfieSearchFlow = ({
|
||||
consentText,
|
||||
removalChannelUrl,
|
||||
}: {
|
||||
consentText: ReactNode
|
||||
removalChannelUrl: string | null
|
||||
}) => {
|
||||
const [step, setStep] = useState<FlowStep>({ kind: 'consent' })
|
||||
const [consented, setConsented] = useState(false)
|
||||
const [activeIntent, setActiveIntent] = useState<SelfieSearchIntent>('search')
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
|
||||
const previewRef = useRef<string | null>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const resultsRef = useRef<HTMLElement>(null)
|
||||
const presenceRef = useRef<HTMLElement>(null)
|
||||
const pickRef = useRef<HTMLElement>(null)
|
||||
const processingRef = useRef<HTMLElement>(null)
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (previewRef.current) URL.revokeObjectURL(previewRef.current)
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
// The async transitions unmount the control that had focus; every new step
|
||||
// starts at its own top (the album scrolls inside its segment container, so
|
||||
// `scrollIntoView` is what reaches it) with focus on the heading region —
|
||||
// keyboard/screen-reader users never land mid-step (the terminal cards focus
|
||||
// and scroll themselves).
|
||||
useEffect(() => {
|
||||
const target =
|
||||
step.kind === 'pick'
|
||||
? pickRef.current
|
||||
: step.kind === 'processing'
|
||||
? processingRef.current
|
||||
: step.kind === 'results'
|
||||
? resultsRef.current
|
||||
: step.kind === 'presence'
|
||||
? presenceRef.current
|
||||
: null
|
||||
target?.scrollIntoView({ block: 'start' })
|
||||
target?.focus()
|
||||
}, [step.kind])
|
||||
|
||||
const releasePreview = () => {
|
||||
if (previewRef.current) URL.revokeObjectURL(previewRef.current)
|
||||
previewRef.current = null
|
||||
setPreviewUrl(null)
|
||||
}
|
||||
|
||||
const selectFile = (next: File | null) => {
|
||||
releasePreview()
|
||||
setFile(next)
|
||||
if (next) {
|
||||
previewRef.current = URL.createObjectURL(next)
|
||||
setPreviewUrl(previewRef.current)
|
||||
}
|
||||
}
|
||||
|
||||
const runSearch = async (intent: SelfieSearchIntent) => {
|
||||
if (!file) return
|
||||
|
||||
setStep({ kind: 'processing', intent })
|
||||
const computed = await computeSelfieDescriptor(file)
|
||||
if (!computed.ok) {
|
||||
setStep(
|
||||
computed.reason === 'unsupported'
|
||||
? { kind: 'unsupported' }
|
||||
: computed.reason === 'empty'
|
||||
? { kind: 'no-face' }
|
||||
: { kind: 'engine-error' },
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/fotos/selfie', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ vector: computed.descriptor, intent }),
|
||||
})
|
||||
const body = (await response.json().catch(() => null)) as SelfieSearchResponse | null
|
||||
|
||||
if (response.status === 429) return setStep({ kind: 'rate-limited' })
|
||||
if (response.status === 404 || response.status === 503) return setStep({ kind: 'closed' })
|
||||
if (!response.ok || body === null || !body.ok) return setStep({ kind: 'engine-error' })
|
||||
|
||||
if (intent === 'leave-index') {
|
||||
if (!('removed' in body)) return setStep({ kind: 'engine-error' })
|
||||
return setStep(body.removed ? { kind: 'removed' } : { kind: 'presence-missing' })
|
||||
}
|
||||
|
||||
if ('found' in body && body.found) return setStep({ kind: 'results', photos: body.photos })
|
||||
return setStep({ kind: 'empty' })
|
||||
} catch {
|
||||
setStep({ kind: 'network' })
|
||||
}
|
||||
}
|
||||
|
||||
const startSelfieStep = (intent: SelfieSearchIntent) => {
|
||||
if (!supportsFaceSearchEngine()) {
|
||||
setStep({ kind: 'unsupported' })
|
||||
return
|
||||
}
|
||||
setActiveIntent(intent)
|
||||
selectFile(null)
|
||||
setStep({ kind: 'pick', intent })
|
||||
}
|
||||
|
||||
const openPicker = () => inputRef.current?.click()
|
||||
|
||||
const onFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const next = event.target.files?.[0] ?? null
|
||||
event.target.value = ''
|
||||
selectFile(next)
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
selectFile(null)
|
||||
setConsented(false)
|
||||
setStep({ kind: 'consent' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full px-5 py-8 sm:px-8 sm:py-10">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={ACCEPTED_IMAGE}
|
||||
className="sr-only"
|
||||
aria-label="Escolher uma selfie"
|
||||
onChange={onFileChange}
|
||||
/>
|
||||
|
||||
{step.kind === 'consent' ? (
|
||||
<p className="mb-4 text-center text-sm font-bold text-(--campaign-muted)">
|
||||
Encontre você nas fotos
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{step.kind === 'consent' ? (
|
||||
<section
|
||||
aria-labelledby="selfie-consent-title"
|
||||
className={`${STEP_SHELL} rounded-2xl border border-(--campaign-line) bg-white p-6 sm:p-7`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="grid size-12 place-items-center rounded-xl bg-[#eef4fb] text-[#184e92]">
|
||||
<ShieldCheck aria-hidden="true" className="size-6" strokeWidth={2} />
|
||||
</div>
|
||||
<span className="rounded-full bg-[#eef4fb] px-3 py-1 text-xs font-extrabold text-[#184e92]">
|
||||
Passo 1 de 3
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-5 text-xs font-black tracking-[0.1em] text-[#184e92] uppercase">
|
||||
Antes da sua selfie
|
||||
</p>
|
||||
<h2
|
||||
id="selfie-consent-title"
|
||||
className="mt-2 border-0 pb-0 font-[family-name:var(--font-exo2)] text-2xl font-black"
|
||||
>
|
||||
Sua face é um dado biométrico sensível
|
||||
</h2>
|
||||
<div className="mt-3 max-h-72 overflow-y-auto rounded-xl border border-(--campaign-line) bg-(--campaign-cream) p-4 text-sm leading-6 text-(--campaign-muted)">
|
||||
{consentText}
|
||||
</div>
|
||||
<div className="mt-5 grid gap-3 rounded-xl bg-[#f9faff] p-4 text-sm">
|
||||
<div className="flex gap-3">
|
||||
<Sparkles aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-[#184e92]" />
|
||||
<p>
|
||||
<b>A selfie não sai do dispositivo.</b>
|
||||
<br />
|
||||
<span className="text-(--campaign-muted)">
|
||||
A imagem é processada aqui e não é guardada na galeria do site.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<ShieldCheck aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-[#184e92]" />
|
||||
<p>
|
||||
<b>Você pode desistir e pedir remoção.</b>
|
||||
<br />
|
||||
<span className="text-(--campaign-muted)">
|
||||
O resultado não mostra nomes de terceiros nem percentual de semelhança.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="mt-5 flex cursor-pointer items-start gap-3 rounded-xl border border-black/15 p-4 text-sm leading-5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={consented}
|
||||
onChange={(event) => setConsented(event.target.checked)}
|
||||
className="mt-0.5 size-5 shrink-0 accent-[#184e92]"
|
||||
/>
|
||||
<span>
|
||||
Li e autorizo o uso temporário da minha biometria facial para procurar a mim mesmo
|
||||
neste álbum.
|
||||
</span>
|
||||
</label>
|
||||
<p className="mt-3 text-xs leading-5 text-(--campaign-muted)">
|
||||
Saiba como tratamos seus dados na{' '}
|
||||
<Link
|
||||
href="/privacidade"
|
||||
className="font-bold text-[#184e92] underline underline-offset-2"
|
||||
>
|
||||
Política de Privacidade
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!consented}
|
||||
onClick={() => startSelfieStep('search')}
|
||||
className={`${ARCHIVE_PHOTO_PRIMARY_BUTTON} mt-6 w-full disabled:cursor-not-allowed disabled:opacity-55`}
|
||||
>
|
||||
Concordar e escolher minha selfie
|
||||
</button>
|
||||
<Link
|
||||
href={ARCHIVE_PHOTO_ALBUM_PATH}
|
||||
className={`mt-3 flex min-h-11 w-full items-center justify-center text-sm font-bold text-(--campaign-muted) ${ARCHIVE_PHOTO_FOCUS}`}
|
||||
>
|
||||
Agora não · voltar ao álbum
|
||||
</Link>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{step.kind === 'pick' ? (
|
||||
<section
|
||||
ref={pickRef}
|
||||
tabIndex={-1}
|
||||
aria-labelledby="selfie-pick-title"
|
||||
className={`${STEP_SHELL} rounded-2xl border border-(--campaign-line) bg-white p-6 focus:outline-none sm:p-7`}
|
||||
>
|
||||
<StepDots current={2} />
|
||||
<p className="mt-5 text-xs font-black tracking-[0.08em] text-[#184e92] uppercase">
|
||||
Passo 2 de 3
|
||||
</p>
|
||||
<h2
|
||||
id="selfie-pick-title"
|
||||
className="mt-1 border-0 pb-0 font-[family-name:var(--font-exo2)] text-2xl font-black"
|
||||
>
|
||||
Escolha uma selfie nítida
|
||||
</h2>
|
||||
<p className="mt-2 text-sm leading-5 text-(--campaign-muted)">
|
||||
Olhe para a câmera, com o rosto bem iluminado e sem outras pessoas na imagem.
|
||||
</p>
|
||||
{previewUrl ? (
|
||||
<div className="relative mt-5 aspect-[4/5] overflow-hidden rounded-2xl border border-black/10 bg-[#f5f5f4]">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- local blob preview, never uploaded */}
|
||||
<img src={previewUrl} alt="Prévia da sua selfie" className="size-full object-cover" />
|
||||
<span className="absolute right-3 bottom-3 rounded-full bg-black/55 px-3 py-1.5 text-[10px] font-bold text-white">
|
||||
Prévia no aparelho
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openPicker}
|
||||
className={`mt-5 flex aspect-[4/5] w-full flex-col items-center justify-center gap-3 rounded-2xl border-2 border-dashed border-[#184e92]/30 bg-[#f9faff] text-sm font-bold text-[#184e92] ${ARCHIVE_PHOTO_FOCUS}`}
|
||||
>
|
||||
<Camera aria-hidden="true" className="size-7" strokeWidth={2} />
|
||||
Tirar ou escolher uma foto
|
||||
</button>
|
||||
)}
|
||||
<div className="mt-4 flex items-start gap-3 rounded-xl bg-[#eef4fb] p-3 text-sm text-[#143c70]">
|
||||
<ShieldCheck aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
|
||||
<p className="leading-5">
|
||||
<b>Esta foto não sai do seu dispositivo.</b>
|
||||
<br />O processamento acontece aqui.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!file}
|
||||
onClick={() =>
|
||||
step.intent === 'leave-index'
|
||||
? setStep({ kind: 'confirm-leave' })
|
||||
: runSearch('search')
|
||||
}
|
||||
className={`${ARCHIVE_PHOTO_PRIMARY_BUTTON} mt-5 w-full disabled:cursor-not-allowed disabled:opacity-55`}
|
||||
>
|
||||
{step.intent === 'leave-index' ? 'Continuar' : 'Usar esta selfie'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openPicker}
|
||||
className={`${ARCHIVE_PHOTO_SECONDARY_BUTTON} mt-2 w-full`}
|
||||
>
|
||||
<Upload aria-hidden="true" className="size-4" />
|
||||
Escolher outra foto
|
||||
</button>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{step.kind === 'processing' ? (
|
||||
<section
|
||||
ref={processingRef}
|
||||
tabIndex={-1}
|
||||
aria-live="polite"
|
||||
className={`${STEP_SHELL} flex min-h-[560px] flex-col items-center justify-center rounded-2xl border border-(--campaign-line) bg-white px-6 py-12 text-center focus:outline-none`}
|
||||
>
|
||||
<div className="relative">
|
||||
<div className="grid size-28 place-items-center overflow-hidden rounded-full border-4 border-white bg-[#f5f5f4] shadow-[0_10px_28px_rgb(0_0_0/16%)]">
|
||||
{previewUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- local blob preview, never uploaded
|
||||
<img src={previewUrl} alt="" className="size-full object-cover" />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="absolute -right-2 -bottom-1 grid size-10 place-items-center rounded-full bg-[#eef4fb]">
|
||||
<span className="size-7 animate-spin rounded-full border-[3px] border-[#184e92]/16 border-t-[#184e92] motion-reduce:animate-none" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-7 text-xs font-black tracking-[0.08em] text-[#184e92] uppercase">
|
||||
{step.intent === 'leave-index' ? 'Saindo do índice' : 'Passo 3 de 3'}
|
||||
</p>
|
||||
<h2 className="mt-2 border-0 pb-0 font-[family-name:var(--font-exo2)] text-2xl font-black">
|
||||
{step.intent === 'leave-index'
|
||||
? 'Confirmando sua saída do índice…'
|
||||
: 'Procurando você nas fotos aprovadas…'}
|
||||
</h2>
|
||||
<p className="mt-3 max-w-[290px] text-sm leading-6 text-(--campaign-muted)">
|
||||
Mantenha esta tela aberta. Sua selfie continua somente neste aparelho.
|
||||
</p>
|
||||
<div className="mt-6 flex items-center gap-2 text-xs font-bold text-[#184e92]">
|
||||
<ShieldCheck aria-hidden="true" className="size-4" />
|
||||
Sem upload da imagem · sem cadastro
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{step.kind === 'results' ? (
|
||||
<section
|
||||
ref={resultsRef}
|
||||
tabIndex={-1}
|
||||
aria-labelledby="selfie-results-title"
|
||||
className="-mx-5 w-auto bg-white px-5 py-8 focus:outline-none sm:-mx-8 sm:px-8 sm:py-9"
|
||||
>
|
||||
<div className="mx-auto w-full max-w-6xl">
|
||||
<div className="flex flex-col gap-5 border-b border-(--campaign-line) pb-6 sm:flex-row sm:items-start sm:justify-between sm:gap-8">
|
||||
<div>
|
||||
<p className="hidden text-xs font-black tracking-[0.1em] text-[#184e92] uppercase sm:block">
|
||||
Sua busca por selfie
|
||||
</p>
|
||||
<h2
|
||||
id="selfie-results-title"
|
||||
className="mt-0 border-0 pb-0 font-[family-name:var(--font-exo2)] text-2xl font-black tracking-[-0.03em] sm:mt-2 sm:text-[34px]"
|
||||
>
|
||||
<span className="sm:hidden">Encontramos você nestas fotos.</span>
|
||||
<span className="hidden sm:inline">
|
||||
Estas são as fotos em que encontramos você.
|
||||
</span>
|
||||
</h2>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-(--campaign-muted)">
|
||||
<span className="sm:hidden">
|
||||
Só registros públicos aprovados, sem nomes de terceiros.
|
||||
</span>
|
||||
<span className="hidden sm:inline">
|
||||
Mostramos apenas registros públicos aprovados. Não exibimos nomes de outras
|
||||
pessoas nem nota de semelhança.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startSelfieStep('search')}
|
||||
className={`${ARCHIVE_PHOTO_SECONDARY_BUTTON} shrink-0`}
|
||||
>
|
||||
Usar outra selfie
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-7 grid grid-cols-2 gap-x-3 gap-y-5 sm:grid-cols-3 sm:gap-x-4 sm:gap-y-7">
|
||||
{step.photos.map((photo) => (
|
||||
<SelfieSearchResultCard key={photo.id} photo={photo} />
|
||||
))}
|
||||
</div>
|
||||
<PresenceBand onManage={() => setStep({ kind: 'presence' })} />
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{step.kind === 'empty' ? (
|
||||
<FlowStateCard
|
||||
icon={<ImageIcon aria-hidden="true" className="size-7" strokeWidth={2} />}
|
||||
title="Não encontramos fotos suas"
|
||||
body="Esta busca funciona para quem autorizou participar do índice. Também pode não haver uma foto sua entre os registros públicos aprovados."
|
||||
note="Não mostramos pessoas parecidas nem tentamos adivinhar quem você é."
|
||||
action={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startSelfieStep('search')}
|
||||
className={`${ARCHIVE_PHOTO_PRIMARY_BUTTON} w-full sm:min-w-44`}
|
||||
>
|
||||
Tentar outra foto
|
||||
</button>
|
||||
}
|
||||
secondary={
|
||||
<Link href={ARCHIVE_PHOTO_ALBUM_PATH} className={STATE_LINK}>
|
||||
Voltar ao álbum
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{step.kind === 'no-face' ? (
|
||||
<FlowStateCard
|
||||
icon={<TriangleAlert aria-hidden="true" className="size-7" strokeWidth={2} />}
|
||||
tone="danger"
|
||||
title="Não encontramos um rosto nesta selfie"
|
||||
body="Escolha uma foto de frente, com o rosto bem iluminado e sem outras pessoas na imagem."
|
||||
note="Nenhuma imagem foi enviada."
|
||||
action={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startSelfieStep('search')}
|
||||
className={`${ARCHIVE_PHOTO_PRIMARY_BUTTON} w-full sm:min-w-44`}
|
||||
>
|
||||
Escolher outra foto
|
||||
</button>
|
||||
}
|
||||
secondary={
|
||||
<Link href={ARCHIVE_PHOTO_ALBUM_PATH} className={STATE_LINK}>
|
||||
Voltar ao álbum
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{step.kind === 'presence' ? (
|
||||
<section
|
||||
ref={presenceRef}
|
||||
tabIndex={-1}
|
||||
aria-labelledby="selfie-presence-title"
|
||||
className={`${STEP_SHELL} rounded-2xl border border-(--campaign-line) bg-white p-6 focus:outline-none sm:p-7`}
|
||||
>
|
||||
<p className="text-xs font-black tracking-[0.08em] text-[#184e92] uppercase">
|
||||
Privacidade
|
||||
</p>
|
||||
<h2
|
||||
id="selfie-presence-title"
|
||||
className="mt-1 border-0 pb-0 font-[family-name:var(--font-exo2)] text-2xl font-black"
|
||||
>
|
||||
Você decide se quer aparecer
|
||||
</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-(--campaign-muted)">
|
||||
Para sair do índice, confirme sua identidade com uma selfie. Para retirar uma foto
|
||||
pública, use o canal da equipe responsável pelo acervo.
|
||||
</p>
|
||||
<div className="mt-5 flex gap-3 rounded-xl border-2 border-[#184e92] bg-[#f9faff] p-4">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="mt-0.5 size-5 shrink-0 rounded-full border-[5px] border-[#184e92]"
|
||||
/>
|
||||
<span>
|
||||
<b className="text-sm">Sair do índice de busca</b>
|
||||
<small className="mt-1 block text-xs leading-5 text-(--campaign-muted)">
|
||||
Seu rosto deixa de ser usado para localizar fotos por selfie.
|
||||
</small>
|
||||
</span>
|
||||
</div>
|
||||
{removalChannelUrl ? (
|
||||
<a
|
||||
href={removalChannelUrl}
|
||||
className={`${ARCHIVE_PHOTO_SECONDARY_BUTTON} mt-3 w-full items-start justify-between p-4 text-left`}
|
||||
>
|
||||
<span>
|
||||
<b className="text-sm">Pedir remoção de uma foto</b>
|
||||
<small className="mt-1 block text-xs leading-5 text-(--campaign-muted)">
|
||||
Abre o canal configurado pela equipe responsável pelo acervo.
|
||||
</small>
|
||||
</span>
|
||||
<ChevronLeft aria-hidden="true" className="mt-0.5 size-4 shrink-0 rotate-180" />
|
||||
</a>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startSelfieStep('leave-index')}
|
||||
className={`${ARCHIVE_PHOTO_PRIMARY_BUTTON} mt-6 w-full`}
|
||||
>
|
||||
Continuar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
className={`mt-4 block w-full text-center text-sm font-bold text-(--campaign-muted) ${ARCHIVE_PHOTO_FOCUS}`}
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{step.kind === 'removed' ? (
|
||||
<FlowStateCard
|
||||
icon={<ShieldCheck aria-hidden="true" className="size-7" strokeWidth={2} />}
|
||||
title="Pronto — você saiu do índice"
|
||||
body="Seu rosto deixou de ser usado para localizar fotos por selfie e o vetor foi apagado."
|
||||
note="Se quiser voltar a participar, fale com a equipe responsável pelo acervo."
|
||||
action={
|
||||
<Link
|
||||
href={ARCHIVE_PHOTO_ALBUM_PATH}
|
||||
className={`${ARCHIVE_PHOTO_PRIMARY_BUTTON} w-full sm:min-w-44`}
|
||||
>
|
||||
Voltar ao álbum
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{step.kind === 'presence-missing' ? (
|
||||
<FlowStateCard
|
||||
icon={<ShieldCheck aria-hidden="true" className="size-7" strokeWidth={2} />}
|
||||
title="Não encontramos sua presença no índice"
|
||||
body="Não foi possível reconhecer uma participação sua no índice com esta selfie. Nenhuma alteração foi feita."
|
||||
action={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startSelfieStep('leave-index')}
|
||||
className={`${ARCHIVE_PHOTO_PRIMARY_BUTTON} w-full sm:min-w-44`}
|
||||
>
|
||||
Tentar outra selfie
|
||||
</button>
|
||||
}
|
||||
secondary={
|
||||
<>
|
||||
<p className="text-sm leading-6 text-(--campaign-muted)">
|
||||
Quer retirar uma foto pública mesmo assim?
|
||||
</p>
|
||||
{removalChannelUrl ? (
|
||||
<a
|
||||
href={removalChannelUrl}
|
||||
className={`${ARCHIVE_PHOTO_SECONDARY_BUTTON} mt-3 w-full`}
|
||||
>
|
||||
Pedir remoção de uma foto
|
||||
</a>
|
||||
) : null}
|
||||
<Link href={ARCHIVE_PHOTO_ALBUM_PATH} className={`${STATE_LINK} mt-4`}>
|
||||
Voltar ao álbum
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{step.kind === 'unsupported' ? (
|
||||
<FlowStateCard
|
||||
icon={<Smartphone aria-hidden="true" className="size-7" strokeWidth={2} />}
|
||||
title="Este navegador não consegue fazer a busca por selfie"
|
||||
body="Para proteger sua imagem, o reconhecimento precisa rodar neste aparelho. Este navegador não oferece os recursos necessários."
|
||||
note="O problema não é com a sua selfie. Nenhuma imagem foi enviada."
|
||||
action={
|
||||
<Link
|
||||
href={ARCHIVE_PHOTO_ALBUM_PATH}
|
||||
className={`${ARCHIVE_PHOTO_PRIMARY_BUTTON} w-full sm:min-w-44`}
|
||||
>
|
||||
Voltar ao álbum
|
||||
</Link>
|
||||
}
|
||||
secondary={
|
||||
<p className="text-xs leading-5 text-(--campaign-muted)">
|
||||
Você pode tentar novamente em outro navegador ou aparelho compatível.
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{step.kind === 'engine-error' ? (
|
||||
<FlowStateCard
|
||||
icon={<TriangleAlert aria-hidden="true" className="size-7" strokeWidth={2} />}
|
||||
tone="danger"
|
||||
title="Não foi possível processar a busca"
|
||||
body="O reconhecimento neste aparelho parou antes de concluir. Sua selfie não foi enviada nem guardada."
|
||||
action={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startSelfieStep('search')}
|
||||
className={`${ARCHIVE_PHOTO_PRIMARY_BUTTON} w-full sm:min-w-44`}
|
||||
>
|
||||
<RefreshCw aria-hidden="true" className="size-4" />
|
||||
Tentar novamente
|
||||
</button>
|
||||
}
|
||||
secondary={
|
||||
<Link href={ARCHIVE_PHOTO_ALBUM_PATH} className={`${STATE_LINK} mt-4`}>
|
||||
Voltar ao álbum
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{step.kind === 'rate-limited' ? (
|
||||
<FlowStateCard
|
||||
icon={<Clock aria-hidden="true" className="size-7" strokeWidth={2} />}
|
||||
tone="warn"
|
||||
title="Muitas tentativas em pouco tempo"
|
||||
body="Para proteger este recurso, a busca foi pausada por alguns minutos."
|
||||
note="Espere alguns minutos antes de tentar novamente."
|
||||
action={
|
||||
<Link
|
||||
href={ARCHIVE_PHOTO_ALBUM_PATH}
|
||||
className={`${ARCHIVE_PHOTO_PRIMARY_BUTTON} w-full sm:min-w-44`}
|
||||
>
|
||||
Voltar ao álbum
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{step.kind === 'network' ? (
|
||||
<FlowStateCard
|
||||
icon={<WifiOff aria-hidden="true" className="size-7" strokeWidth={2} />}
|
||||
title="Não foi possível enviar a busca"
|
||||
body="A conexão falhou ao enviar somente os dados necessários para procurar as fotos. Sua selfie continua neste aparelho."
|
||||
action={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => runSearch(activeIntent)}
|
||||
className={`${ARCHIVE_PHOTO_PRIMARY_BUTTON} w-full sm:min-w-44`}
|
||||
>
|
||||
<RefreshCw aria-hidden="true" className="size-4" />
|
||||
Tentar enviar novamente
|
||||
</button>
|
||||
}
|
||||
secondary={
|
||||
<Link href={ARCHIVE_PHOTO_ALBUM_PATH} className={`${STATE_LINK} mt-4`}>
|
||||
Voltar ao álbum
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{step.kind === 'closed' ? <SelfieSearchClosed /> : null}
|
||||
|
||||
{step.kind === 'confirm-leave' ? (
|
||||
<LeaveConfirmDialog
|
||||
onConfirm={() => runSearch('leave-index')}
|
||||
onCancel={() => setStep({ kind: 'presence' })}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const STATE_LINK = `inline-flex min-h-11 items-center text-sm font-bold text-[#184e92] underline underline-offset-4 ${ARCHIVE_PHOTO_FOCUS}`
|
||||
|
||||
const StepDots = ({ current }: { current: 2 }) => (
|
||||
<div aria-hidden="true" className="flex items-center gap-2">
|
||||
{[1, 2, 3].map((position) => (
|
||||
<span
|
||||
key={position}
|
||||
className={`h-1.5 flex-1 rounded-full ${position <= current ? 'bg-[#184e92]' : 'bg-black/15'}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
const PresenceBand = ({ onManage }: { onManage: () => void }) => (
|
||||
<div className="mt-10 flex flex-col items-start justify-between gap-3 rounded-xl bg-(--campaign-cream) p-5 sm:flex-row sm:items-center">
|
||||
<div>
|
||||
<p className="font-extrabold">Quer controlar sua presença no álbum?</p>
|
||||
<p className="mt-1 text-sm text-(--campaign-muted)">
|
||||
Saia do índice ou peça a remoção de uma foto.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" onClick={onManage} className={`${STATE_LINK} shrink-0`}>
|
||||
Gerenciar minha presença
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
/**
|
||||
* The shared terminal-state card (artefato: cenas 06/09–13): the icon circle,
|
||||
* the honest title/body and the single primary action. `tone` only changes the
|
||||
* icon palette — hierarchy and spacing stay identical across states.
|
||||
*/
|
||||
const FlowStateCard = ({
|
||||
icon,
|
||||
tone = 'info',
|
||||
title,
|
||||
body,
|
||||
note,
|
||||
action,
|
||||
secondary,
|
||||
}: {
|
||||
icon: ReactNode
|
||||
tone?: 'info' | 'danger' | 'warn'
|
||||
title: string
|
||||
body: string
|
||||
note?: string
|
||||
action: ReactNode
|
||||
secondary?: ReactNode
|
||||
}) => {
|
||||
const sectionRef = useRef<HTMLElement>(null)
|
||||
useEffect(() => {
|
||||
sectionRef.current?.scrollIntoView({ block: 'start' })
|
||||
sectionRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
const toneClass =
|
||||
tone === 'danger'
|
||||
? 'bg-red-50 text-[#b42318]'
|
||||
: tone === 'warn'
|
||||
? 'bg-amber-50 text-amber-800'
|
||||
: 'bg-[#eef4fb] text-[#184e92]'
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={sectionRef}
|
||||
tabIndex={-1}
|
||||
className={`${STEP_SHELL} flex min-h-[560px] flex-col items-center justify-center rounded-2xl border border-(--campaign-line) bg-white px-6 py-12 text-center focus:outline-none`}
|
||||
>
|
||||
<div className={`grid size-14 place-items-center rounded-full ${toneClass}`}>{icon}</div>
|
||||
<h2 className="mt-5 border-0 pb-0 font-[family-name:var(--font-exo2)] text-2xl font-black text-balance">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="mt-2 max-w-[340px] text-sm leading-6 text-(--campaign-muted)">{body}</p>
|
||||
{note ? <p className="mt-3 max-w-[340px] text-sm font-bold">{note}</p> : null}
|
||||
<div className="mt-7 w-full max-w-sm">{action}</div>
|
||||
{secondary ? <div className="mt-4 w-full max-w-sm">{secondary}</div> : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The opt-out confirmation (artefato: cena 08, segundo quadro) — a real dialog:
|
||||
* the destructive action asks once, Esc and the backdrop cancel, Tab stays
|
||||
* inside, and focus starts on the cancel action so an accidental Enter never
|
||||
* erases the index entry.
|
||||
*/
|
||||
const LeaveConfirmDialog = ({
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}) => {
|
||||
const cancelRef = useRef<HTMLButtonElement>(null)
|
||||
const dialogRef = useRef<HTMLDivElement>(null)
|
||||
const onCancelRef = useRef(onCancel)
|
||||
onCancelRef.current = onCancel
|
||||
|
||||
useEffect(() => {
|
||||
cancelRef.current?.focus()
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onCancelRef.current()
|
||||
return
|
||||
}
|
||||
if (event.key !== 'Tab') return
|
||||
|
||||
const focusables = dialogRef.current?.querySelectorAll<HTMLElement>(FOCUSABLE)
|
||||
if (!focusables || focusables.length === 0) return
|
||||
const first = focusables[0]!
|
||||
const last = focusables[focusables.length - 1]!
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault()
|
||||
last.focus()
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="selfie-leave-title"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Fechar sem alterar"
|
||||
tabIndex={-1}
|
||||
onClick={onCancel}
|
||||
className="absolute inset-0 cursor-default bg-[#171412]/55 backdrop-blur-[2px]"
|
||||
/>
|
||||
<div className="relative w-full max-w-[420px] rounded-2xl border border-(--campaign-line) bg-white p-5 shadow-[0_24px_70px_rgb(0_0_0/28%)]">
|
||||
<div className="grid size-11 place-items-center rounded-full bg-red-50 text-[#b42318]">
|
||||
<Trash2 aria-hidden="true" className="size-5" strokeWidth={2} />
|
||||
</div>
|
||||
<h2
|
||||
id="selfie-leave-title"
|
||||
className="mt-4 border-0 pb-0 font-[family-name:var(--font-exo2)] text-xl font-black"
|
||||
>
|
||||
Confirmar saída do índice?
|
||||
</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-(--campaign-muted)">
|
||||
Vamos reconhecer você neste aparelho e, se houver correspondência, retirar sua presença do
|
||||
índice de busca.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
className={`mt-6 flex min-h-11 w-full items-center justify-center rounded-[10px] bg-[#b42318] px-4 font-[family-name:var(--font-exo2)] text-sm font-extrabold text-white ${ARCHIVE_PHOTO_FOCUS}`}
|
||||
>
|
||||
Confirmar saída do índice
|
||||
</button>
|
||||
<button
|
||||
ref={cancelRef}
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className={`mt-3 flex min-h-11 w-full items-center justify-center text-sm font-bold text-(--campaign-muted) ${ARCHIVE_PHOTO_FOCUS}`}
|
||||
>
|
||||
Voltar sem alterar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
26
src/components/fotos/SelfieSearchResultCard.tsx
Normal file
26
src/components/fotos/SelfieSearchResultCard.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import Link from 'next/link'
|
||||
|
||||
import { ARCHIVE_PHOTO_ALBUM_PATH } from '@/lib/archivePhotoPublicCatalog'
|
||||
import type { FaceSearchPhotoView } from '@/lib/faceSearch'
|
||||
|
||||
import { ArchivePhotoCardBody } from './ArchivePhotoCard'
|
||||
import { ARCHIVE_PHOTO_CARD } from './archivePhotoClasses'
|
||||
|
||||
/**
|
||||
* C234 — one photo of the selfie-search result (artefato: cena 05, variante
|
||||
* revisada): the album's own card body (`ArchivePhotoCardBody`) minus the
|
||||
* third-party line — the shape of the view model has no `people`, so "Quem
|
||||
* aparece" cannot leak back in. The card links to the album's own detail
|
||||
* (`/fotos?foto=<id>`), the single owner of the photo overlay.
|
||||
*/
|
||||
export const SelfieSearchResultCard = ({ photo }: { photo: FaceSearchPhotoView }) => (
|
||||
<article>
|
||||
<Link
|
||||
href={`${ARCHIVE_PHOTO_ALBUM_PATH}?foto=${photo.id}`}
|
||||
className={`${ARCHIVE_PHOTO_CARD} group`}
|
||||
data-photo-id={photo.id}
|
||||
>
|
||||
<ArchivePhotoCardBody item={{ ...photo, peopleLabel: null }} />
|
||||
</Link>
|
||||
</article>
|
||||
)
|
||||
159
src/components/fotos/faceSearchEngine.ts
Normal file
159
src/components/fotos/faceSearchEngine.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
/**
|
||||
* C234 — the on-device engine of the selfie search. The visitor's photo never
|
||||
* leaves the browser: it is decoded here, resized and run through the SAME
|
||||
* face-api nets the CLI uses (same model ids, same detector options), and only
|
||||
* the 128-float descriptor is ever sent. The engine is lazily imported — the
|
||||
* ~2.6 MB of JS plus the ~7 MB of same-origin models are fetched only when the
|
||||
* visitor actually starts the flow.
|
||||
*
|
||||
* Fail-closed probes come before the download: the bundled TFJS runs on the
|
||||
* WebGL backend for speed, and a browser with WebGL disabled would silently
|
||||
* crawl on the CPU backend (or blank) — the flow says so up front instead of
|
||||
* blaming the selfie. The e2e build replaces the engine with a deterministic
|
||||
* stub (`NEXT_PUBLIC_FACE_SEARCH_STUB=1`, `window.__faceSearchStub`), same
|
||||
* contract as the card cutout.
|
||||
*/
|
||||
import { loadCardPhoto } from '@/components/cards/cardCanvas'
|
||||
import { supportsWebgl } from '@/components/cards/cardCutout'
|
||||
import {
|
||||
FACE_DETECTOR_INPUT_SIZE,
|
||||
FACE_DETECTOR_SCORE_THRESHOLD,
|
||||
FACE_MODEL_DIR,
|
||||
faceStubDescriptorFromBytes,
|
||||
} from '@/lib/faceSearch'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
/** Test seam: the e2e build stubs the engine through this flag. */
|
||||
__faceSearchStub?: 'ok' | 'slow' | 'error'
|
||||
}
|
||||
}
|
||||
|
||||
const STUB_ENABLED = process.env.NEXT_PUBLIC_FACE_SEARCH_STUB === '1'
|
||||
const STUB_DELAY_MS = 1500
|
||||
|
||||
/** Longest edge sent to the engine; the full-resolution photo never goes in. */
|
||||
const SELFIE_MAX_EDGE = 1024
|
||||
|
||||
export type SelfieDescriptorResult =
|
||||
| { ok: true; descriptor: number[] }
|
||||
| { ok: false; reason: 'unsupported' | 'engine' | 'empty' }
|
||||
|
||||
/**
|
||||
* Whether this browser can run the local engine. The caller must invoke it on
|
||||
* the client (the probe needs `document`); the stub build is always "supported"
|
||||
* so the e2e suite exercises the flow, not the capability.
|
||||
*/
|
||||
export const supportsFaceSearchEngine = (): boolean => {
|
||||
if (STUB_ENABLED) return true
|
||||
|
||||
return supportsWebgl()
|
||||
}
|
||||
|
||||
type FaceApi = typeof import('@vladmandic/face-api')
|
||||
|
||||
let facadePromise: Promise<FaceApi> | null = null
|
||||
|
||||
/**
|
||||
* Loads the three nets once per page: `tinyFaceDetector` finds the faces,
|
||||
* `faceLandmark68Net` aligns them and `faceRecognitionNet` produces the 128-d
|
||||
* descriptor — the exact trio the CLI loads. A failed load is not cached, so a
|
||||
* retry re-attempts the download.
|
||||
*/
|
||||
const loadFaceApi = (): Promise<FaceApi> => {
|
||||
if (!facadePromise) {
|
||||
facadePromise = (async () => {
|
||||
const faceapi = await import('@vladmandic/face-api')
|
||||
await Promise.all([
|
||||
faceapi.nets.tinyFaceDetector.loadFromUri(FACE_MODEL_DIR),
|
||||
faceapi.nets.faceLandmark68Net.loadFromUri(FACE_MODEL_DIR),
|
||||
faceapi.nets.faceRecognitionNet.loadFromUri(FACE_MODEL_DIR),
|
||||
])
|
||||
return faceapi
|
||||
})().catch((error: unknown) => {
|
||||
facadePromise = null
|
||||
throw error
|
||||
})
|
||||
}
|
||||
|
||||
return facadePromise
|
||||
}
|
||||
|
||||
const cappedSize = (width: number, height: number) => {
|
||||
const longest = Math.max(width, height)
|
||||
const scale = longest > SELFIE_MAX_EDGE ? SELFIE_MAX_EDGE / longest : 1
|
||||
|
||||
return {
|
||||
width: Math.max(1, Math.round(width * scale)),
|
||||
height: Math.max(1, Math.round(height * scale)),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Descriptor of the LARGEST face in the photo (the person taking the selfie is
|
||||
* the one closest to the camera). Resolves `empty` when no face is found — the
|
||||
* flow has an honest state for that, never a generic engine error.
|
||||
*/
|
||||
export const computeSelfieDescriptor = async (file: File): Promise<SelfieDescriptorResult> => {
|
||||
if (STUB_ENABLED) return stubDescriptor(file)
|
||||
|
||||
if (!supportsWebgl()) return { ok: false, reason: 'unsupported' }
|
||||
|
||||
try {
|
||||
const faceapi = await loadFaceApi()
|
||||
const photo = await loadCardPhoto(file)
|
||||
const { width, height } = cappedSize(photo.naturalWidth, photo.naturalHeight)
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
const context = canvas.getContext('2d')
|
||||
if (!context) return { ok: false, reason: 'engine' }
|
||||
context.imageSmoothingEnabled = true
|
||||
context.imageSmoothingQuality = 'high'
|
||||
context.drawImage(photo, 0, 0, width, height)
|
||||
|
||||
// Let the processing state paint before the synchronous inference.
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
|
||||
|
||||
const results = await faceapi
|
||||
.detectAllFaces(
|
||||
canvas,
|
||||
new faceapi.TinyFaceDetectorOptions({
|
||||
inputSize: FACE_DETECTOR_INPUT_SIZE,
|
||||
scoreThreshold: FACE_DETECTOR_SCORE_THRESHOLD,
|
||||
}),
|
||||
)
|
||||
.withFaceLandmarks()
|
||||
.withFaceDescriptors()
|
||||
if (results.length === 0) return { ok: false, reason: 'empty' }
|
||||
|
||||
const largest = results.reduce((best, current) =>
|
||||
current.detection.box.area > best.detection.box.area ? current : best,
|
||||
)
|
||||
|
||||
return { ok: true, descriptor: Array.from(largest.descriptor) }
|
||||
} catch {
|
||||
return { ok: false, reason: 'engine' }
|
||||
}
|
||||
}
|
||||
|
||||
const readStubMode = (): 'ok' | 'slow' | 'error' => {
|
||||
if (typeof window === 'undefined') return 'ok'
|
||||
const mode = window.__faceSearchStub
|
||||
|
||||
return mode === 'slow' || mode === 'error' ? mode : 'ok'
|
||||
}
|
||||
|
||||
/**
|
||||
* Test seam (`NEXT_PUBLIC_FACE_SEARCH_STUB=1`, e2e builds only): the same bytes
|
||||
* always derive the same descriptor, so the spec seeds a subject whose vector
|
||||
* equals the fixture's without ever loading a model.
|
||||
*/
|
||||
const stubDescriptor = async (file: File): Promise<SelfieDescriptorResult> => {
|
||||
const mode = readStubMode()
|
||||
await new Promise((resolve) => setTimeout(resolve, mode === 'slow' ? STUB_DELAY_MS : 20))
|
||||
if (mode === 'error') return { ok: false, reason: 'engine' }
|
||||
|
||||
const bytes = new Uint8Array(await file.arrayBuffer())
|
||||
return { ok: true, descriptor: faceStubDescriptorFromBytes(bytes) }
|
||||
}
|
||||
|
|
@ -77,5 +77,15 @@ export const PhotoAlbum: GlobalConfig = {
|
|||
'Link (https://) ou e-mail (mailto:) para pedir a remoção de uma foto. Obrigatório para publicar o álbum ou aprovar uma foto.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'selfieSearchEnabled',
|
||||
type: 'checkbox',
|
||||
label: 'Busca por selfie',
|
||||
defaultValue: false,
|
||||
admin: {
|
||||
description:
|
||||
'Liga a busca por selfie (/fotos/encontre). Nasce desligada: só abra com o consentimento configurado e o aval jurídico/DPIA registrados (C234).',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,13 @@ import { normalizeForSearch } from '@/lib/speechSearch'
|
|||
|
||||
export const ARCHIVE_PHOTO_ALBUM_PATH = '/fotos'
|
||||
|
||||
/**
|
||||
* C234 — the selfie search entry (`/fotos/encontre`): a page of the album
|
||||
* (noindex), gated by the album global's `selfieSearchEnabled` flag and by the
|
||||
* Consent resolved server-side.
|
||||
*/
|
||||
export const ARCHIVE_PHOTO_ALBUM_ENTRY_PATH = `${ARCHIVE_PHOTO_ALBUM_PATH}/encontre`
|
||||
|
||||
/** Grid page size (design scene 07: 24 itens; prev/next preserve the facets). */
|
||||
export const ARCHIVE_PHOTO_ALBUM_PAGE_SIZE = 24
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,19 @@ export const CAMPAIGN_PUSH_CONSENT_KEY = 'campanha-notificacoes-push'
|
|||
export const CAMPAIGN_PUSH_CONSENT_MISSING_MESSAGE =
|
||||
'Consentimento de notificações push ainda não configurado.'
|
||||
|
||||
/**
|
||||
* C234 — the public selfie search on the photo album. Two keys because the
|
||||
* two biometric uses are distinct: `FACE_SEARCH_CONSENT_KEY` is the temporary
|
||||
* use of the querying person's face to find themselves in the album (the
|
||||
* vector is discarded with the request), while `FACE_INDEX_CONSENT_KEY` is
|
||||
* the enrollment in the search index (the vector is stored until removal).
|
||||
* Both fail closed: without the Consent row with the exact key the flow is
|
||||
* closed, and editing the text invalidates every enrolled subject until
|
||||
* re-consent.
|
||||
*/
|
||||
export const FACE_SEARCH_CONSENT_KEY = 'busca-selfie-fotos'
|
||||
export const FACE_INDEX_CONSENT_KEY = 'busca-selfie-indice'
|
||||
|
||||
/**
|
||||
* S9 — campaign home "novidades" capture (name + WhatsApp, engagement level
|
||||
* toggle). Fail-closed like every public flow: while the admin has not created
|
||||
|
|
|
|||
188
src/lib/faceSearch.ts
Normal file
188
src/lib/faceSearch.ts
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
/**
|
||||
* C234 — pure contract of the selfie search over the public photo album.
|
||||
*
|
||||
* The visitor's face descriptor is computed on their own device (the selfie
|
||||
* never leaves it); the server only ever sees the 128-float vector and answers
|
||||
* with photos, never with a score. This module owns the wire vocabulary (the
|
||||
* descriptor length, the model id, the intents), the distance math, the
|
||||
* eligibility of an enrolled subject (active + current model + current consent
|
||||
* hash — fail-closed on any of the three), the result view model (which by
|
||||
* SHAPE cannot carry a similarity number or a third-party name) and the
|
||||
* deterministic stub the e2e build uses in place of the engine.
|
||||
*
|
||||
* Scope lock of the gate (PR #1370): only consented/indexed faces exist in the
|
||||
* index (A/C); the anonymous-archive index (B) is out. The vector of the
|
||||
* visitor is never persisted — it lives in the request only.
|
||||
*/
|
||||
import type { ArchivePhotoPublicItem } from '@/lib/archivePhotoPublicCatalog'
|
||||
|
||||
/** face-api's faceRecognitionNet descriptor (128 floats, euclidean space). */
|
||||
export const FACE_SEARCH_DESCRIPTOR_LENGTH = 128
|
||||
|
||||
/** The engine+model the enrolled vectors belong to — a match across models is void. */
|
||||
export const FACE_SEARCH_MODEL = 'face-api@1.7.15/faceRecognitionNet'
|
||||
|
||||
/**
|
||||
* Internal similarity threshold (face-api's usual 0.6 is a permissive default;
|
||||
* the feature prefers missing a match over showing the wrong person). Never
|
||||
* serialized: no caller of this contract receives a distance.
|
||||
*/
|
||||
export const FACE_SEARCH_MAX_DISTANCE = 0.45
|
||||
|
||||
/** Ceiling of photos in one answer; the oldest beyond it stay only in the album. */
|
||||
export const FACE_SEARCH_RESULT_LIMIT = 60
|
||||
|
||||
/**
|
||||
* Detector tuning shared by the browser engine and the CLI engine: they must
|
||||
* load the same three nets with the same options, otherwise the descriptors of
|
||||
* the selfie and of the archive would live in different spaces.
|
||||
*/
|
||||
export const FACE_DETECTOR_INPUT_SIZE = 512
|
||||
export const FACE_DETECTOR_SCORE_THRESHOLD = 0.3
|
||||
|
||||
/** Same-origin directory the browser engine loads the model files from. */
|
||||
export const FACE_MODEL_DIR = '/fotos/modelos'
|
||||
|
||||
export const FACE_SEARCH_INTENTS = ['search', 'leave-index'] as const
|
||||
|
||||
/**
|
||||
* A stored/requested descriptor: exactly `FACE_SEARCH_DESCRIPTOR_LENGTH` finite
|
||||
* numbers, or nothing (fail-closed — a wrong-length or poisoned vector never
|
||||
* reaches the math).
|
||||
*/
|
||||
export const readFaceVector = (value: unknown): number[] | null => {
|
||||
if (!Array.isArray(value) || value.length !== FACE_SEARCH_DESCRIPTOR_LENGTH) return null
|
||||
|
||||
const vector: number[] = []
|
||||
for (const component of value) {
|
||||
if (typeof component !== 'number' || !Number.isFinite(component)) return null
|
||||
vector.push(component)
|
||||
}
|
||||
return vector
|
||||
}
|
||||
|
||||
/** Euclidean distance of same-length descriptors; a length mismatch is never a match. */
|
||||
export const faceEuclideanDistance = (a: readonly number[], b: readonly number[]): number => {
|
||||
if (a.length !== b.length || a.length === 0) return Number.POSITIVE_INFINITY
|
||||
|
||||
let sum = 0
|
||||
for (let index = 0; index < a.length; index += 1) {
|
||||
const delta = a[index] - b[index]
|
||||
sum += delta * delta
|
||||
}
|
||||
return Math.sqrt(sum)
|
||||
}
|
||||
|
||||
export type FaceSearchSubject = {
|
||||
id: number
|
||||
status?: string | null
|
||||
model?: string | null
|
||||
consentHash?: string | null
|
||||
vector?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* A subject only answers while it is `active`, was enrolled with the model the
|
||||
* query belongs to and its consent snapshot is still the configured text
|
||||
* (editing the Consent text invalidates every enrolled descriptor until the
|
||||
* person is re-consented — fail-closed).
|
||||
*/
|
||||
export const faceSubjectIsEligible = (
|
||||
subject: FaceSearchSubject,
|
||||
{ model, consentHash }: { model: string; consentHash: string },
|
||||
): boolean =>
|
||||
subject.status === 'active' && subject.model === model && subject.consentHash === consentHash
|
||||
|
||||
/**
|
||||
* The best eligible subject strictly under `maxDistance`, or null. Subject
|
||||
* order breaks nothing: the comparison is strict, so only a closer subject
|
||||
* replaces the current best.
|
||||
*/
|
||||
export const findFaceMatch = ({
|
||||
vector,
|
||||
subjects,
|
||||
model,
|
||||
consentHash,
|
||||
}: {
|
||||
vector: readonly number[]
|
||||
subjects: readonly FaceSearchSubject[]
|
||||
model: string
|
||||
consentHash: string
|
||||
}): FaceSearchSubject | null => {
|
||||
let best: FaceSearchSubject | null = null
|
||||
let bestDistance = FACE_SEARCH_MAX_DISTANCE
|
||||
|
||||
for (const subject of subjects) {
|
||||
if (!faceSubjectIsEligible(subject, { model, consentHash })) continue
|
||||
const candidate = readFaceVector(subject.vector)
|
||||
if (!candidate) continue
|
||||
|
||||
const distance = faceEuclideanDistance(vector, candidate)
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance
|
||||
best = subject
|
||||
}
|
||||
}
|
||||
|
||||
return best
|
||||
}
|
||||
|
||||
/**
|
||||
* What one matched photo looks like to the visitor: the album's public view
|
||||
* minus the third-party names (`people`/`peopleLabel`) and minus anything
|
||||
* numeric — the shape is the guardrail.
|
||||
*/
|
||||
export type FaceSearchPhotoView = {
|
||||
id: number
|
||||
alt: string
|
||||
title: string
|
||||
takenOn: string | null
|
||||
shortDateLabel: string | null
|
||||
municipalityName: string | null
|
||||
sceneLabel: string | null
|
||||
metaLabel: string
|
||||
thumbnailPath: string
|
||||
mediaPath: string
|
||||
downloadPath: string
|
||||
downloadFilename: string
|
||||
}
|
||||
|
||||
export const toFaceSearchPhotoView = (item: ArchivePhotoPublicItem): FaceSearchPhotoView => ({
|
||||
id: item.id,
|
||||
alt: item.alt,
|
||||
title: item.title,
|
||||
takenOn: item.takenOn,
|
||||
shortDateLabel: item.shortDateLabel,
|
||||
municipalityName: item.municipalityName,
|
||||
sceneLabel: item.sceneLabel,
|
||||
metaLabel: item.metaLabel,
|
||||
thumbnailPath: item.thumbnailPath,
|
||||
mediaPath: item.mediaPath,
|
||||
downloadPath: item.downloadPath,
|
||||
downloadFilename: item.downloadFilename,
|
||||
})
|
||||
|
||||
/**
|
||||
* Deterministic stand-in for the on-device engine (`NEXT_PUBLIC_FACE_SEARCH_STUB=1`,
|
||||
* e2e builds only): the same bytes always derive the same descriptor, so the
|
||||
* spec can seed a subject whose vector equals the fixture's without ever
|
||||
* loading a model. Never used outside the stub seam.
|
||||
*/
|
||||
export const faceStubDescriptorFromBytes = (bytes: Uint8Array): number[] => {
|
||||
let hash = 0x811c9dc5
|
||||
for (const byte of bytes) {
|
||||
hash ^= byte
|
||||
hash = Math.imul(hash, 0x01000193) >>> 0
|
||||
}
|
||||
|
||||
let state = hash || 0x9e3779b9
|
||||
const vector: number[] = []
|
||||
for (let index = 0; index < FACE_SEARCH_DESCRIPTOR_LENGTH; index += 1) {
|
||||
state ^= state << 13
|
||||
state ^= state >>> 17
|
||||
state ^= state << 5
|
||||
state >>>= 0
|
||||
vector.push((state / 0xffffffff) * 2 - 1)
|
||||
}
|
||||
return vector
|
||||
}
|
||||
15
src/lib/schemas/faceSearch.ts
Normal file
15
src/lib/schemas/faceSearch.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { z } from 'zod'
|
||||
|
||||
import { FACE_SEARCH_DESCRIPTOR_LENGTH, FACE_SEARCH_INTENTS } from '@/lib/faceSearch'
|
||||
|
||||
/**
|
||||
* C234 — wire contract of the public `POST /api/fotos/selfie`. Strict object:
|
||||
* an extra key is a malformed body, never a silently ignored one. The vector
|
||||
* must be exactly the on-device descriptor (finite numbers — Zod rejects NaN
|
||||
* and Infinity natively); the intent names the only two actions the endpoint
|
||||
* performs. There is no image field and no identity field — by construction.
|
||||
*/
|
||||
export const faceSearchRequestSchema = z.strictObject({
|
||||
vector: z.array(z.number()).length(FACE_SEARCH_DESCRIPTOR_LENGTH),
|
||||
intent: z.enum(FACE_SEARCH_INTENTS),
|
||||
})
|
||||
20790
src/migrations/20260927_193954_add_face_subject.json
Normal file
20790
src/migrations/20260927_193954_add_face_subject.json
Normal file
File diff suppressed because it is too large
Load diff
61
src/migrations/20260927_193954_add_face_subject.ts
Normal file
61
src/migrations/20260927_193954_add_face_subject.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
CREATE TYPE "public"."enum_face_subject_status" AS ENUM('active', 'removed');
|
||||
CREATE TABLE "face_subject" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"label" varchar NOT NULL,
|
||||
"consent_id" integer NOT NULL,
|
||||
"consent_hash" varchar,
|
||||
"model" varchar,
|
||||
"enrolled_at" timestamp(3) with time zone,
|
||||
"vector" jsonb,
|
||||
"status" "enum_face_subject_status" DEFAULT 'active' NOT NULL,
|
||||
"removed_at" timestamp(3) with time zone,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "face_subject_rels" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"order" integer,
|
||||
"parent_id" integer NOT NULL,
|
||||
"path" varchar NOT NULL,
|
||||
"archive_photo_id" integer
|
||||
);
|
||||
|
||||
ALTER TABLE "archive_photo" ADD COLUMN "faces_checked_at" timestamp(3) with time zone;
|
||||
ALTER TABLE "archive_photo" ADD COLUMN "faces_checked_key" varchar;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "face_subject_id" integer;
|
||||
ALTER TABLE "photo_album" ADD COLUMN "selfie_search_enabled" boolean DEFAULT false;
|
||||
ALTER TABLE "face_subject" ADD CONSTRAINT "face_subject_consent_id_consent_id_fk" FOREIGN KEY ("consent_id") REFERENCES "public"."consent"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "face_subject_rels" ADD CONSTRAINT "face_subject_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."face_subject"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "face_subject_rels" ADD CONSTRAINT "face_subject_rels_archive_photo_fk" FOREIGN KEY ("archive_photo_id") REFERENCES "public"."archive_photo"("id") ON DELETE cascade ON UPDATE no action;
|
||||
CREATE INDEX "face_subject_consent_idx" ON "face_subject" USING btree ("consent_id");
|
||||
CREATE INDEX "face_subject_status_idx" ON "face_subject" USING btree ("status");
|
||||
CREATE INDEX "face_subject_updated_at_idx" ON "face_subject" USING btree ("updated_at");
|
||||
CREATE INDEX "face_subject_created_at_idx" ON "face_subject" USING btree ("created_at");
|
||||
CREATE INDEX "face_subject_rels_order_idx" ON "face_subject_rels" USING btree ("order");
|
||||
CREATE INDEX "face_subject_rels_parent_idx" ON "face_subject_rels" USING btree ("parent_id");
|
||||
CREATE INDEX "face_subject_rels_path_idx" ON "face_subject_rels" USING btree ("path");
|
||||
CREATE INDEX "face_subject_rels_archive_photo_id_idx" ON "face_subject_rels" USING btree ("archive_photo_id");
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_face_subject_fk" FOREIGN KEY ("face_subject_id") REFERENCES "public"."face_subject"("id") ON DELETE cascade ON UPDATE no action;
|
||||
CREATE INDEX "payload_locked_documents_rels_face_subject_id_idx" ON "payload_locked_documents_rels" USING btree ("face_subject_id");`)
|
||||
}
|
||||
|
||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "face_subject" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "face_subject_rels" DISABLE ROW LEVEL SECURITY;
|
||||
DROP TABLE "face_subject" CASCADE;
|
||||
DROP TABLE "face_subject_rels" CASCADE;
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_face_subject_fk";
|
||||
|
||||
DROP INDEX "payload_locked_documents_rels_face_subject_id_idx";
|
||||
ALTER TABLE "archive_photo" DROP COLUMN "faces_checked_at";
|
||||
ALTER TABLE "archive_photo" DROP COLUMN "faces_checked_key";
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "face_subject_id";
|
||||
ALTER TABLE "photo_album" DROP COLUMN "selfie_search_enabled";
|
||||
DROP TYPE "public"."enum_face_subject_status";`)
|
||||
}
|
||||
|
|
@ -90,6 +90,7 @@ import * as migration_20260926_034118_add_speech_embedding from './20260926_0341
|
|||
import * as migration_20260926_062039_add_archive_photo from './20260926_062039_add_archive_photo';
|
||||
import * as migration_20260926_132106_add_archive_photo_catalog from './20260926_132106_add_archive_photo_catalog';
|
||||
import * as migration_20260926_163248_add_archive_photo_publication from './20260926_163248_add_archive_photo_publication';
|
||||
import * as migration_20260927_193954_add_face_subject from './20260927_193954_add_face_subject';
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
|
|
@ -550,6 +551,11 @@ export const migrations = [
|
|||
{
|
||||
up: migration_20260926_163248_add_archive_photo_publication.up,
|
||||
down: migration_20260926_163248_add_archive_photo_publication.down,
|
||||
name: '20260926_163248_add_archive_photo_publication'
|
||||
name: '20260926_163248_add_archive_photo_publication',
|
||||
},
|
||||
{
|
||||
up: migration_20260927_193954_add_face_subject.up,
|
||||
down: migration_20260927_193954_add_face_subject.down,
|
||||
name: '20260927_193954_add_face_subject'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@ export interface Config {
|
|||
contentPiece: ContentPiece;
|
||||
contentMedia: ContentMedia;
|
||||
archivePhoto: ArchivePhoto;
|
||||
faceSubject: FaceSubject;
|
||||
contentEvent: ContentEvent;
|
||||
recording: Recording;
|
||||
recordingMedia: RecordingMedia;
|
||||
|
|
@ -153,6 +154,7 @@ export interface Config {
|
|||
contentPiece: ContentPieceSelect<false> | ContentPieceSelect<true>;
|
||||
contentMedia: ContentMediaSelect<false> | ContentMediaSelect<true>;
|
||||
archivePhoto: ArchivePhotoSelect<false> | ArchivePhotoSelect<true>;
|
||||
faceSubject: FaceSubjectSelect<false> | FaceSubjectSelect<true>;
|
||||
contentEvent: ContentEventSelect<false> | ContentEventSelect<true>;
|
||||
recording: RecordingSelect<false> | RecordingSelect<true>;
|
||||
recordingMedia: RecordingMediaSelect<false> | RecordingMediaSelect<true>;
|
||||
|
|
@ -1563,6 +1565,13 @@ export interface ArchivePhoto {
|
|||
* Tudo o que a busca da lista encontra, normalizado (sem acentos, minúsculas): legenda, texto visível, temas, pessoas, município, álbuns e tags.
|
||||
*/
|
||||
searchText?: string | null;
|
||||
/**
|
||||
* Estado do lote da busca por selfie (C234). Nunca editado à mão.
|
||||
*/
|
||||
faces?: {
|
||||
checkedAt?: string | null;
|
||||
checkedKey?: string | null;
|
||||
};
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
url?: string | null;
|
||||
|
|
@ -1575,6 +1584,58 @@ export interface ArchivePhoto {
|
|||
focalX?: number | null;
|
||||
focalY?: number | null;
|
||||
}
|
||||
/**
|
||||
* Pessoas que consentiram entrar no índice da busca por selfie (/fotos/encontre). Operado pela CLI faces:enroll/faces:index; a remoção é registrada no status.
|
||||
*
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "faceSubject".
|
||||
*/
|
||||
export interface FaceSubject {
|
||||
id: number;
|
||||
/**
|
||||
* Como a assessoria identifica a pessoa; nunca aparece no site.
|
||||
*/
|
||||
label: string;
|
||||
/**
|
||||
* O Consent da adesão ao índice (chave estável busca-selfie-indice).
|
||||
*/
|
||||
consent: number | Consent;
|
||||
/**
|
||||
* Snapshot do texto aceito no enrollment; se o texto mudar, a pessoa fica inelegível até re-consentir.
|
||||
*/
|
||||
consentHash?: string | null;
|
||||
/**
|
||||
* Modelo do descriptor (troca de modelo invalida o índice: re-enrollment).
|
||||
*/
|
||||
model?: string | null;
|
||||
/**
|
||||
* Muda a cada enrollment/re-enrollment; é o que marca o lote como stale (o updated_at muda também quando o lote escreve os vínculos).
|
||||
*/
|
||||
enrolledAt?: string | null;
|
||||
vector?:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
/**
|
||||
* Removida: pedido de saída atendido — o vetor é apagado e a pessoa deixa de ser encontrada.
|
||||
*/
|
||||
status: 'active' | 'removed';
|
||||
/**
|
||||
* Data do atendimento da saída do índice.
|
||||
*/
|
||||
removedAt?: string | null;
|
||||
/**
|
||||
* Derivado do lote (pnpm faces:index): fotos aprovadas em que o descriptor foi encontrado.
|
||||
*/
|
||||
matchedPhotos?: (number | ArchivePhoto)[] | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* Eventos anônimos de circulação (abertura, download, compartilhamento).
|
||||
*
|
||||
|
|
@ -2409,6 +2470,10 @@ export interface PayloadLockedDocument {
|
|||
relationTo: 'archivePhoto';
|
||||
value: number | ArchivePhoto;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'faceSubject';
|
||||
value: number | FaceSubject;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'contentEvent';
|
||||
value: number | ContentEvent;
|
||||
|
|
@ -3201,6 +3266,12 @@ export interface ArchivePhotoSelect<T extends boolean = true> {
|
|||
municipalityName?: T;
|
||||
municipalitySlug?: T;
|
||||
searchText?: T;
|
||||
faces?:
|
||||
| T
|
||||
| {
|
||||
checkedAt?: T;
|
||||
checkedKey?: T;
|
||||
};
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
url?: T;
|
||||
|
|
@ -3213,6 +3284,23 @@ export interface ArchivePhotoSelect<T extends boolean = true> {
|
|||
focalX?: T;
|
||||
focalY?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "faceSubject_select".
|
||||
*/
|
||||
export interface FaceSubjectSelect<T extends boolean = true> {
|
||||
label?: T;
|
||||
consent?: T;
|
||||
consentHash?: T;
|
||||
model?: T;
|
||||
enrolledAt?: T;
|
||||
vector?: T;
|
||||
status?: T;
|
||||
removedAt?: T;
|
||||
matchedPhotos?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "contentEvent_select".
|
||||
|
|
@ -3917,6 +4005,10 @@ export interface PhotoAlbum {
|
|||
* Link (https://) ou e-mail (mailto:) para pedir a remoção de uma foto. Obrigatório para publicar o álbum ou aprovar uma foto.
|
||||
*/
|
||||
removalChannelUrl?: string | null;
|
||||
/**
|
||||
* Liga a busca por selfie (/fotos/encontre). Nasce desligada: só abra com o consentimento configurado e o aval jurídico/DPIA registrados (C234).
|
||||
*/
|
||||
selfieSearchEnabled?: boolean | null;
|
||||
updatedAt?: string | null;
|
||||
createdAt?: string | null;
|
||||
}
|
||||
|
|
@ -4046,6 +4138,7 @@ export interface SocialFeedSettingsSelect<T extends boolean = true> {
|
|||
export interface PhotoAlbumSelect<T extends boolean = true> {
|
||||
published?: T;
|
||||
removalChannelUrl?: T;
|
||||
selfieSearchEnabled?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
globalType?: T;
|
||||
|
|
@ -4098,6 +4191,7 @@ export interface TaskCreateCollectionExport {
|
|||
| 'contentPiece'
|
||||
| 'contentMedia'
|
||||
| 'archivePhoto'
|
||||
| 'faceSubject'
|
||||
| 'contentEvent'
|
||||
| 'recording'
|
||||
| 'recordingMedia'
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import { ContentPiece } from './collections/ContentPiece'
|
|||
import { ElectionCandidate } from './collections/ElectionCandidate'
|
||||
import { ElectionCandidateVote } from './collections/ElectionCandidateVote'
|
||||
import { ElectionTally } from './collections/ElectionTally'
|
||||
import { FaceSubject } from './collections/FaceSubject'
|
||||
import { GoogleCalendarSync } from './collections/GoogleCalendarSync'
|
||||
import { InternetSpeechMedia } from './collections/InternetSpeechMedia'
|
||||
import { Jingle } from './collections/Jingle'
|
||||
|
|
@ -139,6 +140,7 @@ export default buildConfig({
|
|||
ContentPiece,
|
||||
ContentMedia,
|
||||
ArchivePhoto,
|
||||
FaceSubject,
|
||||
ContentEvent,
|
||||
Recording,
|
||||
RecordingMedia,
|
||||
|
|
|
|||
41
src/utilities/boundedRequestBody.ts
Normal file
41
src/utilities/boundedRequestBody.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import 'server-only'
|
||||
|
||||
/**
|
||||
* Reads a request body with a hard byte ceiling, streaming and aborting as
|
||||
* soon as the ceiling is crossed — a chunked body without `content-length`
|
||||
* cannot make a route buffer unbounded input. `null` means "refused".
|
||||
*
|
||||
* Single owner of the bounded-read rule for the anonymous public JSON routes
|
||||
* (the content-events beacon and the selfie search); extracted from the beacon
|
||||
* route when it earned a second caller.
|
||||
*/
|
||||
export const readBoundedRequestBody = async (
|
||||
request: Request,
|
||||
maxBytes: number,
|
||||
): Promise<string | null> => {
|
||||
const body = request.body
|
||||
if (!body) return ''
|
||||
|
||||
const reader = body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let text = ''
|
||||
let bytes = 0
|
||||
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
bytes += value.byteLength
|
||||
if (bytes > maxBytes) {
|
||||
await reader.cancel().catch(() => undefined)
|
||||
return null
|
||||
}
|
||||
text += decoder.decode(value, { stream: true })
|
||||
}
|
||||
return text + decoder.decode()
|
||||
} catch {
|
||||
await reader.cancel().catch(() => undefined)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
@ -48,9 +48,15 @@ const cleanup = (now: number): void => {
|
|||
|
||||
/**
|
||||
* `true` when the event may be recorded. A null key (no trustworthy IP) and
|
||||
* any internal failure answer `true` — the fail-open contract.
|
||||
* any internal failure answer `true` — the fail-open contract. `budget`
|
||||
* defaults to the beacon's generous window; a stricter caller (the C234 selfie
|
||||
* search, which answers a membership oracle) passes its own ceiling while
|
||||
* sharing the same registry, hash and cleanup.
|
||||
*/
|
||||
export const checkContentEventRateLimit = (key: string | null): boolean => {
|
||||
export const checkContentEventRateLimit = (
|
||||
key: string | null,
|
||||
budget: number = MAX_EVENTS_PER_WINDOW,
|
||||
): boolean => {
|
||||
if (!key) return true
|
||||
|
||||
try {
|
||||
|
|
@ -63,7 +69,7 @@ export const checkContentEventRateLimit = (key: string | null): boolean => {
|
|||
store.set(key, { count: 1, resetAt: now + WINDOW_MS })
|
||||
return true
|
||||
}
|
||||
if (existing.count >= MAX_EVENTS_PER_WINDOW) return false
|
||||
if (existing.count >= budget) return false
|
||||
|
||||
existing.count += 1
|
||||
return true
|
||||
|
|
|
|||
102
src/utilities/faceSubjects/faceSubjectEnrollment.ts
Normal file
102
src/utilities/faceSubjects/faceSubjectEnrollment.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import 'server-only'
|
||||
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import { FACE_SEARCH_MODEL, readFaceVector } from '@/lib/faceSearch'
|
||||
|
||||
/**
|
||||
* C234 — writes of the subject lifecycle: enrollment (one row per person who
|
||||
* adhered to the search index — A/C scope of the gate) and the removal that
|
||||
* serves the person's own opt-out. Enrollment is called only by
|
||||
* `pnpm faces:enroll`, after the CLI has proven the selfie has exactly one
|
||||
* detectable face and resolved the enrollment Consent by its stable key; the
|
||||
* removal is called by the search endpoint after the request's descriptor
|
||||
* matched the subject (the face IS the authentication). Neither path takes the
|
||||
* descriptor from any other source and the row is never public.
|
||||
*
|
||||
* Re-enrollment (`subjectId`) replaces the descriptor, refreshes the consent
|
||||
* snapshot and clears the derived links: the previous matches belong to the
|
||||
* previous descriptor and must never survive it. `enrolledAt` also advances —
|
||||
* it is the batch's staleness key, so the next `faces:index` recomputes.
|
||||
*/
|
||||
export type FaceSubjectEnrollmentInput = {
|
||||
payload: Payload
|
||||
label: string
|
||||
descriptor: readonly number[]
|
||||
consent: { id: number; contentHash: string }
|
||||
subjectId?: number | null
|
||||
}
|
||||
|
||||
export type FaceSubjectEnrollment = {
|
||||
id: number
|
||||
created: boolean
|
||||
}
|
||||
|
||||
export const enrollFaceSubject = async ({
|
||||
payload,
|
||||
label,
|
||||
descriptor,
|
||||
consent,
|
||||
subjectId,
|
||||
}: FaceSubjectEnrollmentInput): Promise<FaceSubjectEnrollment> => {
|
||||
const vector = readFaceVector(descriptor)
|
||||
if (!vector) throw new Error('Descriptor inválido para enrollment.')
|
||||
|
||||
const data = {
|
||||
label,
|
||||
consent: consent.id,
|
||||
consentHash: consent.contentHash,
|
||||
model: FACE_SEARCH_MODEL,
|
||||
vector,
|
||||
enrolledAt: new Date().toISOString(),
|
||||
status: 'active' as const,
|
||||
removedAt: null,
|
||||
matchedPhotos: [] as number[],
|
||||
}
|
||||
|
||||
// Intentional bypass: the enrollment CLI is a trusted operator with no
|
||||
// session; the descriptor is validated above and never crosses the wire.
|
||||
const common = { depth: 0 as const, overrideAccess: true }
|
||||
|
||||
if (subjectId != null) {
|
||||
const updated = await payload.update({
|
||||
collection: 'faceSubject',
|
||||
id: subjectId,
|
||||
data,
|
||||
...common,
|
||||
})
|
||||
return { id: updated.id, created: false }
|
||||
}
|
||||
|
||||
const created = await payload.create({ collection: 'faceSubject', data, ...common })
|
||||
return { id: created.id, created: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Opt-out: the subject stops answering (the query checks `status`) and stops
|
||||
* holding biometrics (the vector and the derived links are erased). The row
|
||||
* itself stays as the attendance record. One row, one update — atomic by
|
||||
* itself, no transaction needed.
|
||||
*/
|
||||
export const removeFaceSubjectFromIndex = async ({
|
||||
payload,
|
||||
id,
|
||||
}: {
|
||||
payload: Payload
|
||||
id: number
|
||||
}): Promise<void> => {
|
||||
await payload.update({
|
||||
collection: 'faceSubject',
|
||||
id,
|
||||
data: {
|
||||
status: 'removed',
|
||||
vector: null,
|
||||
removedAt: new Date().toISOString(),
|
||||
matchedPhotos: [],
|
||||
},
|
||||
depth: 0,
|
||||
// Intentional bypass: the search endpoint is anonymous and the removal is
|
||||
// authorized by the matched descriptor, never by a session.
|
||||
overrideAccess: true,
|
||||
})
|
||||
}
|
||||
290
src/utilities/faceSubjects/faceSubjectPhotoIndex.ts
Normal file
290
src/utilities/faceSubjects/faceSubjectPhotoIndex.ts
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
import 'server-only'
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { Payload } from 'payload'
|
||||
import sharp from 'sharp'
|
||||
|
||||
import { ARCHIVE_PHOTO_SLUG } from '@/lib/archivePhoto'
|
||||
import {
|
||||
FACE_SEARCH_MAX_DISTANCE,
|
||||
FACE_SEARCH_MODEL,
|
||||
faceEuclideanDistance,
|
||||
readFaceVector,
|
||||
} from '@/lib/faceSearch'
|
||||
import {
|
||||
listFaceSubjects,
|
||||
type FaceSubjectSummary,
|
||||
} from '@/utilities/faceSubjects/faceSubjectReads'
|
||||
import { messageOf } from '@/utilities/media/ffmpeg'
|
||||
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
|
||||
import {
|
||||
downloadPrivateMediaToFile,
|
||||
resolvePrivateMediaStaticDir,
|
||||
} from '@/utilities/privateMedia/privateMediaResponse'
|
||||
|
||||
/**
|
||||
* C234 — the batch index of the selfie search (`pnpm faces:index`). For every
|
||||
* approved photo it downloads the private original, prepares a small RGB image
|
||||
* and asks the INJECTED analyzer (the same face-api engine the browser runs)
|
||||
* for the descriptors of every face. The descriptors are TRANSIENT — they are
|
||||
* compared against the enrolled subjects and thrown away; only the
|
||||
* photo↔subject links are persisted, inside one transaction per photo. That is
|
||||
* the A/C line of the gate: there is no anonymous face index anywhere.
|
||||
*
|
||||
* Skipping is honest by construction: the photo's `faces.checkedKey` records
|
||||
* the model plus the enrollment revision (`enrolledAt`) of every eligible
|
||||
* subject, so a new/updated/removed subject invalidates every photo and the
|
||||
* next run recomputes — while the batch's own writes never change the key.
|
||||
*
|
||||
* A failure of one photo is returned, never thrown: nothing is written, the
|
||||
* next run retries it and the receipt names the stage.
|
||||
*/
|
||||
|
||||
/** Long edge sent to the engine; the original never leaves the archive. */
|
||||
const IMAGE_MAX_EDGE = 1024
|
||||
|
||||
export type ArchivePhotoFaceImage = {
|
||||
pixels: Buffer
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
/** The engine seam: `src/` never imports `scripts/`; the CLI injects it. */
|
||||
export type ArchivePhotoFaceAnalyzer = (image: ArchivePhotoFaceImage) => Promise<number[][]>
|
||||
|
||||
/**
|
||||
* Decodes any stored image (EXIF-rotated, alpha flattened, ≤1024px raw RGB) —
|
||||
* the exact input both the batch and the enrollment CLI hand to the engine, so
|
||||
* the selfie and the archive enter the same space.
|
||||
*/
|
||||
export const prepareFaceImage = async (filePath: string): Promise<ArchivePhotoFaceImage> => {
|
||||
const prepared = await sharp(filePath)
|
||||
.rotate()
|
||||
.resize({ width: IMAGE_MAX_EDGE, height: IMAGE_MAX_EDGE, fit: 'inside' })
|
||||
.removeAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true })
|
||||
|
||||
return { pixels: prepared.data, width: prepared.info.width, height: prepared.info.height }
|
||||
}
|
||||
|
||||
export type ArchivePhotoFaceIndexItem = {
|
||||
id: number
|
||||
filename: string | null
|
||||
checkedKey: string | null
|
||||
}
|
||||
|
||||
export type ArchivePhotoFaceIndexQueue = {
|
||||
/** The revision every processed photo is stamped with. */
|
||||
indexKey: string
|
||||
/** Subjects the batch will look for (`active` + current model + vector). */
|
||||
eligibleSubjects: number
|
||||
totalApproved: number
|
||||
/** Approved photos whose `faces.checkedKey` is not the current revision. */
|
||||
stale: number
|
||||
/** The work list (newest first); capped by `limit` when asked. */
|
||||
items: ArchivePhotoFaceIndexItem[]
|
||||
}
|
||||
|
||||
export type ArchivePhotoFaceIndexResult =
|
||||
| { status: 'indexed'; matchedSubjects: number[] }
|
||||
| {
|
||||
status: 'failed'
|
||||
stage: 'download' | 'image' | 'analyze' | 'write'
|
||||
error: string
|
||||
}
|
||||
|
||||
const isIndexableSubject = (subject: FaceSubjectSummary): boolean =>
|
||||
subject.status === 'active' &&
|
||||
subject.model === FACE_SEARCH_MODEL &&
|
||||
readFaceVector(subject.vector) !== null
|
||||
|
||||
/**
|
||||
* The revision stamped on each processed photo: the model plus the enrollment
|
||||
* revision of every indexable subject. Deliberately ignores `updatedAt` (the
|
||||
* batch writes `matchedPhotos`, which would bump it and make every photo stale
|
||||
* forever) and ignores removed/stale subjects (they are not searched).
|
||||
*/
|
||||
const facePhotoIndexKey = (subjects: readonly FaceSubjectSummary[]): string => {
|
||||
const revisions = subjects
|
||||
.filter(isIndexableSubject)
|
||||
.map((subject) => `${subject.id}:${subject.enrolledAt ?? ''}`)
|
||||
.sort()
|
||||
|
||||
return createHash('sha256')
|
||||
.update([FACE_SEARCH_MODEL, ...revisions].join('\n'))
|
||||
.digest('hex')
|
||||
}
|
||||
|
||||
/**
|
||||
* The batch queue: the approved photos with their marker, the subjects that
|
||||
* will be searched, and the revision to stamp. `refresh` ignores the marker;
|
||||
* `limit` caps only the returned work list (the counts stay true).
|
||||
*/
|
||||
export const listArchivePhotoFaceIndexQueue = async ({
|
||||
payload,
|
||||
refresh = false,
|
||||
limit,
|
||||
}: {
|
||||
payload: Payload
|
||||
refresh?: boolean
|
||||
limit?: number
|
||||
}): Promise<ArchivePhotoFaceIndexQueue> => {
|
||||
const subjects = await listFaceSubjects(payload)
|
||||
const indexKey = facePhotoIndexKey(subjects)
|
||||
|
||||
const photos = await payload.find({
|
||||
collection: ARCHIVE_PHOTO_SLUG,
|
||||
where: { publicationStatus: { equals: 'approved' } },
|
||||
sort: ['-takenOn', '-id'],
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: { filename: true, faces: true },
|
||||
// Intentional bypass: the batch CLI is a trusted operator with no session.
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
const rows: ArchivePhotoFaceIndexItem[] = photos.docs.map((doc) => ({
|
||||
id: doc.id,
|
||||
filename: doc.filename ?? null,
|
||||
checkedKey: doc.faces?.checkedKey ?? null,
|
||||
}))
|
||||
const staleRows = refresh ? rows : rows.filter((row) => row.checkedKey !== indexKey)
|
||||
|
||||
return {
|
||||
indexKey,
|
||||
eligibleSubjects: subjects.filter(isIndexableSubject).length,
|
||||
totalApproved: photos.docs.length,
|
||||
stale: staleRows.length,
|
||||
items: limit && limit > 0 ? staleRows.slice(0, limit) : staleRows,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One photo: download → prepare → analyze → transactional link update + marker.
|
||||
* The fresh subject read happens INSIDE the transaction, so an enrollment that
|
||||
* lands mid-run is respected (the photo is simply reprocessed by the changed
|
||||
* key on a later run).
|
||||
*/
|
||||
export const indexArchivePhotoFaces = async ({
|
||||
payload,
|
||||
item,
|
||||
indexKey,
|
||||
analyze,
|
||||
}: {
|
||||
payload: Payload
|
||||
item: ArchivePhotoFaceIndexItem
|
||||
indexKey: string
|
||||
analyze: ArchivePhotoFaceAnalyzer
|
||||
}): Promise<ArchivePhotoFaceIndexResult> => {
|
||||
if (!item.filename) {
|
||||
return { status: 'failed', stage: 'download', error: 'Foto sem arquivo armazenado.' }
|
||||
}
|
||||
|
||||
const tempDir = await mkdtemp(join(tmpdir(), 'teqo-face-index-'))
|
||||
const imagePath = join(tempDir, 'original')
|
||||
|
||||
try {
|
||||
try {
|
||||
await downloadPrivateMediaToFile({
|
||||
media: { filename: item.filename },
|
||||
staticDir: resolvePrivateMediaStaticDir(payload, ARCHIVE_PHOTO_SLUG),
|
||||
destinationPath: imagePath,
|
||||
})
|
||||
} catch (error) {
|
||||
return { status: 'failed', stage: 'download', error: messageOf(error, 'erro desconhecido') }
|
||||
}
|
||||
|
||||
let image: ArchivePhotoFaceImage
|
||||
try {
|
||||
image = await prepareFaceImage(imagePath)
|
||||
} catch (error) {
|
||||
return { status: 'failed', stage: 'image', error: messageOf(error, 'erro desconhecido') }
|
||||
}
|
||||
|
||||
let descriptors: number[][]
|
||||
try {
|
||||
descriptors = await analyze(image)
|
||||
} catch (error) {
|
||||
return { status: 'failed', stage: 'analyze', error: messageOf(error, 'erro desconhecido') }
|
||||
}
|
||||
|
||||
try {
|
||||
const matchedSubjects = await writeArchivePhotoFaceMatches({
|
||||
payload,
|
||||
photoId: item.id,
|
||||
descriptors,
|
||||
indexKey,
|
||||
})
|
||||
return { status: 'indexed', matchedSubjects }
|
||||
} catch (error) {
|
||||
return { status: 'failed', stage: 'write', error: messageOf(error, 'erro desconhecido') }
|
||||
}
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true }).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the links of one photo for every eligible subject and stamps the
|
||||
* marker — one transaction with a fresh read, so a concurrent enrollment or
|
||||
* takedown never meets a half-written state.
|
||||
*/
|
||||
const writeArchivePhotoFaceMatches = async ({
|
||||
payload,
|
||||
photoId,
|
||||
descriptors,
|
||||
indexKey,
|
||||
}: {
|
||||
payload: Payload
|
||||
photoId: number
|
||||
descriptors: number[][]
|
||||
indexKey: string
|
||||
}): Promise<number[]> =>
|
||||
withPayloadTransaction(payload, async ({ req }) => {
|
||||
const subjects = await listFaceSubjects(payload, req)
|
||||
const matchedSubjects: number[] = []
|
||||
|
||||
for (const subject of subjects) {
|
||||
if (!isIndexableSubject(subject)) continue
|
||||
|
||||
const vector = readFaceVector(subject.vector)
|
||||
if (!vector) continue
|
||||
const matched = descriptors.some(
|
||||
(descriptor) => faceEuclideanDistance(descriptor, vector) < FACE_SEARCH_MAX_DISTANCE,
|
||||
)
|
||||
const linked = subject.matchedPhotoIds.includes(photoId)
|
||||
if (matched) matchedSubjects.push(subject.id)
|
||||
if (matched === linked) continue
|
||||
|
||||
const next = matched
|
||||
? [...subject.matchedPhotoIds, photoId]
|
||||
: subject.matchedPhotoIds.filter((id) => id !== photoId)
|
||||
await payload.update({
|
||||
collection: 'faceSubject',
|
||||
id: subject.id,
|
||||
data: { matchedPhotos: next },
|
||||
depth: 0,
|
||||
req,
|
||||
// Intentional bypass: the batch CLI is a trusted operator with no session.
|
||||
overrideAccess: true,
|
||||
})
|
||||
}
|
||||
|
||||
await payload.update({
|
||||
collection: ARCHIVE_PHOTO_SLUG,
|
||||
id: photoId,
|
||||
data: { faces: { checkedAt: new Date().toISOString(), checkedKey: indexKey } },
|
||||
depth: 0,
|
||||
req,
|
||||
context: { faceIndex: true },
|
||||
// Intentional bypass: the batch CLI is a trusted operator with no session.
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
return matchedSubjects
|
||||
})
|
||||
136
src/utilities/faceSubjects/faceSubjectReads.ts
Normal file
136
src/utilities/faceSubjects/faceSubjectReads.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import 'server-only'
|
||||
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import type { FaceSearchSubject } from '@/lib/faceSearch'
|
||||
|
||||
/**
|
||||
* C234 — server reads of the enrolled subjects behind the selfie search. The
|
||||
* collection is admin-only, so every read here is the Local API without a user
|
||||
* (the documented anonymous-read bypass, same precedent as
|
||||
* `archivePhotoReads`): the SELECTOR is the search request itself, never an
|
||||
* actor. The rows are few (one per adhered person) and the search must see the
|
||||
* live state — no cache, so an opt-out stops answering immediately.
|
||||
*/
|
||||
|
||||
/** A relationship value at depth 0 is the id; tolerate the populated shape. */
|
||||
const relationIds = (value: unknown): number[] => {
|
||||
if (!Array.isArray(value)) return []
|
||||
|
||||
const ids: number[] = []
|
||||
for (const entry of value) {
|
||||
if (typeof entry === 'number') {
|
||||
ids.push(entry)
|
||||
continue
|
||||
}
|
||||
if (entry && typeof entry === 'object' && 'id' in entry && typeof entry.id === 'number') {
|
||||
ids.push(entry.id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
export type FaceSubjectSummary = {
|
||||
id: number
|
||||
status: string | null
|
||||
model: string | null
|
||||
consentHash: string | null
|
||||
enrolledAt: string | null
|
||||
vector: unknown
|
||||
matchedPhotoIds: number[]
|
||||
}
|
||||
|
||||
type FaceSubjectDoc = {
|
||||
id: number
|
||||
status?: string | null
|
||||
model?: string | null
|
||||
consentHash?: string | null
|
||||
enrolledAt?: string | null
|
||||
vector?: unknown
|
||||
matchedPhotos?: unknown
|
||||
}
|
||||
|
||||
const toSummary = (doc: FaceSubjectDoc): FaceSubjectSummary => ({
|
||||
id: doc.id,
|
||||
status: doc.status ?? null,
|
||||
model: doc.model ?? null,
|
||||
consentHash: doc.consentHash ?? null,
|
||||
enrolledAt: doc.enrolledAt ?? null,
|
||||
vector: doc.vector,
|
||||
matchedPhotoIds: relationIds(doc.matchedPhotos),
|
||||
})
|
||||
|
||||
const faceSubjectSelect = {
|
||||
status: true,
|
||||
model: true,
|
||||
consentHash: true,
|
||||
enrolledAt: true,
|
||||
vector: true,
|
||||
matchedPhotos: true,
|
||||
} as const
|
||||
|
||||
/** Every subject (any status) — the CLI inventory and the batch's fresh read. */
|
||||
export const listFaceSubjects = async (
|
||||
payload: Payload,
|
||||
req?: { transactionID?: number | string },
|
||||
): Promise<FaceSubjectSummary[]> => {
|
||||
const result = await payload.find({
|
||||
collection: 'faceSubject',
|
||||
sort: ['id'],
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: faceSubjectSelect,
|
||||
// Intentional bypass: subjects are admin-only configuration and both the
|
||||
// search (anonymous request) and the CLI (no session) must read them.
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
|
||||
return result.docs.map((doc) => toSummary(doc as FaceSubjectDoc))
|
||||
}
|
||||
|
||||
/** Active subjects only — the candidate set of one search. */
|
||||
export const listSearchableFaceSubjects = async (
|
||||
payload: Payload,
|
||||
): Promise<FaceSearchSubject[]> => {
|
||||
const result = await payload.find({
|
||||
collection: 'faceSubject',
|
||||
where: { status: { equals: 'active' } },
|
||||
sort: ['id'],
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: { status: true, model: true, consentHash: true, vector: true },
|
||||
// Intentional bypass: the search endpoint answers anonymous requests and
|
||||
// the selector is the consent-gated query itself, never an actor.
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
return result.docs.map((doc) => ({
|
||||
id: doc.id,
|
||||
status: doc.status ?? null,
|
||||
model: doc.model ?? null,
|
||||
consentHash: doc.consentHash ?? null,
|
||||
vector: doc.vector,
|
||||
}))
|
||||
}
|
||||
|
||||
/** The approved photos linked to one subject (the search answer). */
|
||||
export const getFaceSubjectMatchedPhotoIds = async (
|
||||
payload: Payload,
|
||||
id: number,
|
||||
): Promise<number[]> => {
|
||||
const doc = await payload
|
||||
.findByID({
|
||||
collection: 'faceSubject',
|
||||
id,
|
||||
depth: 0,
|
||||
select: { matchedPhotos: true },
|
||||
// Intentional bypass: same anonymous-read rationale as the list above.
|
||||
overrideAccess: true,
|
||||
})
|
||||
.catch(() => null)
|
||||
|
||||
return relationIds((doc as FaceSubjectDoc | null)?.matchedPhotos)
|
||||
}
|
||||
444
tests/e2e/frontendFotosSelfie.e2e.spec.ts
Normal file
444
tests/e2e/frontendFotosSelfie.e2e.spec.ts
Normal file
|
|
@ -0,0 +1,444 @@
|
|||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
import type { APIRequestContext, Page } from '@playwright/test'
|
||||
import sharp from 'sharp'
|
||||
|
||||
import {
|
||||
FACE_INDEX_CONSENT_KEY,
|
||||
FACE_SEARCH_CONSENT_KEY,
|
||||
} from '../../src/lib/campaignConsentKeys.js'
|
||||
import { FACE_SEARCH_MODEL, faceStubDescriptorFromBytes } from '../../src/lib/faceSearch.js'
|
||||
import { hashConsentContent } from '../../src/utilities/consentContentHash.js'
|
||||
import { adminHeaders } from '../helpers/adminApi'
|
||||
import { seedTestUser } from '../helpers/seedUser'
|
||||
import { waitForStreamSettled } from './fixtures/campaignE2EFixtures'
|
||||
import { expect, test } from './fixtures/e2eTest'
|
||||
|
||||
/**
|
||||
* C234 — the selfie search over real HTTP: the entry gated by the album flag,
|
||||
* the Consent fail-closed, the A/C answer (only the enrolled subject's approved
|
||||
* photos, never a third-party name), the honest empty and the opt-out by
|
||||
* matched descriptor. The engine is the deterministic stub
|
||||
* (`NEXT_PUBLIC_FACE_SEARCH_STUB=1`): the fixture bytes derive the descriptor
|
||||
* the seeded subject carries, so no model is ever downloaded.
|
||||
*
|
||||
* Rows are seeded through the deployed REST API (admin session) so the server
|
||||
* process runs the real hooks. Serial: this spec owns its faceSubject/Consent
|
||||
* rows and flips the same `photoAlbum` global as the C233 spec (the project
|
||||
* depends on `frontendFotos`).
|
||||
*/
|
||||
|
||||
const BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:3000'
|
||||
const REMOVAL_CHANNEL = 'https://example.org/acervo/remocao'
|
||||
|
||||
type Headers = Record<string, string>
|
||||
|
||||
const createdPhotoIds: number[] = []
|
||||
const createdSubjectIds: number[] = []
|
||||
const createdConsentKeys = new Set<string>()
|
||||
|
||||
const uniqueMarker = () => randomUUID().slice(0, 8)
|
||||
|
||||
const fixtureConsentText = (label: string) => ({
|
||||
root: {
|
||||
type: 'root',
|
||||
children: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
children: [{ type: 'text', text: `Consentimento de teste C234 (${label}).`, version: 1 }],
|
||||
direction: null,
|
||||
format: '',
|
||||
indent: 0,
|
||||
version: 1,
|
||||
},
|
||||
],
|
||||
direction: null,
|
||||
format: '',
|
||||
indent: 0,
|
||||
version: 1,
|
||||
},
|
||||
})
|
||||
|
||||
const jpegWithFill = (marker: string): Promise<Buffer> => {
|
||||
const byte = Number.parseInt(marker.slice(0, 2), 16) % 200
|
||||
return sharp({
|
||||
create: {
|
||||
width: 96,
|
||||
height: 96,
|
||||
channels: 3,
|
||||
background: { r: 30 + byte, g: 120, b: 40 + (byte % 60) },
|
||||
},
|
||||
})
|
||||
.jpeg()
|
||||
.toBuffer()
|
||||
}
|
||||
|
||||
const setAlbum = async (
|
||||
request: APIRequestContext,
|
||||
headers: Headers,
|
||||
data: { published: boolean; removalChannelUrl: string | null; selfieSearchEnabled: boolean },
|
||||
): Promise<void> => {
|
||||
const response = await request.post(`${BASE_URL}/api/globals/photoAlbum`, { headers, data })
|
||||
expect(response.ok(), await response.text()).toBeTruthy()
|
||||
}
|
||||
|
||||
const createPhoto = async (
|
||||
request: APIRequestContext,
|
||||
headers: Headers,
|
||||
caption: string,
|
||||
): Promise<number> => {
|
||||
const marker = uniqueMarker()
|
||||
const response = await request.post(`${BASE_URL}/api/archivePhoto`, {
|
||||
headers,
|
||||
multipart: {
|
||||
_payload: JSON.stringify({
|
||||
flickrId: `c234-e2e-${marker}`,
|
||||
alt: `Foto de teste C234 ${marker}: ${caption}`,
|
||||
publicationStatus: 'approved',
|
||||
takenAt: '2026-09-12 10:20:30',
|
||||
catalog: { caption, people: ['Jorge Solla'] },
|
||||
}),
|
||||
file: {
|
||||
name: `c234-${marker}.jpg`,
|
||||
mimeType: 'image/jpeg',
|
||||
buffer: await jpegWithFill(marker),
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(response.ok(), await response.text()).toBeTruthy()
|
||||
const id = ((await response.json()) as { doc: { id: number } }).doc.id
|
||||
createdPhotoIds.push(id)
|
||||
return id
|
||||
}
|
||||
|
||||
const ensureConsent = async (
|
||||
request: APIRequestContext,
|
||||
headers: Headers,
|
||||
key: string,
|
||||
): Promise<{ id: number; text: unknown }> => {
|
||||
const existing = await request.get(
|
||||
`${BASE_URL}/api/consent?limit=1&depth=0&where[key][equals]=${encodeURIComponent(key)}`,
|
||||
{ headers },
|
||||
)
|
||||
expect(existing.ok(), await existing.text()).toBeTruthy()
|
||||
const found = ((await existing.json()) as { docs: { id: number; text: unknown }[] }).docs[0]
|
||||
if (found) return found
|
||||
|
||||
const created = await request.post(`${BASE_URL}/api/consent`, {
|
||||
headers,
|
||||
data: { key, text: fixtureConsentText(key) },
|
||||
})
|
||||
expect(created.ok(), await created.text()).toBeTruthy()
|
||||
createdConsentKeys.add(key)
|
||||
const doc = ((await created.json()) as { doc: { id: number; text: unknown } }).doc
|
||||
return { id: doc.id, text: doc.text }
|
||||
}
|
||||
|
||||
const createSubject = async (
|
||||
request: APIRequestContext,
|
||||
headers: Headers,
|
||||
data: { vector: number[]; consentId: number; consentHash: string; matchedPhotos: number[] },
|
||||
): Promise<number> => {
|
||||
const response = await request.post(`${BASE_URL}/api/faceSubject`, {
|
||||
headers,
|
||||
data: {
|
||||
label: `Pessoa de teste ${uniqueMarker()}`,
|
||||
consent: data.consentId,
|
||||
consentHash: data.consentHash,
|
||||
model: FACE_SEARCH_MODEL,
|
||||
vector: data.vector,
|
||||
enrolledAt: new Date().toISOString(),
|
||||
status: 'active',
|
||||
matchedPhotos: data.matchedPhotos,
|
||||
},
|
||||
})
|
||||
expect(response.ok(), await response.text()).toBeTruthy()
|
||||
const id = ((await response.json()) as { doc: { id: number } }).doc.id
|
||||
createdSubjectIds.push(id)
|
||||
return id
|
||||
}
|
||||
|
||||
const faceSubjectById = async (
|
||||
request: APIRequestContext,
|
||||
headers: Headers,
|
||||
id: number,
|
||||
): Promise<{ status: string; vector: unknown }> => {
|
||||
const response = await request.get(`${BASE_URL}/api/faceSubject/${id}?depth=0`, { headers })
|
||||
expect(response.ok(), await response.text()).toBeTruthy()
|
||||
return (await response.json()) as { status: string; vector: unknown }
|
||||
}
|
||||
|
||||
/** Consent card → pick screen with the given fixture, ready for the primary action. */
|
||||
const reachPickWithFixture = async (
|
||||
page: Page,
|
||||
fixture: { name: string; mimeType: string; buffer: Buffer },
|
||||
{ firstPick = false }: { firstPick?: boolean } = {},
|
||||
) => {
|
||||
if (firstPick) {
|
||||
await page.getByRole('checkbox').check()
|
||||
await page.getByRole('button', { name: 'Concordar e escolher minha selfie' }).click()
|
||||
}
|
||||
await page.locator('input[type="file"]').setInputFiles(fixture)
|
||||
}
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.describe('Frontend busca por selfie (C234)', () => {
|
||||
let headers: Headers
|
||||
let indexConsent: { id: number; text: unknown }
|
||||
let indexConsentHash: string
|
||||
let subjectId: number
|
||||
let linkedPhoto: number
|
||||
let draftLinkedPhoto: number
|
||||
let otherPhoto: number
|
||||
const marker = uniqueMarker()
|
||||
|
||||
type SelfieFixture = { name: string; mimeType: string; buffer: Buffer }
|
||||
const selfieA: SelfieFixture = {
|
||||
name: 'selfie-a.jpg',
|
||||
mimeType: 'image/jpeg',
|
||||
buffer: Buffer.alloc(0),
|
||||
}
|
||||
const selfieB: SelfieFixture = {
|
||||
name: 'selfie-b.jpg',
|
||||
mimeType: 'image/jpeg',
|
||||
buffer: Buffer.alloc(0),
|
||||
}
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
await seedTestUser()
|
||||
headers = await adminHeaders(request, BASE_URL)
|
||||
|
||||
selfieA.buffer = await jpegWithFill('11')
|
||||
selfieB.buffer = await jpegWithFill('77')
|
||||
|
||||
await ensureConsent(request, headers, FACE_SEARCH_CONSENT_KEY)
|
||||
indexConsent = await ensureConsent(request, headers, FACE_INDEX_CONSENT_KEY)
|
||||
indexConsentHash = hashConsentContent(indexConsent.text)
|
||||
|
||||
await setAlbum(request, headers, {
|
||||
published: true,
|
||||
removalChannelUrl: REMOVAL_CHANNEL,
|
||||
selfieSearchEnabled: true,
|
||||
})
|
||||
|
||||
linkedPhoto = await createPhoto(request, headers, `Plenária encontrada ${marker}`)
|
||||
otherPhoto = await createPhoto(request, headers, `Outra foto aprovada ${marker}`)
|
||||
draftLinkedPhoto = await createPhoto(request, headers, `Rascunho do índice ${marker}`)
|
||||
await request.patch(`${BASE_URL}/api/archivePhoto/${draftLinkedPhoto}`, {
|
||||
headers,
|
||||
data: { publicationStatus: 'draft' },
|
||||
})
|
||||
|
||||
subjectId = await createSubject(request, headers, {
|
||||
vector: faceStubDescriptorFromBytes(new Uint8Array(selfieA.buffer)),
|
||||
consentId: indexConsent.id,
|
||||
consentHash: indexConsentHash,
|
||||
matchedPhotos: [linkedPhoto, draftLinkedPhoto],
|
||||
})
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
const cleanupHeaders = await adminHeaders(request, BASE_URL).catch(() => null)
|
||||
if (cleanupHeaders) {
|
||||
for (const id of createdSubjectIds.splice(0)) {
|
||||
await request
|
||||
.delete(`${BASE_URL}/api/faceSubject/${id}`, { headers: cleanupHeaders })
|
||||
.catch(() => undefined)
|
||||
}
|
||||
for (const id of createdPhotoIds.splice(0)) {
|
||||
await request
|
||||
.delete(`${BASE_URL}/api/archivePhoto/${id}`, { headers: cleanupHeaders })
|
||||
.catch(() => undefined)
|
||||
}
|
||||
for (const key of createdConsentKeys) {
|
||||
const lookup = await request
|
||||
.get(
|
||||
`${BASE_URL}/api/consent?limit=1&depth=0&where[key][equals]=${encodeURIComponent(key)}`,
|
||||
{
|
||||
headers: cleanupHeaders,
|
||||
},
|
||||
)
|
||||
.catch(() => null)
|
||||
const doc = lookup ? ((await lookup.json()) as { docs: { id: number }[] }).docs[0] : null
|
||||
if (doc) {
|
||||
await request
|
||||
.delete(`${BASE_URL}/api/consent/${doc.id}`, { headers: cleanupHeaders })
|
||||
.catch(() => undefined)
|
||||
}
|
||||
}
|
||||
await setAlbum(request, cleanupHeaders, {
|
||||
published: true,
|
||||
removalChannelUrl: REMOVAL_CHANNEL,
|
||||
selfieSearchEnabled: false,
|
||||
}).catch(() => undefined)
|
||||
}
|
||||
})
|
||||
|
||||
test('gates the entry on the album flag and the flow on the explicit consent', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/fotos')
|
||||
await waitForStreamSettled(page)
|
||||
const entry = page.getByRole('link', { name: 'Começar busca por selfie' })
|
||||
await expect(entry).toBeVisible()
|
||||
await entry.click()
|
||||
await page.waitForURL('**/fotos/encontre')
|
||||
await waitForStreamSettled(page)
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Sua face é um dado biométrico sensível' }),
|
||||
).toBeVisible()
|
||||
const primary = page.getByRole('button', { name: 'Concordar e escolher minha selfie' })
|
||||
await expect(primary).toBeDisabled()
|
||||
await page.getByRole('checkbox').check()
|
||||
await expect(primary).toBeEnabled()
|
||||
})
|
||||
|
||||
test('finds only the approved photos of the matched subject, without third-party names', async ({
|
||||
page,
|
||||
}) => {
|
||||
// `slow` keeps the processing state on screen long enough to be asserted.
|
||||
await page.addInitScript(() => {
|
||||
;(window as unknown as { __faceSearchStub?: string }).__faceSearchStub = 'slow'
|
||||
})
|
||||
await page.goto('/fotos/encontre')
|
||||
await waitForStreamSettled(page)
|
||||
await reachPickWithFixture(page, selfieA, { firstPick: true })
|
||||
await page.getByRole('button', { name: 'Usar esta selfie' }).click()
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Procurando você nas fotos aprovadas…' }),
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Estas são as fotos em que encontramos você.' }),
|
||||
).toBeVisible()
|
||||
|
||||
const card = page.locator(`a[data-photo-id="${linkedPhoto}"]`)
|
||||
await expect(card).toBeVisible()
|
||||
await expect(card.getByText(`Plenária encontrada ${marker}`)).toBeVisible()
|
||||
// The draft is linked to the subject but is not public; the other approved
|
||||
// photo does not contain the enrolled face.
|
||||
await expect(page.locator(`a[data-photo-id="${draftLinkedPhoto}"]`)).toHaveCount(0)
|
||||
await expect(page.locator(`a[data-photo-id="${otherPhoto}"]`)).toHaveCount(0)
|
||||
// The result card never carries the album's "Quem aparece" line.
|
||||
await expect(card.getByText('Jorge Solla')).toHaveCount(0)
|
||||
await expect(page.getByText(/nota de semelhança/)).toBeVisible()
|
||||
})
|
||||
|
||||
test('shows the honest empty state for a face that is not in the index', async ({ page }) => {
|
||||
await page.goto('/fotos/encontre')
|
||||
await waitForStreamSettled(page)
|
||||
await reachPickWithFixture(page, selfieB, { firstPick: true })
|
||||
await page.getByRole('button', { name: 'Usar esta selfie' }).click()
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Não encontramos fotos suas' })).toBeVisible()
|
||||
await expect(page.getByText(/pessoas parecidas/)).toBeVisible()
|
||||
})
|
||||
|
||||
test('shows the no-match opt-out state with the configured removal channel', async ({ page }) => {
|
||||
await page.goto('/fotos/encontre')
|
||||
await waitForStreamSettled(page)
|
||||
// The presence screen is reached from the result band (the person is in the
|
||||
// index); the leave attempt then uses a selfie that does not match.
|
||||
await reachPickWithFixture(page, selfieA, { firstPick: true })
|
||||
await page.getByRole('button', { name: 'Usar esta selfie' }).click()
|
||||
await page.getByRole('button', { name: 'Gerenciar minha presença' }).click()
|
||||
await page.getByRole('button', { name: 'Continuar' }).click()
|
||||
await page.locator('input[type="file"]').setInputFiles(selfieB)
|
||||
await page.getByRole('button', { name: 'Continuar' }).click()
|
||||
await page.getByRole('button', { name: 'Confirmar saída do índice' }).click()
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Não encontramos sua presença no índice' }),
|
||||
).toBeVisible()
|
||||
const channel = page.getByRole('link', { name: 'Pedir remoção de uma foto' })
|
||||
await expect(channel).toBeVisible()
|
||||
await expect(channel).toHaveAttribute('href', REMOVAL_CHANNEL)
|
||||
})
|
||||
|
||||
test('serves the opt-out by matched descriptor and stops answering afterwards', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await page.goto('/fotos/encontre')
|
||||
await waitForStreamSettled(page)
|
||||
await reachPickWithFixture(page, selfieA, { firstPick: true })
|
||||
await page.getByRole('button', { name: 'Usar esta selfie' }).click()
|
||||
await page.getByRole('button', { name: 'Gerenciar minha presença' }).click()
|
||||
|
||||
await page.getByRole('button', { name: 'Continuar' }).click()
|
||||
await page.locator('input[type="file"]').setInputFiles(selfieA)
|
||||
await page.getByRole('button', { name: 'Continuar' }).click()
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Confirmar saída do índice?' })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Confirmar saída do índice' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Pronto — você saiu do índice' })).toBeVisible()
|
||||
|
||||
const subject = await faceSubjectById(request, headers, subjectId)
|
||||
expect(subject.status).toBe('removed')
|
||||
expect(subject.vector).toBeNull()
|
||||
})
|
||||
|
||||
test('fails the engine honestly when the local processing breaks', async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
;(window as unknown as { __faceSearchStub?: string }).__faceSearchStub = 'error'
|
||||
})
|
||||
await page.goto('/fotos/encontre')
|
||||
await waitForStreamSettled(page)
|
||||
await reachPickWithFixture(page, selfieA, { firstPick: true })
|
||||
await page.getByRole('button', { name: 'Usar esta selfie' }).click()
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Não foi possível processar a busca' }),
|
||||
).toBeVisible()
|
||||
await expect(page.getByText(/não foi enviada nem guardada/)).toBeVisible()
|
||||
})
|
||||
|
||||
test('closes the page when the query Consent disappears', async ({ page, request }) => {
|
||||
const lookup = await request.get(
|
||||
`${BASE_URL}/api/consent?limit=1&depth=0&where[key][equals]=${FACE_SEARCH_CONSENT_KEY}`,
|
||||
{ headers },
|
||||
)
|
||||
const doc = ((await lookup.json()) as { docs: { id: number }[] }).docs[0]
|
||||
expect(doc).toBeDefined()
|
||||
await request.delete(`${BASE_URL}/api/consent/${doc!.id}`, { headers })
|
||||
|
||||
try {
|
||||
await page.goto('/fotos/encontre')
|
||||
await waitForStreamSettled(page)
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'A busca por selfie está indisponível' }),
|
||||
).toBeVisible()
|
||||
} finally {
|
||||
await ensureConsent(request, headers, FACE_SEARCH_CONSENT_KEY)
|
||||
}
|
||||
})
|
||||
|
||||
test('removes the entry from the album when the feature flag is off', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await setAlbum(request, headers, {
|
||||
published: true,
|
||||
removalChannelUrl: REMOVAL_CHANNEL,
|
||||
selfieSearchEnabled: false,
|
||||
})
|
||||
try {
|
||||
await page.goto('/fotos')
|
||||
await waitForStreamSettled(page)
|
||||
await expect(page.getByRole('link', { name: 'Começar busca por selfie' })).toHaveCount(0)
|
||||
|
||||
// Through the API context on purpose: the browser would log the
|
||||
// document's own 404 and trip the console-error guard (same choice as
|
||||
// the C233 kill-switch test).
|
||||
expect((await request.get(`${BASE_URL}/fotos/encontre`)).status()).toBe(404)
|
||||
} finally {
|
||||
await setAlbum(request, headers, {
|
||||
published: true,
|
||||
removalChannelUrl: REMOVAL_CHANNEL,
|
||||
selfieSearchEnabled: true,
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
112
tests/helpers/faceIntFixtures.ts
Normal file
112
tests/helpers/faceIntFixtures.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdtemp, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import { ARCHIVE_PHOTO_SLUG, type ArchivePhotoImport } from '@/lib/archivePhoto'
|
||||
import { FACE_INDEX_CONSENT_KEY } from '@/lib/campaignConsentKeys'
|
||||
import { FACE_SEARCH_DESCRIPTOR_LENGTH } from '@/lib/faceSearch'
|
||||
import { getConsentByKey } from '@/utilities/campaignConsent'
|
||||
import { enrollFaceSubject } from '@/utilities/faceSubjects/faceSubjectEnrollment'
|
||||
import { ingestArchivePhoto } from '@/utilities/flickr/archivePhotoIngest'
|
||||
|
||||
import { ARCHIVE_PHOTO_JPEG_BYTES } from './archivePhotoFixture'
|
||||
|
||||
/**
|
||||
* C234 — the fixtures shared by the face-search int specs (subject/index and
|
||||
* the API): the file-backed archive photo over the real Payload test database
|
||||
* and the enrolled subject. The two spec files serialize on the same lease
|
||||
* because the index revision is derived state over EVERY enrolled subject — an
|
||||
* enrollment in the sibling spec changes the key and would legitimately mark
|
||||
* photos stale mid-assertion.
|
||||
*/
|
||||
export const FACE_SPECS_LEASE_KEY = 'c234-face-specs'
|
||||
export const FACE_INDEX_LEASE_KEY = 'c234-face-index-consent'
|
||||
export const FACE_SEARCH_LEASE_KEY = 'c234-face-search-consent'
|
||||
|
||||
export const faceTestVector = (value: number): number[] =>
|
||||
Array.from({ length: FACE_SEARCH_DESCRIPTOR_LENGTH }, () => value)
|
||||
|
||||
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: null,
|
||||
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,
|
||||
})
|
||||
|
||||
/**
|
||||
* Ingests one archive photo fixture and optionally approves it (approval needs
|
||||
* the album's removal channel to be configured by the caller).
|
||||
*/
|
||||
export const createFaceTestPhoto = async ({
|
||||
payload,
|
||||
approved,
|
||||
prefix,
|
||||
trackedFlickrIds,
|
||||
tempDirs,
|
||||
}: {
|
||||
payload: Payload
|
||||
approved: boolean
|
||||
prefix: string
|
||||
trackedFlickrIds: Set<string>
|
||||
tempDirs: string[]
|
||||
}): Promise<{ id: number; flickrId: string }> => {
|
||||
const flickrId = `${prefix}-${randomUUID().slice(0, 8)}`
|
||||
trackedFlickrIds.add(flickrId)
|
||||
const dir = await mkdtemp(join(tmpdir(), `${prefix}-photo-`))
|
||||
tempDirs.push(dir)
|
||||
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') throw new Error(`fixture not created: ${created.status}`)
|
||||
|
||||
if (approved) {
|
||||
await payload.update({
|
||||
collection: ARCHIVE_PHOTO_SLUG,
|
||||
id: created.id,
|
||||
data: { publicationStatus: 'approved' },
|
||||
overrideAccess: true,
|
||||
})
|
||||
}
|
||||
return { id: created.id, flickrId }
|
||||
}
|
||||
|
||||
/** Enrolls a subject with the current index Consent resolved by stable key. */
|
||||
export const enrollFaceTestSubject = async ({
|
||||
payload,
|
||||
label,
|
||||
vector,
|
||||
trackedSubjectIds,
|
||||
}: {
|
||||
payload: Payload
|
||||
label: string
|
||||
vector: number[]
|
||||
trackedSubjectIds: Set<number>
|
||||
}): Promise<number> => {
|
||||
const consent = await getConsentByKey(payload, FACE_INDEX_CONSENT_KEY)
|
||||
if (!consent) throw new Error('index consent not configured')
|
||||
|
||||
const enrolled = await enrollFaceSubject({
|
||||
payload,
|
||||
label,
|
||||
descriptor: vector,
|
||||
consent: { id: consent.id, contentHash: consent.contentHash },
|
||||
})
|
||||
trackedSubjectIds.add(enrolled.id)
|
||||
return enrolled.id
|
||||
}
|
||||
|
|
@ -126,6 +126,17 @@ export const purgeOrphanedConsentRenames = async (
|
|||
export const CAMPAIGN_INVITE_CONSENT_LEASE_KEY = 'campaign-invite-consent'
|
||||
export const SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY = 'supporter-registration-consent'
|
||||
export const SUPPORTER_VOTE_INTENTION_CONSENT_LEASE_KEY = 'supporter-vote-intention-consent'
|
||||
|
||||
/**
|
||||
* Serializes spec files that write the `photoAlbum` global. A Payload global is
|
||||
* a single row created by the first write; two parallel spec files writing a
|
||||
* missing global race the creation and the table ends with TWO rows — every
|
||||
* later read then picks one of them by `created_at` and the specs disagree
|
||||
* about the state (observed when the C233 album spec and the C234 face specs
|
||||
* ran in parallel).
|
||||
*/
|
||||
export const PHOTO_ALBUM_LEASE_KEY = 'photo-album-global'
|
||||
|
||||
/**
|
||||
* Serializes spec files that treat the election collections as test-owned:
|
||||
* `electionResultsImport.int.spec.ts` wipes ALL election rows, which would
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import { ingestArchivePhoto } from '@/utilities/flickr/archivePhotoIngest'
|
|||
|
||||
import { ARCHIVE_PHOTO_JPEG_BYTES } from '../helpers/archivePhotoFixture'
|
||||
import { installCampaignFixtures } from '../helpers/campaignFixtures'
|
||||
import { PHOTO_ALBUM_LEASE_KEY, withExclusiveTestDatabaseLease } from '../helpers/testDatabaseLease'
|
||||
|
||||
// C233 — the public album boundary over the real Payload `teqo_test`: the
|
||||
// approve/draft/removed gate, the sticky removal, the removal-channel guard,
|
||||
|
|
@ -78,21 +79,28 @@ const createPhoto = async (): Promise<{ id: number; flickrId: string }> => {
|
|||
return { id: created.id, flickrId }
|
||||
}
|
||||
|
||||
// The `photoAlbum` global is a single row created by the first write: every
|
||||
// spec file that writes it serializes on the shared lease so parallel forks
|
||||
// cannot race the creation into two rows (C234 specs write it too).
|
||||
const setChannel = async (removalChannelUrl: string): Promise<void> => {
|
||||
await payload.updateGlobal({
|
||||
slug: 'photoAlbum',
|
||||
data: { published: true, removalChannelUrl },
|
||||
overrideAccess: true,
|
||||
})
|
||||
await withExclusiveTestDatabaseLease(payload, PHOTO_ALBUM_LEASE_KEY, () =>
|
||||
payload.updateGlobal({
|
||||
slug: 'photoAlbum',
|
||||
data: { published: true, removalChannelUrl },
|
||||
overrideAccess: true,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/** Published is false here on purpose: the guard refuses an open album with no channel. */
|
||||
const closeAlbumAndClearChannel = async (): Promise<void> => {
|
||||
await payload.updateGlobal({
|
||||
slug: 'photoAlbum',
|
||||
data: { published: false, removalChannelUrl: null },
|
||||
overrideAccess: true,
|
||||
})
|
||||
await withExclusiveTestDatabaseLease(payload, PHOTO_ALBUM_LEASE_KEY, () =>
|
||||
payload.updateGlobal({
|
||||
slug: 'photoAlbum',
|
||||
data: { published: false, removalChannelUrl: null },
|
||||
overrideAccess: true,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const approve = async (id: number): Promise<void> => {
|
||||
|
|
|
|||
286
tests/int/faceSearchApi.int.spec.ts
Normal file
286
tests/int/faceSearchApi.int.spec.ts
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { rm } from 'node:fs/promises'
|
||||
import type { Payload } from 'payload'
|
||||
import { getPayload } from 'payload'
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('next/cache', () => ({
|
||||
revalidatePath: () => undefined,
|
||||
revalidateTag: vi.fn(),
|
||||
unstable_cache: (fn: unknown) => fn,
|
||||
}))
|
||||
|
||||
import { POST as postSelfie } from '@/app/(frontend)/api/fotos/selfie/route'
|
||||
import { ARCHIVE_PHOTO_SLUG } from '@/lib/archivePhoto'
|
||||
import { FACE_INDEX_CONSENT_KEY, FACE_SEARCH_CONSENT_KEY } from '@/lib/campaignConsentKeys'
|
||||
import config from '@/payload.config'
|
||||
import { removeFaceSubjectFromIndex } from '@/utilities/faceSubjects/faceSubjectEnrollment'
|
||||
import {
|
||||
indexArchivePhotoFaces,
|
||||
listArchivePhotoFaceIndexQueue,
|
||||
} from '@/utilities/faceSubjects/faceSubjectPhotoIndex'
|
||||
|
||||
import { installCampaignFixtures } from '../helpers/campaignFixtures'
|
||||
import {
|
||||
createFaceTestPhoto,
|
||||
enrollFaceTestSubject,
|
||||
FACE_INDEX_LEASE_KEY,
|
||||
FACE_SEARCH_LEASE_KEY,
|
||||
FACE_SPECS_LEASE_KEY,
|
||||
faceTestVector,
|
||||
} from '../helpers/faceIntFixtures'
|
||||
import {
|
||||
acquireTestDatabaseLease,
|
||||
ensureLeasedConsent,
|
||||
PHOTO_ALBUM_LEASE_KEY,
|
||||
withExclusiveTestDatabaseLease,
|
||||
} from '../helpers/testDatabaseLease'
|
||||
|
||||
// C234 — the anonymous selfie endpoint over the real Payload `teqo_test`:
|
||||
// the kill switch and the Consent fail-closed layers, the A/C answer (only
|
||||
// enrolled subjects, only approved photos, never a score or a third-party
|
||||
// name), the opt-out by matched descriptor and the anonymous anti-abuse.
|
||||
|
||||
let payload: Payload
|
||||
const createdFlickrIds = new Set<string>()
|
||||
const createdSubjectIds = new Set<number>()
|
||||
const tempDirs: string[] = []
|
||||
|
||||
installCampaignFixtures({
|
||||
getPayload: () => payload,
|
||||
setPayload: (nextPayload) => {
|
||||
payload = nextPayload
|
||||
},
|
||||
})
|
||||
|
||||
const CHANNEL = 'https://jorge.solla.example.org/remocao'
|
||||
|
||||
const createPhoto = (approved: boolean) =>
|
||||
createFaceTestPhoto({
|
||||
payload,
|
||||
approved,
|
||||
prefix: 'c234-api',
|
||||
trackedFlickrIds: createdFlickrIds,
|
||||
tempDirs,
|
||||
})
|
||||
|
||||
const enrollFixtureSubject = (label: string, vector: number[]) =>
|
||||
enrollFaceTestSubject({ payload, label, vector, trackedSubjectIds: createdSubjectIds })
|
||||
|
||||
const indexPhotoForSubject = async (photoId: number, vector: number[]): Promise<void> => {
|
||||
const queue = await listArchivePhotoFaceIndexQueue({ payload, refresh: true })
|
||||
const result = await indexArchivePhotoFaces({
|
||||
payload,
|
||||
item: queue.items.find((item) => item.id === photoId)!,
|
||||
indexKey: queue.indexKey,
|
||||
analyze: async () => [vector],
|
||||
})
|
||||
if (result.status !== 'indexed') throw new Error(`index fixture failed: ${result.status}`)
|
||||
}
|
||||
|
||||
const setSelfieSearchEnabled = async (enabled: boolean): Promise<void> => {
|
||||
await withExclusiveTestDatabaseLease(payload, PHOTO_ALBUM_LEASE_KEY, () =>
|
||||
payload.updateGlobal({
|
||||
slug: 'photoAlbum',
|
||||
data: { published: true, removalChannelUrl: CHANNEL, selfieSearchEnabled: enabled },
|
||||
overrideAccess: true,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const post = (body: unknown, headers: Record<string, string> = {}): Promise<Response> =>
|
||||
postSelfie(
|
||||
new Request('http://localhost/api/fotos/selfie', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', ...headers },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
)
|
||||
|
||||
describe('selfie search endpoint (C234)', () => {
|
||||
let specsLease: Awaited<ReturnType<typeof acquireTestDatabaseLease>>
|
||||
|
||||
beforeAll(async () => {
|
||||
payload = await getPayload({ config: await config })
|
||||
specsLease = await acquireTestDatabaseLease(payload, FACE_SPECS_LEASE_KEY)
|
||||
await ensureLeasedConsent(payload, {
|
||||
consentKey: FACE_SEARCH_CONSENT_KEY,
|
||||
leaseKey: FACE_SEARCH_LEASE_KEY,
|
||||
})
|
||||
await ensureLeasedConsent(payload, {
|
||||
consentKey: FACE_INDEX_CONSENT_KEY,
|
||||
leaseKey: FACE_INDEX_LEASE_KEY,
|
||||
})
|
||||
await setSelfieSearchEnabled(false)
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
for (const flickrId of createdFlickrIds) {
|
||||
await payload.delete({
|
||||
collection: ARCHIVE_PHOTO_SLUG,
|
||||
where: { flickrId: { equals: flickrId } },
|
||||
overrideAccess: true,
|
||||
})
|
||||
}
|
||||
for (const id of createdSubjectIds) {
|
||||
await payload.delete({ collection: 'faceSubject', id, overrideAccess: true })
|
||||
}
|
||||
await setSelfieSearchEnabled(false)
|
||||
await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true })))
|
||||
await specsLease.release()
|
||||
})
|
||||
|
||||
it('answers closed (404) while the album kill switch is off, before anything else', async () => {
|
||||
const response = await post({ vector: faceTestVector(0), intent: 'search' })
|
||||
expect(response.status).toBe(404)
|
||||
await expect(response.json()).resolves.toEqual({ ok: false, error: 'closed' })
|
||||
})
|
||||
|
||||
it('refuses a cross-origin request and a malformed body', async () => {
|
||||
const crossOrigin = await post(
|
||||
{ vector: faceTestVector(0), intent: 'search' },
|
||||
{ origin: 'https://malicioso.example' },
|
||||
)
|
||||
expect(crossOrigin.status).toBe(403)
|
||||
|
||||
await setSelfieSearchEnabled(true)
|
||||
const shortVector = await post({ vector: [0, 1], intent: 'search' })
|
||||
expect(shortVector.status).toBe(400)
|
||||
|
||||
const badIntent = await post({ vector: faceTestVector(0), intent: 'enroll' })
|
||||
expect(badIntent.status).toBe(400)
|
||||
|
||||
const extraKey = await post({ vector: faceTestVector(0), intent: 'search', image: 'x' })
|
||||
expect(extraKey.status).toBe(400)
|
||||
|
||||
const notJson = await postSelfie(
|
||||
new Request('http://localhost/api/fotos/selfie', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: '{nope',
|
||||
}),
|
||||
)
|
||||
expect(notJson.status).toBe(400)
|
||||
await setSelfieSearchEnabled(false)
|
||||
})
|
||||
|
||||
it('fails closed (503) while the query Consent is not configured', async () => {
|
||||
await setSelfieSearchEnabled(true)
|
||||
try {
|
||||
// The C234 specs lease already serializes every writer of this key, so
|
||||
// the absence window is exclusive by construction.
|
||||
await payload.delete({
|
||||
collection: 'consent',
|
||||
where: { key: { equals: FACE_SEARCH_CONSENT_KEY } },
|
||||
overrideAccess: true,
|
||||
})
|
||||
const response = await post({ vector: faceTestVector(0), intent: 'search' })
|
||||
expect(response.status).toBe(503)
|
||||
await expect(response.json()).resolves.toEqual({ ok: false, error: 'consent' })
|
||||
} finally {
|
||||
await ensureLeasedConsent(payload, {
|
||||
consentKey: FACE_SEARCH_CONSENT_KEY,
|
||||
leaseKey: FACE_SEARCH_LEASE_KEY,
|
||||
})
|
||||
await setSelfieSearchEnabled(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('returns only the approved photos of the matched subject, without names or score', async () => {
|
||||
const vector = faceTestVector(0)
|
||||
const subject = await enrollFixtureSubject('Pessoa encontrada', vector)
|
||||
const approved = await createPhoto(true)
|
||||
const draft = await createPhoto(false)
|
||||
await indexPhotoForSubject(approved.id, vector)
|
||||
|
||||
// A draft linked by hand must never surface: the endpoint intersects the
|
||||
// subject's links with the approved public read.
|
||||
await payload.update({
|
||||
collection: 'faceSubject',
|
||||
id: subject,
|
||||
data: { matchedPhotos: [approved.id, draft.id] },
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
await setSelfieSearchEnabled(true)
|
||||
const response = await post({ vector, intent: 'search' })
|
||||
expect(response.status).toBe(200)
|
||||
|
||||
const body = (await response.json()) as {
|
||||
ok: boolean
|
||||
found: boolean
|
||||
photos: { id: number }[]
|
||||
}
|
||||
expect(body.ok).toBe(true)
|
||||
expect(body.found).toBe(true)
|
||||
expect(body.photos.map((photo) => photo.id)).toEqual([approved.id])
|
||||
expect(JSON.stringify(body)).not.toMatch(/"people"|"score"|"distance"|"similarity"/i)
|
||||
|
||||
// A face nobody enrolled in the index gets the honest empty answer.
|
||||
const stranger = await post({ vector: faceTestVector(1), intent: 'search' })
|
||||
await expect(stranger.json()).resolves.toEqual({ ok: true, found: false })
|
||||
|
||||
await setSelfieSearchEnabled(false)
|
||||
await removeFaceSubjectFromIndex({ payload, id: subject })
|
||||
})
|
||||
|
||||
it('serves the opt-out by matched descriptor and stops answering afterwards', async () => {
|
||||
const vector = faceTestVector(0.25)
|
||||
const subject = await enrollFixtureSubject('Sai pelo endpoint', vector)
|
||||
const approved = await createPhoto(true)
|
||||
await indexPhotoForSubject(approved.id, vector)
|
||||
|
||||
await setSelfieSearchEnabled(true)
|
||||
const before = await post({ vector, intent: 'search' })
|
||||
await expect(before.json()).resolves.toMatchObject({ found: true })
|
||||
|
||||
const leave = await post({ vector, intent: 'leave-index' })
|
||||
await expect(leave.json()).resolves.toEqual({ ok: true, removed: true })
|
||||
|
||||
const stored = await payload.findByID({
|
||||
collection: 'faceSubject',
|
||||
id: subject,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(stored.status).toBe('removed')
|
||||
expect(stored.vector).toBeNull()
|
||||
|
||||
const after = await post({ vector, intent: 'search' })
|
||||
await expect(after.json()).resolves.toEqual({ ok: true, found: false })
|
||||
|
||||
// A descriptor that matches nobody cannot remove anybody.
|
||||
const nobody = await post({ vector: faceTestVector(1), intent: 'leave-index' })
|
||||
await expect(nobody.json()).resolves.toEqual({ ok: true, removed: false })
|
||||
|
||||
await setSelfieSearchEnabled(false)
|
||||
})
|
||||
|
||||
it('throttles the anonymous client well before the beacon budget', async () => {
|
||||
const headers = { 'x-forwarded-for': '198.51.100.77' }
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
const response = await post({ vector: faceTestVector(0), intent: 'search' }, headers)
|
||||
expect(response.status).toBe(404)
|
||||
}
|
||||
const throttled = await post({ vector: faceTestVector(0), intent: 'search' }, headers)
|
||||
expect(throttled.status).toBe(429)
|
||||
await expect(throttled.json()).resolves.toEqual({ ok: false, error: 'rate-limited' })
|
||||
})
|
||||
|
||||
it('keeps the model contract: only the current model can match', async () => {
|
||||
const vector = faceTestVector(0.75)
|
||||
const subject = await enrollFixtureSubject('Modelo trocado', vector)
|
||||
await payload.update({
|
||||
collection: 'faceSubject',
|
||||
id: subject,
|
||||
data: { model: 'outro-modelo@0' },
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
await setSelfieSearchEnabled(true)
|
||||
const response = await post({ vector, intent: 'search' })
|
||||
await expect(response.json()).resolves.toEqual({ ok: true, found: false })
|
||||
await setSelfieSearchEnabled(false)
|
||||
})
|
||||
})
|
||||
249
tests/int/faceSubjectIndex.int.spec.ts
Normal file
249
tests/int/faceSubjectIndex.int.spec.ts
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { rm } from 'node:fs/promises'
|
||||
import type { Payload } from 'payload'
|
||||
import { getPayload } from 'payload'
|
||||
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 } from '@/lib/archivePhoto'
|
||||
import { FACE_INDEX_CONSENT_KEY } from '@/lib/campaignConsentKeys'
|
||||
import { FACE_SEARCH_DESCRIPTOR_LENGTH, FACE_SEARCH_MODEL } from '@/lib/faceSearch'
|
||||
import config from '@/payload.config'
|
||||
import { getConsentByKey } from '@/utilities/campaignConsent'
|
||||
import { removeFaceSubjectFromIndex } from '@/utilities/faceSubjects/faceSubjectEnrollment'
|
||||
import {
|
||||
indexArchivePhotoFaces,
|
||||
listArchivePhotoFaceIndexQueue,
|
||||
} from '@/utilities/faceSubjects/faceSubjectPhotoIndex'
|
||||
import {
|
||||
getFaceSubjectMatchedPhotoIds,
|
||||
listSearchableFaceSubjects,
|
||||
} from '@/utilities/faceSubjects/faceSubjectReads'
|
||||
|
||||
import { installCampaignFixtures } from '../helpers/campaignFixtures'
|
||||
import {
|
||||
createFaceTestPhoto,
|
||||
enrollFaceTestSubject,
|
||||
FACE_INDEX_LEASE_KEY,
|
||||
FACE_SPECS_LEASE_KEY,
|
||||
faceTestVector,
|
||||
} from '../helpers/faceIntFixtures'
|
||||
import {
|
||||
acquireTestDatabaseLease,
|
||||
ensureLeasedConsent,
|
||||
PHOTO_ALBUM_LEASE_KEY,
|
||||
withExclusiveTestDatabaseLease,
|
||||
} from '../helpers/testDatabaseLease'
|
||||
|
||||
// C234 — the subject/index boundary over the real Payload `teqo_test`: the
|
||||
// enrollment write, the batch's transactional link replacement, the marker
|
||||
// that makes the run resumable, the opt-out that erases the descriptor and the
|
||||
// A/C rule that only enrolled subjects are ever searched.
|
||||
|
||||
let payload: Payload
|
||||
const createdFlickrIds = new Set<string>()
|
||||
const createdSubjectIds = new Set<number>()
|
||||
const tempDirs: string[] = []
|
||||
|
||||
installCampaignFixtures({
|
||||
getPayload: () => payload,
|
||||
setPayload: (nextPayload) => {
|
||||
payload = nextPayload
|
||||
},
|
||||
})
|
||||
|
||||
const CHANNEL = 'https://jorge.solla.example.org/remocao'
|
||||
|
||||
const createApprovedPhoto = () =>
|
||||
createFaceTestPhoto({
|
||||
payload,
|
||||
approved: true,
|
||||
prefix: 'c234',
|
||||
trackedFlickrIds: createdFlickrIds,
|
||||
tempDirs,
|
||||
})
|
||||
|
||||
const enrollFixtureSubject = (label: string, vector: number[]) =>
|
||||
enrollFaceTestSubject({ payload, label, vector, trackedSubjectIds: createdSubjectIds })
|
||||
|
||||
const markerOf = async (
|
||||
id: number,
|
||||
): Promise<{ checkedAt?: string | null; checkedKey?: string | null }> => {
|
||||
const doc = await payload.findByID({
|
||||
collection: ARCHIVE_PHOTO_SLUG,
|
||||
id,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
return doc.faces ?? {}
|
||||
}
|
||||
|
||||
describe('face subject enrollment and archive index (C234)', () => {
|
||||
let specsLease: Awaited<ReturnType<typeof acquireTestDatabaseLease>>
|
||||
|
||||
beforeAll(async () => {
|
||||
payload = await getPayload({ config: await config })
|
||||
specsLease = await acquireTestDatabaseLease(payload, FACE_SPECS_LEASE_KEY)
|
||||
await ensureLeasedConsent(payload, {
|
||||
consentKey: FACE_INDEX_CONSENT_KEY,
|
||||
leaseKey: FACE_INDEX_LEASE_KEY,
|
||||
})
|
||||
await withExclusiveTestDatabaseLease(payload, PHOTO_ALBUM_LEASE_KEY, () =>
|
||||
payload.updateGlobal({
|
||||
slug: 'photoAlbum',
|
||||
data: { published: true, removalChannelUrl: CHANNEL },
|
||||
overrideAccess: true,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
for (const flickrId of createdFlickrIds) {
|
||||
await payload.delete({
|
||||
collection: ARCHIVE_PHOTO_SLUG,
|
||||
where: { flickrId: { equals: flickrId } },
|
||||
overrideAccess: true,
|
||||
})
|
||||
}
|
||||
for (const id of createdSubjectIds) {
|
||||
await payload.delete({ collection: 'faceSubject', id, overrideAccess: true })
|
||||
}
|
||||
await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true })))
|
||||
await specsLease.release()
|
||||
})
|
||||
|
||||
it('enrolls a subject with the current model and consent snapshot', async () => {
|
||||
const consent = await getConsentByKey(payload, FACE_INDEX_CONSENT_KEY)
|
||||
const id = await enrollFixtureSubject('Pessoa de teste', faceTestVector(0))
|
||||
|
||||
const stored = await payload.findByID({
|
||||
collection: 'faceSubject',
|
||||
id,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(stored.status).toBe('active')
|
||||
expect(stored.model).toBe(FACE_SEARCH_MODEL)
|
||||
expect(stored.consentHash).toBe(consent?.contentHash)
|
||||
expect(stored.vector).toHaveLength(FACE_SEARCH_DESCRIPTOR_LENGTH)
|
||||
expect(stored.enrolledAt).toBeTruthy()
|
||||
|
||||
const searchable = await listSearchableFaceSubjects(payload)
|
||||
expect(searchable.map((subject) => subject.id)).toContain(id)
|
||||
})
|
||||
|
||||
it('links an approved photo to the enrolled subject and stamps the marker', async () => {
|
||||
const owner = await enrollFixtureSubject('Dona da foto', faceTestVector(0))
|
||||
const photo = await createApprovedPhoto()
|
||||
|
||||
const queue = await listArchivePhotoFaceIndexQueue({ payload })
|
||||
expect(queue.eligibleSubjects).toBeGreaterThanOrEqual(1)
|
||||
expect(queue.items.map((item) => item.id)).toContain(photo.id)
|
||||
|
||||
const result = await indexArchivePhotoFaces({
|
||||
payload,
|
||||
item: queue.items.find((item) => item.id === photo.id)!,
|
||||
indexKey: queue.indexKey,
|
||||
analyze: async () => [faceTestVector(0)],
|
||||
})
|
||||
expect(result).toEqual({ status: 'indexed', matchedSubjects: expect.arrayContaining([owner]) })
|
||||
expect(await getFaceSubjectMatchedPhotoIds(payload, owner)).toContain(photo.id)
|
||||
|
||||
const marker = await markerOf(photo.id)
|
||||
expect(marker.checkedKey).toBe(queue.indexKey)
|
||||
expect(marker.checkedAt).toBeTruthy()
|
||||
|
||||
// The marker now spares the photo on the next run.
|
||||
const nextQueue = await listArchivePhotoFaceIndexQueue({ payload })
|
||||
expect(nextQueue.items.map((item) => item.id)).not.toContain(photo.id)
|
||||
const refreshed = await listArchivePhotoFaceIndexQueue({ payload, refresh: true })
|
||||
expect(refreshed.items.map((item) => item.id)).toContain(photo.id)
|
||||
})
|
||||
|
||||
it('removes a link that stopped matching and re-adds it when the face matches again', async () => {
|
||||
const subject = await enrollFixtureSubject('Rosto mutável', faceTestVector(0))
|
||||
const photo = await createApprovedPhoto()
|
||||
const queue = await listArchivePhotoFaceIndexQueue({ payload, refresh: true })
|
||||
|
||||
const indexed = await indexArchivePhotoFaces({
|
||||
payload,
|
||||
item: queue.items.find((item) => item.id === photo.id)!,
|
||||
indexKey: queue.indexKey,
|
||||
analyze: async () => [faceTestVector(0)],
|
||||
})
|
||||
expect(indexed.status).toBe('indexed')
|
||||
expect(await getFaceSubjectMatchedPhotoIds(payload, subject)).toContain(photo.id)
|
||||
|
||||
const removed = await indexArchivePhotoFaces({
|
||||
payload,
|
||||
item: queue.items.find((item) => item.id === photo.id)!,
|
||||
indexKey: queue.indexKey,
|
||||
analyze: async () => [faceTestVector(1)],
|
||||
})
|
||||
expect(removed.status).toBe('indexed')
|
||||
expect(await getFaceSubjectMatchedPhotoIds(payload, subject)).not.toContain(photo.id)
|
||||
|
||||
await indexArchivePhotoFaces({
|
||||
payload,
|
||||
item: queue.items.find((item) => item.id === photo.id)!,
|
||||
indexKey: queue.indexKey,
|
||||
analyze: async () => [faceTestVector(0)],
|
||||
})
|
||||
expect(await getFaceSubjectMatchedPhotoIds(payload, subject)).toContain(photo.id)
|
||||
})
|
||||
|
||||
it('changes the index key on a new enrollment so every photo is reprocessed', async () => {
|
||||
const queue = await listArchivePhotoFaceIndexQueue({ payload })
|
||||
await enrollFixtureSubject('Nova adesão', faceTestVector(0.5))
|
||||
const nextQueue = await listArchivePhotoFaceIndexQueue({ payload })
|
||||
expect(nextQueue.indexKey).not.toBe(queue.indexKey)
|
||||
})
|
||||
|
||||
it('erases the descriptor and the links on opt-out', async () => {
|
||||
const subject = await enrollFixtureSubject('Sai do índice', faceTestVector(0))
|
||||
const photo = await createApprovedPhoto()
|
||||
const queue = await listArchivePhotoFaceIndexQueue({ payload, refresh: true })
|
||||
await indexArchivePhotoFaces({
|
||||
payload,
|
||||
item: queue.items.find((item) => item.id === photo.id)!,
|
||||
indexKey: queue.indexKey,
|
||||
analyze: async () => [faceTestVector(0)],
|
||||
})
|
||||
expect(await getFaceSubjectMatchedPhotoIds(payload, subject)).toContain(photo.id)
|
||||
|
||||
await removeFaceSubjectFromIndex({ payload, id: subject })
|
||||
|
||||
const stored = await payload.findByID({
|
||||
collection: 'faceSubject',
|
||||
id: subject,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(stored.status).toBe('removed')
|
||||
expect(stored.vector).toBeNull()
|
||||
expect(stored.removedAt).toBeTruthy()
|
||||
expect(await getFaceSubjectMatchedPhotoIds(payload, subject)).toEqual([])
|
||||
|
||||
const searchable = await listSearchableFaceSubjects(payload)
|
||||
expect(searchable.map((candidate) => candidate.id)).not.toContain(subject)
|
||||
})
|
||||
|
||||
it('fails a photo with no stored file instead of writing a link', async () => {
|
||||
const subject = await enrollFixtureSubject('Sem arquivo', faceTestVector(0))
|
||||
const queue = await listArchivePhotoFaceIndexQueue({ payload, refresh: true })
|
||||
const result = await indexArchivePhotoFaces({
|
||||
payload,
|
||||
item: { id: 999_999_999, filename: null, checkedKey: null },
|
||||
indexKey: queue.indexKey,
|
||||
analyze: async () => [faceTestVector(0)],
|
||||
})
|
||||
expect(result).toEqual({ status: 'failed', stage: 'download', error: expect.any(String) })
|
||||
expect(await getFaceSubjectMatchedPhotoIds(payload, subject)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
|
@ -214,6 +214,10 @@ describe('campaign JSON mutation route convention', () => {
|
|||
'src/app/(frontend)/api/content-events/route.ts',
|
||||
'anonymous public beacon (C213): reads no cookie and no session, so there is no CSRF surface to guard — the write is gated by the published-slug read and throttled in memory; explicit isSameOriginRequest is still a bar-raiser, and the campaign-scoped error envelope does not apply',
|
||||
],
|
||||
[
|
||||
'src/app/(frontend)/api/fotos/selfie/route.ts',
|
||||
'anonymous public selfie search (C234): reads no cookie and no session, so there is no CSRF surface to guard — the request is bounded/parsed strictly, throttled in memory and gated server-side by the album kill switch + Consent; explicit isSameOriginRequest is still a bar-raiser, and the campaign-scoped error envelope does not apply',
|
||||
],
|
||||
[
|
||||
'src/app/(campaign)/campanha/api/ai-chat/route.ts',
|
||||
'streaming AI endpoint (ReadableStream, not JSON) — cookie-authenticated via campaign-token, origin-checked by cookie path',
|
||||
|
|
@ -438,6 +442,10 @@ describe('src/utilities top-level is pinned', () => {
|
|||
'activityUi.ts',
|
||||
'activityViewModels.ts',
|
||||
'advisorData.ts',
|
||||
// C234 — bounded streaming body reader shared by the anonymous JSON routes
|
||||
// (content-events beacon + selfie search); extracted from the beacon route
|
||||
// when it earned a second caller.
|
||||
'boundedRequestBody.ts',
|
||||
'calendarFeed.ts',
|
||||
'campaignAccess.ts',
|
||||
'campaignActionContext.ts',
|
||||
|
|
|
|||
|
|
@ -58,4 +58,16 @@ describe('checkContentEventRateLimit', () => {
|
|||
vi.setSystemTime(new Date('2026-09-23T12:10:00.001Z'))
|
||||
expect(checkContentEventRateLimit(key)).toBe(true)
|
||||
})
|
||||
|
||||
it('honours a stricter caller budget on the same registry (C234 selfie search)', () => {
|
||||
const key = 'unit-strict-budget-client'
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
expect(checkContentEventRateLimit(key, 20)).toBe(true)
|
||||
}
|
||||
expect(checkContentEventRateLimit(key, 20)).toBe(false)
|
||||
|
||||
// Another client still has its own window and the default budget is intact.
|
||||
expect(checkContentEventRateLimit(`${key}-other`, 20)).toBe(true)
|
||||
expect(checkContentEventRateLimit('unit-default-budget-client')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -83,6 +83,11 @@ describe('E2E_AFFECTED_MANIFEST (OPS5)', () => {
|
|||
// overlay, media route) is a new contract and the migration makes every
|
||||
// PR of this delivery high-risk (curated only).
|
||||
'frontendFotos',
|
||||
// C234 — deliberate: the selfie search page/endpoint (Consent
|
||||
// fail-closed, only consented subjects, only approved photos) is a new
|
||||
// contract and the migration makes every PR of this delivery high-risk
|
||||
// (curated only).
|
||||
'frontendFotosSelfie',
|
||||
])
|
||||
const onDisk = new Set(specNamesOnDisk())
|
||||
for (const spec of E2E_CURATED_SPECS) {
|
||||
|
|
|
|||
139
tests/unit/faceCli.unit.spec.ts
Normal file
139
tests/unit/faceCli.unit.spec.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
// C234 — the write guards of the two face CLIs, exercised as subprocesses:
|
||||
// every refusal must happen before any engine call or DB connection.
|
||||
|
||||
const repoRoot = process.cwd()
|
||||
const enrollScript = join(repoRoot, 'scripts', 'enroll-face-subject.mjs')
|
||||
const indexScript = join(repoRoot, 'scripts', 'index-archive-faces.mjs')
|
||||
|
||||
const run = (scriptPath: string, 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',
|
||||
TEQO_ENV: '',
|
||||
ALLOW_REMOTE_DB: '',
|
||||
FACE_ENROLL_CONFIRM: '',
|
||||
FACE_INDEX_CONFIRM: '',
|
||||
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('faces:enroll write guards (C234)', () => {
|
||||
const enrollArgs = ['--label', 'Pessoa', '--selfie', '/tmp/nao-existe-234.jpg', '--apply']
|
||||
|
||||
it('prints the help even without a database', () => {
|
||||
const result = run(enrollScript, ['--help'], { DATABASE_URL: '' })
|
||||
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.stdout).toContain('FACE_ENROLL_CONFIRM')
|
||||
expect(result.stdout).toContain('busca-selfie-indice')
|
||||
})
|
||||
|
||||
it('refuses --apply on a production target without the intent flag', () => {
|
||||
const result = run(enrollScript, enrollArgs)
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(output(result)).toContain('FACE_ENROLL_CONFIRM=1')
|
||||
})
|
||||
|
||||
it('requires the declared TEQO_ENV once the intent flag is set', () => {
|
||||
const result = run(enrollScript, enrollArgs, { FACE_ENROLL_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(enrollScript, enrollArgs, {
|
||||
FACE_ENROLL_CONFIRM: '1',
|
||||
TEQO_ENV: 'staging',
|
||||
})
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(output(result)).toContain('≠ "teqo_staging"')
|
||||
})
|
||||
|
||||
it('passes the guards and refuses a missing selfie before the engine/DB', () => {
|
||||
const result = run(enrollScript, enrollArgs, {
|
||||
FACE_ENROLL_CONFIRM: '1',
|
||||
TEQO_ENV: 'production',
|
||||
})
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(output(result)).toContain('não foi possível ler a selfie')
|
||||
})
|
||||
})
|
||||
|
||||
describe('faces:index write guards (C234)', () => {
|
||||
it('prints the help even without a database', () => {
|
||||
const result = run(indexScript, ['--help'], { DATABASE_URL: '' })
|
||||
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.stdout).toContain('FACE_INDEX_CONFIRM')
|
||||
expect(result.stdout).toContain('--refresh')
|
||||
})
|
||||
|
||||
it('refuses --apply on a production target without the intent flag', () => {
|
||||
const result = run(indexScript, ['--apply'])
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(output(result)).toContain('FACE_INDEX_CONFIRM=1')
|
||||
})
|
||||
|
||||
it('requires the declared TEQO_ENV once the intent flag is set', () => {
|
||||
const result = run(indexScript, ['--apply'], { FACE_INDEX_CONFIRM: '1' })
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(output(result)).toContain('TEQO_ENV')
|
||||
})
|
||||
|
||||
it('refuses --apply without the complete S3_* set on a declared production target', () => {
|
||||
const result = run(indexScript, ['--apply'], {
|
||||
FACE_INDEX_CONFIRM: '1',
|
||||
TEQO_ENV: 'production',
|
||||
})
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(output(result)).toContain('S3_BUCKET')
|
||||
})
|
||||
|
||||
it('reaches the payload bootstrap with the complete target declared', () => {
|
||||
const result = run(indexScript, ['--apply'], {
|
||||
FACE_INDEX_CONFIRM: '1',
|
||||
TEQO_ENV: 'production',
|
||||
...withS3,
|
||||
})
|
||||
|
||||
// No production database here: the run must fail while connecting (or
|
||||
// right after), never with one of the write guards.
|
||||
expect(result.status).toBe(1)
|
||||
expect(output(result)).not.toContain('FACE_INDEX_CONFIRM=1')
|
||||
expect(output(result)).not.toContain('TEQO_ENV=')
|
||||
})
|
||||
})
|
||||
128
tests/unit/faceEnrollPlan.unit.spec.ts
Normal file
128
tests/unit/faceEnrollPlan.unit.spec.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
FACE_ENROLL_DEFAULT_OUT_DIR,
|
||||
faceEnrollReportStamp,
|
||||
formatFaceEnrollReport,
|
||||
parseFaceEnrollCliArgs,
|
||||
} from '../../scripts/lib/faceEnrollPlan.mjs'
|
||||
|
||||
// C234 — the pure parser/report of `pnpm faces:enroll`: every refusal happens
|
||||
// before any engine call or DB connection, and the human lines never carry the
|
||||
// descriptor.
|
||||
|
||||
describe('parseFaceEnrollCliArgs', () => {
|
||||
it('defaults to the dry-run with the standard receipt dir', () => {
|
||||
const options = parseFaceEnrollCliArgs(['--label', 'Pessoa', '--selfie', '/tmp/selfie.jpg'])
|
||||
expect(options).toEqual({
|
||||
apply: false,
|
||||
label: 'Pessoa',
|
||||
selfie: '/tmp/selfie.jpg',
|
||||
subject: null,
|
||||
out: FACE_ENROLL_DEFAULT_OUT_DIR,
|
||||
help: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('parses --apply, --subject, --out and --help', () => {
|
||||
const options = parseFaceEnrollCliArgs([
|
||||
'--apply',
|
||||
'--label',
|
||||
' Pessoa ',
|
||||
'--selfie',
|
||||
'/tmp/selfie.jpg',
|
||||
'--subject',
|
||||
'7',
|
||||
'--out',
|
||||
'data/outro',
|
||||
])
|
||||
expect(options.apply).toBe(true)
|
||||
expect(options.label).toBe(' Pessoa ')
|
||||
expect(options.subject).toBe(7)
|
||||
expect(options.out).toBe('data/outro')
|
||||
|
||||
expect(parseFaceEnrollCliArgs(['--help']).help).toBe(true)
|
||||
})
|
||||
|
||||
it('fails closed on missing label/selfie, bad subject and unknown args', () => {
|
||||
expect(() => parseFaceEnrollCliArgs(['--selfie', '/tmp/a.jpg'])).toThrow(/--label/)
|
||||
expect(() => parseFaceEnrollCliArgs(['--label', 'Pessoa'])).toThrow(/--selfie/)
|
||||
expect(() => parseFaceEnrollCliArgs(['--label', ' ', '--selfie', '/tmp/a.jpg'])).toThrow(
|
||||
/--label/,
|
||||
)
|
||||
expect(() =>
|
||||
parseFaceEnrollCliArgs(['--label', 'P', '--selfie', '/tmp/a.jpg', '--subject', '0']),
|
||||
).toThrow(/--subject/)
|
||||
expect(() =>
|
||||
parseFaceEnrollCliArgs(['--label', 'P', '--selfie', '/tmp/a.jpg', '--nope']),
|
||||
).toThrow(/argumento desconhecido/)
|
||||
expect(() => parseFaceEnrollCliArgs(['--label'])).toThrow(/faltou valor/)
|
||||
expect(() =>
|
||||
parseFaceEnrollCliArgs(['--label', 'P', '--selfie', '/tmp/a.jpg', '--out', '../etc']),
|
||||
).toThrow(/--out/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('faceEnrollReportStamp', () => {
|
||||
it('is filesystem-safe', () => {
|
||||
expect(faceEnrollReportStamp('2026-09-27T16:42:01.123Z')).toBe('2026-09-27T16-42-01-123Z')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatFaceEnrollReport', () => {
|
||||
const base = {
|
||||
runAt: '2026-09-27T16:42:01.123Z',
|
||||
mode: 'apply',
|
||||
target: '127.0.0.1/teqo_wt234',
|
||||
model: 'face-api@1.7.15/faceRecognitionNet',
|
||||
label: 'Pessoa',
|
||||
subjectId: 3,
|
||||
subjectAction: 'created' as const,
|
||||
faceCount: 1,
|
||||
allowed: true,
|
||||
reason: null,
|
||||
consentKey: 'busca-selfie-indice',
|
||||
consentHash: 'abc',
|
||||
durationMs: 10,
|
||||
}
|
||||
|
||||
it('reports the enrollment without ever mentioning a descriptor', () => {
|
||||
const lines = formatFaceEnrollReport(base)
|
||||
const text = lines.join('\n')
|
||||
|
||||
expect(text).toContain('criada: #3 (Pessoa)')
|
||||
expect(text).toContain('busca-selfie-indice')
|
||||
expect(text).toContain('pnpm faces:index --apply')
|
||||
expect(text).not.toMatch(/descriptor|vetor|vector/i)
|
||||
})
|
||||
|
||||
it('shows the dry-run as a plan, never as a write', () => {
|
||||
const lines = formatFaceEnrollReport({
|
||||
...base,
|
||||
mode: 'plan',
|
||||
subjectAction: null,
|
||||
subjectId: null,
|
||||
})
|
||||
const text = lines.join('\n')
|
||||
|
||||
expect(text).toContain('plano: Pessoa (nada gravado')
|
||||
expect(text).not.toContain('criada')
|
||||
})
|
||||
|
||||
it('shows the refusal reason and no subject line', () => {
|
||||
const lines = formatFaceEnrollReport({
|
||||
...base,
|
||||
allowed: false,
|
||||
subjectId: null,
|
||||
subjectAction: null,
|
||||
faceCount: 2,
|
||||
reason: 'mais de um rosto na selfie',
|
||||
})
|
||||
const text = lines.join('\n')
|
||||
|
||||
expect(text).toContain('RECUSADO: mais de um rosto na selfie')
|
||||
expect(text).not.toContain('criada')
|
||||
})
|
||||
})
|
||||
176
tests/unit/faceIndexPlan.unit.spec.ts
Normal file
176
tests/unit/faceIndexPlan.unit.spec.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
FACE_INDEX_DEFAULT_OUT_DIR,
|
||||
faceIndexReportStamp,
|
||||
formatFaceIndexInventory,
|
||||
formatFaceIndexReport,
|
||||
parseFaceIndexCliArgs,
|
||||
summarizeFaceIndexInventory,
|
||||
summarizeFaceIndexResults,
|
||||
} from '../../scripts/lib/faceIndexPlan.mjs'
|
||||
|
||||
// C234 — the pure parser/summaries of `pnpm faces:index`: the honest staleness
|
||||
// accounting and the operator-facing lines.
|
||||
|
||||
describe('parseFaceIndexCliArgs', () => {
|
||||
it('defaults to the dry-run and parses the modes', () => {
|
||||
expect(parseFaceIndexCliArgs([])).toEqual({
|
||||
apply: false,
|
||||
verify: false,
|
||||
limit: null,
|
||||
refresh: false,
|
||||
out: FACE_INDEX_DEFAULT_OUT_DIR,
|
||||
help: false,
|
||||
})
|
||||
|
||||
const apply = parseFaceIndexCliArgs(['--apply', '--limit', '5', '--refresh', '--out', 'x'])
|
||||
expect(apply.apply).toBe(true)
|
||||
expect(apply.limit).toBe(5)
|
||||
expect(apply.refresh).toBe(true)
|
||||
expect(apply.out).toBe('x')
|
||||
|
||||
expect(parseFaceIndexCliArgs(['--verify']).verify).toBe(true)
|
||||
expect(parseFaceIndexCliArgs(['--help']).help).toBe(true)
|
||||
})
|
||||
|
||||
it('fails closed on mutual exclusion, bad limit and escaping out', () => {
|
||||
expect(() => parseFaceIndexCliArgs(['--apply', '--verify'])).toThrow(/mutuamente exclusivos/)
|
||||
expect(() => parseFaceIndexCliArgs(['--limit', '0'])).toThrow(/--limit/)
|
||||
expect(() => parseFaceIndexCliArgs(['--limit', 'x'])).toThrow(/--limit/)
|
||||
expect(() => parseFaceIndexCliArgs(['--out', '../etc'])).toThrow(/--out/)
|
||||
expect(() => parseFaceIndexCliArgs(['--refresh=1'])).toThrow(/argumento desconhecido/)
|
||||
expect(() => parseFaceIndexCliArgs(['--limit'])).toThrow(/faltou valor/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('summarizeFaceIndexResults', () => {
|
||||
it('counts indexed/linked/failed and aggregates links per subject', () => {
|
||||
const summary = summarizeFaceIndexResults([
|
||||
{ photoId: 1, status: 'indexed', matchedSubjects: [3, 4] },
|
||||
{ photoId: 2, status: 'indexed', matchedSubjects: [3] },
|
||||
{ photoId: 3, status: 'indexed', matchedSubjects: [] },
|
||||
{ photoId: 4, status: 'failed', stage: 'download', error: 'sem arquivo' },
|
||||
])
|
||||
|
||||
expect(summary.indexed).toBe(3)
|
||||
expect(summary.failed).toBe(1)
|
||||
expect(summary.linkedPhotos).toBe(2)
|
||||
expect(summary.linksBySubject).toEqual([
|
||||
{ subjectId: 3, photos: 2 },
|
||||
{ subjectId: 4, photos: 1 },
|
||||
])
|
||||
expect(summary.failures).toEqual([{ photoId: 4, stage: 'download', error: 'sem arquivo' }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('summarizeFaceIndexInventory', () => {
|
||||
const queue = { indexKey: 'k', eligibleSubjects: 2, totalApproved: 10, stale: 4 }
|
||||
const subjects = [
|
||||
{
|
||||
id: 1,
|
||||
status: 'active',
|
||||
model: 'face-api@1.7.15/faceRecognitionNet',
|
||||
consentHash: 'hash-atual',
|
||||
vector: [0],
|
||||
matchedPhotoIds: [1, 2],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
status: 'active',
|
||||
model: 'face-api@1.7.15/faceRecognitionNet',
|
||||
consentHash: 'hash-antigo',
|
||||
vector: [0],
|
||||
matchedPhotoIds: [],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
status: 'removed',
|
||||
model: 'face-api@1.7.15/faceRecognitionNet',
|
||||
consentHash: 'hash-atual',
|
||||
vector: null,
|
||||
matchedPhotoIds: [],
|
||||
},
|
||||
]
|
||||
|
||||
it('derives the honest gap, the consent staleness and the links breakdown', () => {
|
||||
const inventory = summarizeFaceIndexInventory({
|
||||
queue,
|
||||
subjects,
|
||||
currentConsentHash: 'hash-atual',
|
||||
})
|
||||
|
||||
expect(inventory).toEqual({
|
||||
indexKey: 'k',
|
||||
totalApproved: 10,
|
||||
indexed: 6,
|
||||
stale: 4,
|
||||
eligibleSubjects: 2,
|
||||
activeSubjects: 2,
|
||||
removedSubjects: 1,
|
||||
staleConsentSubjects: 1,
|
||||
linkedPhotos: 2,
|
||||
linksBySubject: [{ subjectId: 1, photos: 2 }],
|
||||
})
|
||||
})
|
||||
|
||||
it('treats a missing current consent as stale for everyone', () => {
|
||||
const inventory = summarizeFaceIndexInventory({
|
||||
queue,
|
||||
subjects,
|
||||
currentConsentHash: null,
|
||||
})
|
||||
expect(inventory.staleConsentSubjects).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatting', () => {
|
||||
it('stamps the receipt name and prints the inventory lines', () => {
|
||||
expect(faceIndexReportStamp('2026-09-27T16:42:01.123Z')).toBe('2026-09-27T16-42-01-123Z')
|
||||
|
||||
const lines = formatFaceIndexInventory(
|
||||
{
|
||||
indexKey: 'k',
|
||||
totalApproved: 10,
|
||||
indexed: 6,
|
||||
stale: 4,
|
||||
eligibleSubjects: 2,
|
||||
activeSubjects: 2,
|
||||
removedSubjects: 1,
|
||||
staleConsentSubjects: 1,
|
||||
linkedPhotos: 2,
|
||||
linksBySubject: [{ subjectId: 1, photos: 2 }],
|
||||
},
|
||||
'face-api@1.7.15/faceRecognitionNet',
|
||||
)
|
||||
const text = lines.join('\n')
|
||||
expect(text).toContain('fotos aprovadas: 10')
|
||||
expect(text).toContain('desatualizadas (a processar): 4')
|
||||
expect(text).toContain('consentimento vencido')
|
||||
expect(text).toContain('sujeito #1: 2 foto(s)')
|
||||
})
|
||||
|
||||
it('prints the apply summary with failures', () => {
|
||||
const report = {
|
||||
mode: 'apply',
|
||||
target: 'host/db',
|
||||
model: 'face-api@1.7.15/faceRecognitionNet',
|
||||
queue: { items: 3, totalApproved: 10, eligibleSubjects: 1 },
|
||||
summary: {
|
||||
indexed: 2,
|
||||
failed: 1,
|
||||
linkedPhotos: 1,
|
||||
linksBySubject: [{ subjectId: 3, photos: 1 }],
|
||||
failures: [{ photoId: 4, stage: 'analyze', error: 'engine fora' }],
|
||||
},
|
||||
}
|
||||
const text = formatFaceIndexReport(report).join('\n')
|
||||
|
||||
expect(text).toContain('modo: apply')
|
||||
expect(text).toContain('processadas: 2 | vinculadas a alguém: 1 | falharam: 1')
|
||||
expect(text).toContain('sujeito #3: 1 foto(s)')
|
||||
expect(text).toContain('✗ foto 4 [analyze]: engine fora')
|
||||
})
|
||||
})
|
||||
180
tests/unit/faceSearch.unit.spec.ts
Normal file
180
tests/unit/faceSearch.unit.spec.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { ArchivePhotoPublicItem } from '@/lib/archivePhotoPublicCatalog'
|
||||
import {
|
||||
FACE_SEARCH_DESCRIPTOR_LENGTH,
|
||||
FACE_SEARCH_MAX_DISTANCE,
|
||||
FACE_SEARCH_MODEL,
|
||||
faceEuclideanDistance,
|
||||
faceStubDescriptorFromBytes,
|
||||
faceSubjectIsEligible,
|
||||
findFaceMatch,
|
||||
readFaceVector,
|
||||
toFaceSearchPhotoView,
|
||||
} from '@/lib/faceSearch'
|
||||
|
||||
// C234 — the pure contract of the selfie search: vector validation, the
|
||||
// euclidean math, the eligibility of an enrolled subject and the result view
|
||||
// model that by shape cannot carry a score or a third-party name.
|
||||
|
||||
const vectorOf = (value: number, length = FACE_SEARCH_DESCRIPTOR_LENGTH): number[] =>
|
||||
Array.from({ length }, () => value)
|
||||
|
||||
const subjectOf = (
|
||||
overrides: Partial<{
|
||||
id: number
|
||||
status: string
|
||||
model: string
|
||||
consentHash: string | null
|
||||
vector: unknown
|
||||
}> = {},
|
||||
) => ({
|
||||
id: 1,
|
||||
status: 'active',
|
||||
model: FACE_SEARCH_MODEL,
|
||||
consentHash: 'hash-atual',
|
||||
vector: vectorOf(0),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('readFaceVector', () => {
|
||||
it('accepts exactly the descriptor length of finite numbers', () => {
|
||||
expect(readFaceVector(vectorOf(0.25))).toHaveLength(FACE_SEARCH_DESCRIPTOR_LENGTH)
|
||||
})
|
||||
|
||||
it('fails closed on anything else', () => {
|
||||
expect(readFaceVector(null)).toBeNull()
|
||||
expect(readFaceVector('vector')).toBeNull()
|
||||
expect(readFaceVector([1, 2, 3])).toBeNull()
|
||||
expect(readFaceVector(vectorOf(0).slice(1))).toBeNull()
|
||||
expect(readFaceVector([...vectorOf(0).slice(1), Number.NaN])).toBeNull()
|
||||
expect(readFaceVector([...vectorOf(0).slice(1), Number.POSITIVE_INFINITY])).toBeNull()
|
||||
expect(readFaceVector([...vectorOf(0).slice(1), '1'])).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('faceEuclideanDistance', () => {
|
||||
it('is the plain euclidean distance of same-length descriptors', () => {
|
||||
expect(faceEuclideanDistance([0, 0], [0, 0])).toBe(0)
|
||||
expect(faceEuclideanDistance([0, 0], [3, 4])).toBe(5)
|
||||
})
|
||||
|
||||
it('is Infinity when the descriptors cannot be compared', () => {
|
||||
expect(faceEuclideanDistance([0, 0], [0])).toBe(Number.POSITIVE_INFINITY)
|
||||
expect(faceEuclideanDistance([], [])).toBe(Number.POSITIVE_INFINITY)
|
||||
})
|
||||
})
|
||||
|
||||
describe('faceSubjectIsEligible', () => {
|
||||
const current = { model: FACE_SEARCH_MODEL, consentHash: 'hash-atual' }
|
||||
|
||||
it('requires active status, the current model and the current consent hash', () => {
|
||||
expect(faceSubjectIsEligible(subjectOf(), current)).toBe(true)
|
||||
expect(faceSubjectIsEligible(subjectOf({ status: 'removed' }), current)).toBe(false)
|
||||
expect(faceSubjectIsEligible(subjectOf({ model: 'outro-modelo' }), current)).toBe(false)
|
||||
expect(faceSubjectIsEligible(subjectOf({ consentHash: 'hash-antigo' }), current)).toBe(false)
|
||||
expect(faceSubjectIsEligible(subjectOf({ consentHash: null }), current)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('findFaceMatch', () => {
|
||||
const current = { model: FACE_SEARCH_MODEL, consentHash: 'hash-atual' }
|
||||
|
||||
it('returns the closest eligible subject strictly under the threshold', () => {
|
||||
const far = subjectOf({ id: 1, vector: [...vectorOf(0).slice(1), 1] })
|
||||
const near = subjectOf({ id: 2, vector: [...vectorOf(0).slice(1), 0.1] })
|
||||
const match = findFaceMatch({
|
||||
vector: vectorOf(0),
|
||||
subjects: [far, near],
|
||||
...current,
|
||||
})
|
||||
expect(match?.id).toBe(2)
|
||||
})
|
||||
|
||||
it('ignores ineligible subjects and subjects with a malformed vector', () => {
|
||||
const removed = subjectOf({ id: 1, status: 'removed', vector: vectorOf(0) })
|
||||
const staleModel = subjectOf({ id: 2, model: 'outro', vector: vectorOf(0) })
|
||||
const staleConsent = subjectOf({ id: 3, consentHash: 'antigo', vector: vectorOf(0) })
|
||||
const broken = subjectOf({ id: 4, vector: [1, 2, 3] })
|
||||
const eligible = subjectOf({ id: 5, vector: vectorOf(0) })
|
||||
|
||||
const match = findFaceMatch({
|
||||
vector: vectorOf(0),
|
||||
subjects: [removed, staleModel, staleConsent, broken, eligible],
|
||||
...current,
|
||||
})
|
||||
expect(match?.id).toBe(5)
|
||||
})
|
||||
|
||||
it('never matches at or beyond the threshold and answers null when nothing is close', () => {
|
||||
const atThreshold = subjectOf({
|
||||
id: 1,
|
||||
vector: [...vectorOf(0).slice(1), FACE_SEARCH_MAX_DISTANCE],
|
||||
})
|
||||
expect(findFaceMatch({ vector: vectorOf(0), subjects: [atThreshold], ...current })).toBeNull()
|
||||
|
||||
const far = subjectOf({
|
||||
id: 2,
|
||||
vector: [...vectorOf(0).slice(1), FACE_SEARCH_MAX_DISTANCE + 1],
|
||||
})
|
||||
expect(findFaceMatch({ vector: vectorOf(0), subjects: [far], ...current })).toBeNull()
|
||||
expect(findFaceMatch({ vector: vectorOf(0), subjects: [], ...current })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('toFaceSearchPhotoView', () => {
|
||||
const item: ArchivePhotoPublicItem = {
|
||||
id: 7,
|
||||
alt: 'Plenária na Câmara',
|
||||
title: 'Plenária pela saúde',
|
||||
takenOn: '2026-09-12T12:00:00.000Z',
|
||||
dateLabel: '12 de setembro de 2026',
|
||||
shortDateLabel: '12 set 2026',
|
||||
municipalityName: 'Feira de Santana',
|
||||
municipalitySlug: 'feira-de-santana',
|
||||
scene: 'plenaria',
|
||||
sceneLabel: 'Plenária',
|
||||
people: ['Jorge Solla', 'Outra Pessoa'],
|
||||
peopleLabel: 'Jorge Solla e Outra Pessoa',
|
||||
metaLabel: '12 set 2026 · Feira de Santana · Plenária',
|
||||
searchText: 'plenaria feira de santana',
|
||||
thumbnailPath: '/fotos/7/midia?tamanho=grade',
|
||||
mediaPath: '/fotos/7/midia',
|
||||
downloadPath: '/fotos/7/midia?download=1',
|
||||
downloadFilename: 'jorge-solla-1313-foto-7.jpg',
|
||||
}
|
||||
|
||||
it('carries the photo context but never third-party names', () => {
|
||||
const view = toFaceSearchPhotoView(item) as Record<string, unknown>
|
||||
|
||||
expect(view.id).toBe(7)
|
||||
expect(view.title).toBe('Plenária pela saúde')
|
||||
expect(view.metaLabel).toBe('12 set 2026 · Feira de Santana · Plenária')
|
||||
expect(view.thumbnailPath).toBe('/fotos/7/midia?tamanho=grade')
|
||||
|
||||
expect(view).not.toHaveProperty('people')
|
||||
expect(view).not.toHaveProperty('peopleLabel')
|
||||
expect(Object.keys(view).join(' ')).not.toMatch(/score|similar|distance|percent/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('faceStubDescriptorFromBytes', () => {
|
||||
it('is deterministic, descriptor-sized and bounded', () => {
|
||||
const bytes = Uint8Array.from([1, 2, 3, 4, 5])
|
||||
const first = faceStubDescriptorFromBytes(bytes)
|
||||
const second = faceStubDescriptorFromBytes(bytes)
|
||||
|
||||
expect(first).toHaveLength(FACE_SEARCH_DESCRIPTOR_LENGTH)
|
||||
expect(first).toEqual(second)
|
||||
expect(first.every((value) => value >= -1 && value <= 1)).toBe(true)
|
||||
})
|
||||
|
||||
it('derives different descriptors for different bytes', () => {
|
||||
const a = faceStubDescriptorFromBytes(Uint8Array.from([1, 2, 3]))
|
||||
const b = faceStubDescriptorFromBytes(Uint8Array.from([3, 2, 1]))
|
||||
expect(a).not.toEqual(b)
|
||||
expect(faceEuclideanDistance(a, b)).toBeGreaterThan(FACE_SEARCH_MAX_DISTANCE)
|
||||
})
|
||||
})
|
||||
52
tests/unit/faceSearchRequest.unit.spec.ts
Normal file
52
tests/unit/faceSearchRequest.unit.spec.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { FACE_SEARCH_DESCRIPTOR_LENGTH } from '@/lib/faceSearch'
|
||||
import { faceSearchRequestSchema } from '@/lib/schemas/faceSearch'
|
||||
|
||||
// C234 — the wire contract of the selfie endpoint: strict object, exact
|
||||
// descriptor length, finite numbers only and the two intents. Everything else
|
||||
// is a malformed body (never a silently ignored field).
|
||||
|
||||
const vector = (): number[] => Array.from({ length: FACE_SEARCH_DESCRIPTOR_LENGTH }, () => 0.5)
|
||||
|
||||
describe('faceSearchRequestSchema', () => {
|
||||
it('accepts both intents with a full descriptor', () => {
|
||||
expect(faceSearchRequestSchema.safeParse({ vector: vector(), intent: 'search' }).success).toBe(
|
||||
true,
|
||||
)
|
||||
expect(
|
||||
faceSearchRequestSchema.safeParse({ vector: vector(), intent: 'leave-index' }).success,
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects unknown intents and extra keys', () => {
|
||||
expect(faceSearchRequestSchema.safeParse({ vector: vector(), intent: 'enroll' }).success).toBe(
|
||||
false,
|
||||
)
|
||||
expect(
|
||||
faceSearchRequestSchema.safeParse({ vector: vector(), intent: 'search', image: 'x' }).success,
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects descriptors of the wrong length or with non-finite values', () => {
|
||||
expect(
|
||||
faceSearchRequestSchema.safeParse({ vector: vector().slice(1), intent: 'search' }).success,
|
||||
).toBe(false)
|
||||
expect(
|
||||
faceSearchRequestSchema.safeParse({
|
||||
vector: [...vector().slice(1), Number.NaN],
|
||||
intent: 'search',
|
||||
}).success,
|
||||
).toBe(false)
|
||||
expect(
|
||||
faceSearchRequestSchema.safeParse({
|
||||
vector: [...vector().slice(1), Number.POSITIVE_INFINITY],
|
||||
intent: 'search',
|
||||
}).success,
|
||||
).toBe(false)
|
||||
expect(faceSearchRequestSchema.safeParse({ vector: {}, intent: 'search' }).success).toBe(false)
|
||||
expect(faceSearchRequestSchema.safeParse({ intent: 'search' }).success).toBe(false)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue