# Melhorias pré-borda: `sw.js`, `Vary`, robots/404, chave de FPC e MISS de notícias (F1 + F3 + F2.1)

- **Branch:** continuidade `infra-update` — épico `public_infra_update-3662`, plano `pl-d27c`
  (frentes F1, F3 e app-side da F2), antes dos itens externos da borda Multi-CDN.
- **Stack:** Laravel 5.5.50 / PHP 7.2.34 (cPanel/AlmaLinux/Apache 2.4 + EasyApache4 + Multi-CDN).
- **Arquivos alterados:**
  - `public/.htaccess` (ordem `[L0.3]`/`[L0.3b]`; novo `[L0.3c]` para o `Vary`)
  - `routes/web.php` (rota `/robots.txt`)
  - `app/Http/Controllers/Portal/RobotsController.php` (**novo**)
  - `app/Exceptions/Handler.php` (`NotFoundHttpException` → 404 direto)
  - `app/Http/Controllers/Admin/ErrorsController.php` (`notFound` → 404)
  - `app/Http/Controllers/Portal/PortalController.php` (`staticShow` → 404)
  - `app/Http/Middleware/PageCache.php` (chave normalizada + versão + hook de purge)
  - `app/Http/Traits/NewsTrait.php` (`getContentNews` com cache versionado)
  - `config/cache.php` (`ignored_query_params`)
  - `config/cdn.php` (**novo**)
  - `app/Services/Cdn/CdnPurgeService.php` (**novo**)
  - `tests/Unit/PageCacheTest.php`, `tests/Unit/CdnPurgeServiceTest.php` (**novo**)
- **Categoria:** performance / cache de borda + SEO técnico (tarefa #23410).
- **Seeds:** `a8e0` (F1.1, closed), `0a60` (F1.2, in_progress), `8c27` (F3.1, closed),
  `da8f` (F3.2, closed), `03e7` (F2.1, closed).

---

## 1. Contexto

A aferição ao vivo do canário confirmou a camada de origem (FPC, `Cache-Control` público,
ETag/304, gzip, `immutable`, WebP), mas revelou pendências que precedem a borda (A2/A3/A4):

1. `sw.js` era servido `immutable` — quebra a atualização do Service Worker (PWA).
2. `Vary: Accept-Encoding, User-Agent` reaparecia fora do app, fragmentando cache compartilhado.
3. `robots.txt` caía em `302 → /error/404` (soft-404), e URLs inexistentes devolviam 200.
4. A chave do FPC usava `fullUrl()`, então `utm_*`/`gclid` criavam entradas distintas.
5. O MISS da listagem de notícias (`whereHas` + `paginate`) custava ~3 s em produção.

## 2. Correções

### 2.1 F1.1 — `sw.js` não pode ser `immutable` (`public/.htaccess`)

O bloco de exceção `[L0.3b]` vinha **antes** do bloco `[L0.3]`. Como ambos usam `Header set`
para o mesmo header, o **último vence** — logo o `sw.js` herdava `immutable`. Correção:
mover `[L0.3b]` para **depois** de `[L0.3]`. O comentário anterior estava incorreto.

### 2.2 F1.2 — remover `User-Agent` do `Vary` (`public/.htaccess`, bloco `[L0.3c]`)

O app (`CacheControl`) já remove `User-Agent` no HTML, mas o header voltava em estáticos e em
respostas geradas pelo Apache/WHM. Foram adicionadas três diretivas `Header edit Vary`
**sem `<IfModule>`** (cruas) cobrindo `Accept-Encoding, User-Agent`, a ordem inversa e
`User-Agent` isolado. Se o valor não casar/header ausente, é **no-op** (não muda nada).

> **Lacuna conhecida:** não é possível alterar a camada WHM/vhost daqui. Só produção confirma
> se o `Header edit` do `.htaccess` vence o include global. Por isso o seed `0a60` ficou
> `in_progress`.

### 2.3 F3.2 — `robots.txt` 200 e 404 real

- `Portal\RobotsController@show` → `text/plain` com `Sitemap: {url('/sitemap.xml')}` absoluto do
  host (funciona por portal, ao contrário do `public/robots.txt` estático). Rota fora do grupo
  de manutenção.
- `Handler::render` para `NotFoundHttpException` passou a devolver `admin.errors.404` com
  **status 404 direto** (antes: `302 → /error/404`, que respondia 200). `ErrorsController@notFound`
  e `PortalController@staticShow` (ramo de slug inexistente) também passaram a devolver 404.

### 2.4 F2.1 — chave de FPC normalizada + `CdnPurgeService`

- `PageCache::normalizedUrl()` remove `utm_*`, `gclid`, `fbclid`, `msclkid`, `_ga`, etc.
  (lista em `config/cache.php → page_cache.ignored_query_params`) e ordena a query. A chave
  passa a ser `md5(url normalizada | locale)`. `?utm_source=x` reaproveita a entrada de `/`;
  `?page=2` permanece distinta.
- `CdnPurgeService` (`config/cdn.php`): drivers `cloudflare`/`fastly`/`webhook`, **no-op por
  default** enquanto não há provedor, **nunca lança** (falha de CDN é logada e absorvida).
  Acionado em `PageCache::flush()`.

### 2.5 F3.1 — cache versionado da listagem de notícias

Índices quentes já existiam (`PerformanceIndexTest`). O ganho restante foi parar de recomputar a
listagem a cada expiração do FPC: `NewsTrait::getContentNews()` agora usa `Cache::remember`,
com chave ancorada em `PageCache::version()`. `PageCache::flush()` **bumpa** a versão, então
publicar/remover conteúdo invalida as listas imediatamente (sem notícia obsoleta).

## 3. Testes e evidências (container `public_infra_update-app-1`)

```bash
# Lint
docker exec -i public_infra_update-app-1 bash -c 'cd /var/www/html && php -l <arquivo>'

# Suíte
docker exec -i public_infra_update-app-1 bash -c 'cd /var/www/html && php vendor/bin/phpunit'
# => 70 testes / 371 asserções; 1 falha = stub Tests\Feature\ExampleTest (302 em /), por design

# Integração (GET real; portas host mudaram para 32772/32773 após restart)
curl -sk -D - -o /dev/null https://localhost:32773/sw.js
# => cache-control: public, max-age=0, must-revalidate   (bug corrigido)
curl -sk -D - -o /dev/null https://localhost:32773/assets/js/custom/portal/barra-privacidade.js
# => cache-control: public, max-age=31536000, immutable    (sem regressão)
curl -sk https://localhost:32773/robots.txt
# => 200 text/plain:  User-agent: * / Disallow: / Sitemap: https://localhost:32773/sitemap.xml
curl -sk -o /dev/null -w '%{http_code}\n' https://localhost:32773/url-que-nao-existe-xyz   # 404 (1 segmento)
curl -sk -o /dev/null -w '%{http_code}\n' https://localhost:32773/foo/bar/baz               # 404 (vários)
curl -sk -o /dev/null -w '%{http_code}\n' https://localhost:32773/error/404                  # 404
curl -sk -o /dev/null -w '%{http_code}\n' https://localhost:32773/                            # 200
curl -sk -D - -o /dev/null 'https://localhost:32773/?utm_source=facebook&gclid=abc' | grep -i x-page-cache
# => x-page-cache: HIT  (mesma chave de /)
curl -sk -D - -o /dev/null 'https://localhost:32773/?page=2' | grep -i x-page-cache
# => x-page-cache: MISS (paginacao real continua distinta)
```

Verificação direta do F3.1 (bootstrap do app): `getContentNews()` devolveu
`LengthAwarePaginator`, a chave `news.list.v*` existia no file cache (`cache_has=yes`) e a
segunda chamada reutilizou o valor sem erro.

> **OPcache:** o container roda `opcache.validate_timestamps=0`; **toda** mudança PHP exige
> reload do php-fpm (`kill -USR2 <master>`) ou restart do container antes de validar. Após
> `docker restart`, o Docker remapeou as portas host (`32768/32769 → 32772/32773`).

## 4. Garantias

- Código 100% PHP 7.2 / Laravel 5.5; sem `<IfModule mod_headers.c>`; sem `env()` fora de `config/`.
- Sem regressão nos fluxos testados: `/`, `/noticias`, `/sitemap.xml`, `/manifest.webmanifest`,
  assets versionados e formulários.
- `CdnPurgeService` é **inofensivo por default** (sem provedor → sem rede); falha de borda nunca
  derruba a publicação.
- Invalidação do cache de listas atrelada à versão bumpada no `flush()` — sem obsolescência silenciosa.

## 5. Limites e riscos residuais

- **F1.2** (`Vary`) depende de vencer a camada WHM — validar em produção (`0a60` `in_progress`).
- **F3.1**: o MISS de ~3 s **não é reproduzível localmente** (base de teste pequena, sem
  subcategorias de `noticias`); a queda real só se mede em produção/carga.
- `/error/404` mudou de 200 → 404 (correção semântica). Nenhum consumo interno encontrado, mas
  é uma mudança de contrato observável.
- Sem cobertura E2E de browser (Playwright não existe no repo).

## 6. Próximos passos

1. Validar o `Vary` no canário/produção; se o WHM não ceder, abrir ação de root.
2. Profiling/medição do MISS de notícias com a base real; se persistir, avaliar trocar o
   `whereHas` por `whereIn(subquery)`/`join`.
3. Frentes **externas** (F2.2 contratação da borda, F2.3 failover, F2.4 purge multi-provedor) e
   F2.5 (monitoramento + medição final A1–A4) — ainda `blocked` no `pl-d27c`.

---

## 7. Problemática do sitemap (achado desta rodada, independente da borda)

O F3.2 corrigiu o `robots.txt`, mas expôs um problema **anterior**: o `Sitemap:` que ele publica
aponta para um arquivo obsoleto. É trabalho de **origem/app**, sem dependência de CDN.

### 7.1 Sintoma observado

- `GET /sitemap.xml` **não passa pelo Laravel**: devolve o arquivo estático
  `public/sitemap.xml`, que é **versionado no git** (5.132 B; última alteração no merge
  `e1df3b2`; `mtime` 2026-09-08).
- O conteúdo é **legado de homologação**: `lastmod` fixo em `2023-08-03`, `<loc>` de **outros
  domínios** (`guarabira.online`, `guarabira.pb.gov.br`, `fgts.gov.br`, `falabr.cgu.gov.br`,
  `iapm.pb.gov.br`, `sttransguarabira.com.br`…) e uma **URL interna vazada**
  (`http://prefeituras_v5/publicacoes`).
- A raiz XML é **inválida para o schema** de sitemap: usa `xsi="…"` e `schemaLocation="…"` em vez
  de `xmlns:xsi` + `xsi:schemaLocation`, e ainda contém o typo `hhttp://`. É *well-formed* (o
  `xmllint` não acusa erro porque são apenas atributos sem prefixo), mas não é um sitemap válido
  para buscadores.

### 7.2 Causa raiz

1. **A rota é dead code em produção.** O Apache serve arquivos existentes (`-f`) antes do front
   controller; o `.htaccess` só reescreve `robots.txt` (linha 69). Assim
   `Route::any('/sitemap.xml', 'Portal\SitemapController@sitemap')` (`routes/web.php:120`)
   **nunca é alcançada**.
2. **O controller está quebrado mesmo se fosse alcançado** (`Portal\SitemapController@sitemap`,
   `app/Http/Controllers/Portal/SitemapController.php:26-66`):
   - `$url_atual = "http://$_SERVER[HTTP_HOST]"` — esquema `http://` **hardcoded**, sem
     `Request::secure()/url()` (risco de URL mista);
   - `$xml->asXML('sitemap.xml')` **grava arquivo no CWD durante um `GET`** (efeito colateral,
     corrida/permissão) e **não retorna response**;
   - `echo "Arquivo XML …"` → resposta não-XML e sem `Content-Type: application/xml`;
   - `strpos($link, '//') == false` — bug clássico (`0 == false`): links iniciados em `//` viram
     relativos e geram `http://host//…`;
   - `foreach ($url as $url)` sombreia a variável da coleção e `$url->links->link` pode ser `null`
     (relação ausente) → erro fatal;
   - `Route::any` (aceita POST etc.) e está **dentro do grupo `check.site.manutention`** — sai do
     ar em manutenção, ao contrário do `robots.txt`, que é o primeiro que o crawler lê.
3. **Regressão funcional do F3.2:** como o `robots.txt` agora publica
   `Sitemap: {host}/sitemap.xml`, os buscadores são **encaminhados ao arquivo legado**. O smoke
   test de F3.2 (200 em `/sitemap.xml`) passou porque mediu o **estático**, não o mapa correto.

### 7.3 Impacto

SEO — crawlers recebem um sitemap obsoleto, de outro portal, com URL interna exposta, e o mapa
"correto" (`/mapa-do-site`) é apenas uma página HTML. O próprio `sw.js` já trata `/sitemap.xml`
como *network-only* (seed `853d`), o que evidencia a intenção de servir dinâmico.

### 7.4 Correção proposta (app-side, sem borda)

- `.htaccess`: `RewriteRule ^sitemap\.xml$ index.php [L]` **antes** do bloco `-f` (como feito para
  `robots.txt`), e mover a rota para **fora** do grupo de manutenção (junto de `robots`/`manifest`),
  como `Route::get`.
- Reescrever `SitemapController@sitemap` para **retornar** um `Response` com
  `Content-Type: application/xml; charset=UTF-8`, esquema via `url()`/`Request::secure()`,
  `xmlns:xsi` + `xsi:schemaLocation` corretos, `lastmod` real por conteúdo/menu e **sem** escrever
  em disco (e com cache, como as demais listas).
- Decisão pendente: **remover** `public/sitemap.xml` do repositório para não voltar a ser servido
  por engano (é destrutivo e exige aval).
- **Aceite:** `curl -s -D - https://host/sitemap.xml` → `200`,
  `Content-Type: application/xml`, todos os `<loc>` do host atual, XML válido no validador de
  sitemap do Google e **sem** URL interna/legada.

> **Estado de versionamento:** nenhuma mudança foi commitada (`git add`/`commit`/`push` e
> `sd sync` não executados). As alterações estão apenas na árvore de trabalho local.
