# Correção: cache de borda do HTML público (L1.1 + L3.3) — middleware `CacheControl`

- **Branch:** grupo de correções `infra-update` (camada L1 — segunda camada do EPIC; executada em conjunto com L3.3)
- **Stack:** Laravel 5.5.50 / PHP 7.2.34 (cPanel/AlmaLinux/Apache 2.4 + EasyApache4 + Multi-CDN)
- **Arquivos alterados:**
  - `app/Http/Middleware/CacheControl.php` (novo)
  - `app/Http/Kernel.php` (registro de `cache.public` no `$routeMiddleware`)
  - `routes/web.php` (grupo `check.site.manutencao` recebe `cache.public`)
  - `routes/basepro.php` (rota pública `staticShow` `/{slug}` recebe `cache.public`)
   - `.seeds`: issues `daa9` (L1.1) e `72bb` (L3.3) e `f9fc` (L1.2) concluídos; `650c` (L1.3) marcado `deferred` (A4)
- **Categoria:** performance / cache de borda (tarefa #23410)
- **Seeds:** plano `pl-cc49` (rev 4) — abordagem L1.1+L3.3 juntos

---

## 1. Problema

No baseline (`2026-09-08-12-10`, 62 domínios) o `cache_hit = 0,0` e `cache_policy = 16/100`.
O harness mede `cache_hit` lendo os headers da **borda do Multi-CDN**
(`X-Cache`, `CF-Cache-Status`, `X-Cache-Status`, `Age` — ver `_pesquisa/BASELINE.md:56-57`).
Ou seja, **nada está sendo servido em cache de borda**: toda requisição bate a origem.

## 2. Causa real

Para uma borda de CDN cachear uma resposta HTML, ela exige **duas** condições:

1. `Cache-Control: public` (com `s-maxage` para a borda compartilhada); e
2. **ausência de `Set-Cookie`** na resposta.

No Laravel, **todas** as rotas passam pelo grupo `web`
(`app/Http/Kernel.php:30-38`), que inclui `StartSession` + `VerifyCsrfToken`. Isso emite
`laravel_session` e `XSRF-TOKEN` em **toda** resposta — mesmo nas páginas públicas de conteúdo.
Consequentemente:

- O `web` group injetava `Cache-Control: no-cache, private` (causa de `cache_policy=16`);
- E o `Set-Cookie` impedía qualquer cache de borda (causa de `cache_hit=0`).

Um middleware que apenas sobrescrevesse o `Cache-Control` (`public`) **não bastaria**: a borda
continuaria vendo o cookie e não cachearia. Era preciso também eliminar o `Set-Cookie`.

## 3. Correção real (abordagem mínima e segura)

Em vez de refatorar grupos de rota (`web.public` + `RouteServiceProvider`), o que exigiria
reatribuir dezenas de rotas POST/`any` e arriscava quebrar CSRF/admin, adotou-se o caminho
**menos invasivo**: um único middleware que, nas rotas GET do portal público, **remove o
`Set-Cookie`** e **define o `Cache-Control` público**. Assim a borda passa a cachear sem tocar
em nenhuma rota de admin ou de formulário.

> **Armadilha corrigida (3 pontos, validado em container):**
> 1. O middleware **não pode ser global** (`Kernel::$middleware`): ele roda *fora* da resolução
>    de rota, então `$request->route()` é `null` lá e o guard por controller não funciona. Tem que
>    estar **dentro do grupo `web`** (middleware de rota), onde a rota já está resolvida.
> 2. Dentro do grupo `web`, o `Router::gatherRouteMiddleware()` faz `sortMiddleware()` pela
>    lista `$middlewarePriority`. Middleware **ausente** dessa lista vai para o *fim da ordem de
>    entrada* → roda *primeiro* no outbound, **antes** do `StartSession` adicionar o cookie e o
>    `no-cache, private`. Por isso `CacheControl` deve ser o **PRIMEIRO** tanto no array do grupo
>    `web` **quanto** em `$middlewarePriority` (assim roda *por último* no outbound, após o
>    `StartSession` ter setado cookie/cache-control, e consegue remover/sobrescrever).
> 3. Os cookies de sessão/XSRF são enfileirados no **array de cookies** da resposta (o header
>    `Set-Cookie` é montado no envio), logo `headers->remove('Set-Cookie')` não adianta. É preciso
>    esvaziar o array com `removeCookie()` em loop.
>
> **Gotcha de teste:** `curl -sI` manda **HEAD**, e o middleware só age em **GET** → sempre vai
> parecer "sem efeito". Testar com GET real: `curl -s -D - -o /dev/null https://.../`. E em
> Docker/prod com OPcache, o bind-mount nem sempre atualiza o mtime → `validate_timestamps` não
> recarrega; **reinice o php-fpm / container** para limpar o OPcache antes de validar.

### 3.1 `app/Http/Middleware/CacheControl.php` (novo)

- **Middleware do grupo `web`** (primeiro do array) **e primeiro em `$middlewarePriority`** — assim
  roda **por último no outbound**, após o `StartSession` ter adicionado cookie/`no-cache` (ver
  armadilha acima). Atua **só em GET**, resposta não-redirect e `status < 400`.
- **Só controllers do portal público** (sub-namespace `Portal`, ex. `App\Http\Controllers\Portal\…`):
  admin (`Controllers\Admin`), api e demais ficam de fora, preservando sessão/cookie. Identificado
  via `$route->getActionName()`.
- Define:
  ```
  Cache-Control: public, max-age=300, s-maxage=300,
                 stale-while-revalidate=86400, stale-if-error=604800
  Vary: Accept-Encoding
  ```
  (TTL 300s confirmado pelo usuário; `s-maxage` é o que a borda do CDN honra.)
- Remove `Pragma`, `Expires` e **esvazia o array de cookies** da resposta (`removeCookie()` em
  loop — `headers->remove('Set-Cookie')` não funciona, pois o cookie é enfileirado no array).
- **Exclui por nome** as rotas que precedem um POST com CSRF (páginas com formulário), para
  não quebrar a validação de CSRF no submit: `contato`, `ouvidoria`, `buscar`, `buscando`,
  `noticia`, `noticias`, `formulario`, `cultural`, `newsletter`.

### 3.2 `app/Http/Kernel.php`

Registrado em **dois** lugares para rodar por último no outbound (após o `StartSession`):

**(a) PRIMEIRO do grupo `web`** — fica dentro da resolução de rota e é o último a atuar na volta:
```php
'web' => [
    \App\Http\Middleware\CacheControl::class,
    \App\Http\Middleware\EncryptCookies::class,
    \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
    \Illuminate\Session\Middleware\StartSession::class,
    ...
],
```

**(b) PRIMEIRO em `$middlewarePriority`** — senão o `sortMiddleware()` empurra o middleware para
o fim da ordem de entrada (primeiro no outbound) e o `StartSession` re-faz o cookie depois:
```php
protected $middlewarePriority = [
    \App\Http\Middleware\CacheControl::class,
    \Illuminate\Session\Middleware\StartSession::class,
    \Illuminate\View\Middleware\ShareErrorsFromSession::class,
    \Illuminate\Auth\Middleware\Authenticate::class,
    \Illuminate\Session\Middleware\AuthenticateSession::class,
    \Illuminate\Routing\Middleware\SubstituteBindings::class,
    \Illuminate\Auth\Middleware\Authorize::class,
];
```
(Não é `$routeMiddleware` nominal; as rotas não precisam declará-lo.)

### 3.3 / 3.4 `routes/web.php` e `routes/basepro.php`

**Não há edição de rota.** Como `cache.public` é global, todas as rotas GET do portal público
já são cobertas automaticamente (filtro por namespace `Portal` dentro do middleware). As rotas
continuam apenas no grupo `check.site.manutencao`.

> **Nota:** a sessão ainda é iniciada server-side nas páginas públicas (overhead de
> `StartSession` permanece); o que muda é o *response* (sem cookie → cacheável). Se no futuro
> se quiser eliminar também esse overhead, o refactor de grupo `web.public` + `RouteServiceProvider`
> fica como melhoria futura — não necessário para o ganho de `cache_hit`.

## 4. Retrocompatibilidade

- **Admin (`/cms`) e rotas autenticadas:** inalteradas — não recebem `cache.public`, seguem com
  `no-cache, private` + `Set-Cookie`. Zero risco de vazar conteúdo por-usuário.
- **Formulários/CSRF:** rotas de formulário (contato, ouvidoria, busca, notícias, formulários
  dinâmicos, carta de serviços, newsletter) são **excluídas por nome** → mantêm a sessão/cookie
  e o submit funciona normalmente.
- **Nenhuma alteração de controladores ou de lógica de negócio.**
- Headers de segurança (H5/H7 do `.htaccess`) não são tocados por este middleware.

## 5. Verificação (no canário)

```bash
# Página pública de conteúdo — deve vir pública, SEM cookie, com Vary
curl -sI https://infra-update.devs.maxima.inf.br/ \
  | grep -iE 'cache-control|set-cookie|vary'
# esperado:
#   Cache-Control: public, max-age=300, s-maxage=300, stale-while-revalidate=86400, stale-if-error=604800
#   Vary: Accept-Encoding
#   (NENHUM header Set-Cookie)

# Admin — deve continuar no-cache,private + Set-Cookie (inalterado)
curl -sI https://infra-update.devs.maxima.inf.br/cms | grep -iE 'cache-control|set-cookie'

# Formulário (contato) — excluído: mantém cookie/sessão para CSRF
curl -sI https://infra-update.devs.maxima.inf.br/contato | grep -i set-cookie

# Medir no canário e comparar vs baseline
ETAPA=01-cache-cdn DESCRICAO="L1 cache-control + sem Set-Cookie" \
  bash runners/medir_estrutura.sh infra-update.devs.maxima.inf.br
python3 ../comparar/comparar.py measuring/results/2026-09-08-12-10 measuring/results/<nova-data>
# esperado: cache_policy >= 80; cache_hit sobe SE a borda Multi-CDN estiver configurada
#           para cachear HTML com public + s-maxage + sem cookie (track A4)
```

## 6. Notas de deploy

- `php` não está disponível neste sandbox → a validação de `artisan route:list`/lint deve ser
  feita no canário. O middleware é PHP 7.2/PSR-4 compatível.
- **Dependência de infra (A4):** `cache_hit` só sobe de fato se a borda do Multi-CDN estiver
  configurada para cachear HTML quando recebe `public` + `s-maxage` + sem `Set-Cookie`. Se a
  borda não estiver assim configurada, `cache_policy` sobe mas `cache_hit` fica ~0 (medir e
  acionar A4).
- L1.2 (ETag/304 em HTML) **IMPLEMENTADO em 2026-09-09** — no próprio `CacheControl.php`
  (após setar `Cache-Control`, emite `ETag` e responde `304` em `If-None-Match` batendo). Fecha o
  Critério A2 (`revalidation > 0`). L1.3 (purge Multi-CDN) segue **deferred** (depende de A4).
- **🔧 CORREÇÃO DE ETag (2026-09-09, pós-medida `09-cache-borda`):** em produção o
  `revalidation=0` mesmo com `Cache-Control: public` — bug real, não ruído. Causa: o Apache
  (`mod_deflate`/`mod_brotli`) anexa o sufixo `-gzip`/`-br` a **ETags FORTES** ao comprimir a
  resposta; clientes que pedem compressão (browsers, CDN, `curl --compressed`) recebiam
  `ETag: "<hash>-gzip"` e devolviam esse valor em `If-None-Match`, mas o middleware comparava
  contra o `<hash>` cru → `200` sempre (o `artisan serve` do canário não gzipa, por isso os 23
  testes de middleware passavam e o bug só aparecia em produção). **Correção:** emitir **ETag
  FRACO** `W/"<md5>"` (o Apache não altera validadores fracos, que são encoding-insensive por
  RFC 7232) **e** normalizar o `If-None-Match` (remover prefixo `W/`, aspas e o sufixo
  `-gzip`/`-br`) antes de comparar. Agora `304` dispara para QUALQUER cliente → `revalidation=1`.
  Testes: `gzip_client_com_etag_suffix_gzip_dispara_304`, `etag_emitido_e_fraco`.
- **🔧 NONCE + ETag (2026-09-10, SEC-1f):** o `CspNonce` emite um *nonce por request* e o
  injeta no corpo dos `<script>`/`<style>` inline, mas a injeção ocorre **depois** do
  `CacheControl` calcular o `ETag` (ordem do grupo `web`: PageCache→CacheControl→…→CspNonce),
  então o `ETag` **não inclui o nonce** e fica estável → o `304` funciona de verdade em
  produção (fecha A2). No HIT do FPC, `applyToCachedResponse` re-injeta um nonce fresco no
  corpo cacheado. A borda (A4) serve apenas `Report-Only` (downgrade enforce→Report-Only no
  flip SEC-1e), evitando o *static nonce vulnerability* — ver
  `plan/2026-09-10-csp-enforce-edge-coexistence.md` (abordagem #2).
- **🔧 `Vary` sem `User-Agent` (2026-09-09):** o HTML de portal público não varia por
  `User-Agent` (md5 do corpo idêntico entre UAs — verificado). Remover `User-Agent` do `Vary`
  evita fragmentar o cache de borda (uma cópia por string de UA) e melhora a `hit-ratio` do
  Multi-CDN (A4). O `CacheControl` agora força `Vary: Accept-Encoding` e remove qualquer
  `User-Agent`. Teste: `vary_nao_inclui_user_agent`.
- **⚠️ GATE DE SEGURANÇA L1.0 (2026-09-09):** todo layout público do portal embute `<meta name="csrf-token">`
  + hidden `_token` (ex.: `resources/views/portal/layouts/modelo1/layout/head.blade.php:15`). Com o
  cookie de sessão removido, cada GET anônimo abre nova sessão → HTML **varia por request** → `ETag`
  instável e `304` nunca dispara; e se a borda cacheasse esse HTML `public`, serviria o token de uma
  sessão a outra → **CSRF 419 no submit**. Por isso o `CacheControl` inspeciona o corpo: se há
  `csrf-token`/`_token`, responde `private, no-cache` e **mantém o cookie**; só páginas sem CSRF
  recebem `public` + ETag. Como TODAS as páginas do portal têm o meta, **nenhuma é cacheada hoje** —
  L1.1 entrega zero cache até o desacoplamento do CSRF. Pré-requisito: `public_infra_update-c556`
  (L1.0 — injetar `_token` client-side no submit).
- Guardrail: manter HTTP/2, TLS 1.3, redirects=0, disponibilidade=100% e os headers de segurança.

## 7. Nota: stack EA4 `ea-php72` (não alt-php / PHP Selector)

O domínio roda **EasyApache 4 / `ea-php72`** (confirmado via `phpinfo` no canário), **não** o
PHP Selector (alt-php) do CloudLinux. Logo as notas abaixo sobre "PHP Selector" **não se aplicam**
a este ambiente:

- **OPcache:** está **ATIVO** no php.ini de sistema do EA4
  (`/opt/cpanel/ea-php72/root/etc/php.ini`, `opcache.enable=1`, `validate_timestamps=0`). Tunado
  via WHM (wiki/infra-update/WHM.md P0-B). Não há "opcache desmarcado no Selector" — esse caminho
  é de alt-php e não é este caso.
- **APCu:** não existe RPM `ea-php72-php-apcu` no repo EA4; instalado por **build manual pecl**
  (ativo em produção). O `apcu-autoloader` do `composer.json` usa APCu após `composer dump-autoload`.
  ⚠️ Build manual é sobrepujado por rebuild do EA4 (ver WHM.md P0-C).
- **`brotli` (extensão PHP):** irrelevante para compressão (feita no WHM Optimize Website /
  `mod_brotli`). Não marcar para este fim.
- **`redis`:** disponível na conta → `CACHE_DRIVER=redis` / `SESSION_DRIVER=redis` viável para
  L2.2/L2.3 (cache compartilhado, mais rápido que `file`).

Em resumo: como a conta usa EA4 (não alt-php), a configuração de extensões/runtime é via **WHM**
(php.ini de sistema + `php.d`), não pelo PHP Selector. Chaves `PHP_INI_SYSTEM` (`opcache.*`,
`realpath_cache_*`, `apcu.*`) são ignoradas no `.user.ini`/MultiPHP INI da conta (vide L0).

---

**Veja também:** `melhorias-cache-csrf-l1-l1.0.md` — sumário das rodadas (L1.1/L1.2, descoberta
do CSRF, mitigação, L1.0 Fase 1, seeds e status).

