# F1.5 — Onde os arquivos vão morar

Decisão de hospedagem do armazenamento de objetos. O código já está pronto e
testado; falta escolher para onde ele aponta.

Escrito em 19/08/2026.

---

## O que está em jogo

346 MB em 1.146 arquivos, medidos em produção:

| Diretório | Arquivos | Tamanho |
|---|---:|---:|
| `vip-class` | 63 | 164 MB |
| `layout/img/avatar` | 949 | 103 MB |
| `arquivos` | 111 | 75 MB |
| `banners` | 15 | 2,3 MB |
| `assistentes` | 6 | 2,0 MB |
| `contratos` | 2 | 60 KB |

---

## A armadilha: MinIO na VPS de produção não resolve nada

O motivo de tirar os arquivos do disco local é o failover automático da F1.7. Se
os objetos moram **na mesma máquina** que morreu, o nó promovido continua sem
contrato, sem certificado e sem foto. Só se trocou o diretório de lugar.

Vale repetir porque é fácil de perder de vista: **o problema nunca foi o
diretório. Foi o arquivo existir num lugar só.**

---

## As opções, com o custo real

| | Durabilidade | Custo/mês para 346 MB | Trabalho |
|---|---|---:|---|
| MinIO na VPS de produção | **nenhuma** — morre junto | R$ 0 | baixo |
| MinIO em VPS separada | um ponto de falha novo | preço de uma VPS | médio |
| MinIO com replicação para o BVIP | duas cópias, controle total | R$ 0 (usa o que já existe) | **alto** |
| Cloudflare R2 | replicada pelo provedor | **~R$ 0,03** | baixo |
| Backblaze B2 | idem | **~R$ 0,01** | baixo |
| Google Cloud Storage | idem | **~R$ 0,04** | baixo |

Os centavos não são erro de conta. **346 MB é volume irrisório para
armazenamento de objetos** — a faixa em que auto-hospedar compensa começa em
terabytes, não em megabytes. Mesmo crescendo dez vezes, a conta fica abaixo de
um real por mês.

O que se paga num serviço gerenciado não é espaço: é **não ter que garantir
durabilidade sozinho** — réplica, verificação de integridade, restauro.

---

## DECIDIDO em 19/08/2026: servidor local por ora

Decisão do responsável: manter tudo no servidor local agora, e tratar a
distribuição em nuvem no futuro.

**A consequência, registrada uma vez para ficar rastreável:** enquanto os objetos
morarem na mesma máquina da aplicação, a camada de arquivos **não** está coberta
pelo failover. Uma promoção entrega o sistema funcionando e sem contrato, sem
certificado, sem banner e sem foto de colaborador. Vira o **D33**.

Isso não anula o trabalho — pelo contrário, é o que o justifica. Com a abstração
no lugar, virar essa chave depois é **editar `local.php`**, não reabrir sete
arquivos de serviço. O código fica pronto para a nuvem antes de a nuvem ser
contratada.

O que muda no plano: o portão da F1.7 passa a ter uma lacuna conhecida e escrita,
em vez de uma surpresa no primeiro failover.

---

## Recomendação (para quando a distribuição entrar)

**Homologação e desenvolvimento: MinIO**, que já está no `docker-compose.yml` sob
o perfil `f15`. Sem custo, sem conta externa, e exercita exatamente o mesmo
protocolo.

**Produção: serviço gerenciado.** Entre os três, **Cloudflare R2** tem a
vantagem que mais importa aqui: **saída de dados sem cobrança**. Avatar, banner
e material do VIP Class são servidos o tempo todo para o aplicativo e para o
ERP, e é justamente a saída que costuma dominar a fatura nos outros.

Se a preferência por Google Cloud pesar mais que isso, **o GCS serve sem
alteração de código** — ele expõe API compatível com S3 em modo de
interoperabilidade, com chave HMAC e assinatura SigV4, que é exatamente o que o
`ArmazenamentoS3` fala.

**Se ainda assim for auto-hospedado**, então precisa ser MinIO numa máquina
**diferente** da produção, com replicação de bucket para o BVIP. Funciona, e é a
opção com mais trabalho de operação — a mesma que a F1.7 já vai ter para o banco.

---

## Por que o protocolo S3 e não o SDK

Registrado porque a escolha parece contraintuitiva num ambiente que não usa AWS.

**Justamente por não usar.** S3 virou o protocolo comum de armazenamento de
objetos: MinIO, R2, B2 e o GCS em interoperabilidade falam todos ele. Escrever
contra o protocolo mantém as quatro portas abertas.

O SDK da AWS é que amarraria — e traria o problema do D11, que prendia o projeto
ao Composer 2.2 e ao PHP 8.1 por causa do `laminas-cache`. Cem linhas de SigV4
custam menos que mexer naquele `composer.lock`.

> **08/11/2026 (a fatia 130, o D11 PREPARADO):** o PHP saiu da trava -- o `laminas-cache` foi
> ao 3.13.0 e o lock aceita do 8.1 ao 8.3 (o `D11LockAceitaOPhpNovoTest` confere). O Composer
> 2.2 segue, agora pelo `laminas-dependency-plugin` (`plugin-api < 2.3`), e o `laminas-cache`
> 3.13.0 ainda pede `psr/cache` 1. A escolha do protocolo continua de pé pelo primeiro motivo.

---

## Migrar os 346 MB

Uma vez, com o sistema no ar, antes de virar a configuração:

0. **Garante que o endereço do `url()` se lê sem assinatura** (08/11/2026, a correção da
   fatia 130): a `url_publica` (o domínio público do bucket, como o do R2) ou um bucket de
   leitura pública. Desde a fatia 130 a foto do colaborador sai pelo `Armazenamento` nas telas
   e na API do aplicativo, e sem a `url_publica` o `url()` devolve o endereço cru do bucket
   (`https://<endpoint>/<bucket>/layout/img/avatar/...`); com o bucket privado, o normal no R2
   e na AWS, as fotos somem do topo do ERP, das telas de usuários e do aplicativo. Confira
   abrindo, sem estar logado, o endereço de uma foto que o `url()` devolve. (Que o endereço cru
   responda 403 depende da política do bucket, e isso não foi medido aqui.)
1. Sobe tudo para o destino, com o caminho lógico preservado -- inclusive os três `padrao*`
   de `public/layout/img/avatar/`, que são versionados no git e não vêm dos envios.
2. Confere contagem e resumo de cada arquivo.
3. Vira o `driver` no `local.php`.
4. **Mantém o disco local intacto por algumas semanas** — se algo escapou, o
   arquivo ainda está lá.

O passo 4 é o que torna a virada reversível. Só depois de um ciclo completo de
uso é que o diretório antigo sai.

---

## As URLs de leitura (o passo 3 do D33)

Gravar no bucket não adianta se a tela continua pedindo o arquivo ao site. Quem mostra
um arquivo pede o endereço ao `Armazenamento`: `url($caminho)` nas telas (no disco,
`/<caminho>`; no bucket, a `url_publica` ou o endereço dele) e, na API do aplicativo,
`urlCompleta($caminho, $prefixoLocal)` (08/11/2026, a fatia 130): no disco, o prefixo
que a resposta sempre colou, **byte a byte** -- há aplicativos antigos em campo (D5), e a
lista de colaboradores da unidade sempre mandou as duas barras de
`https://franquiabv.com.br//layout/img/avatar/...`; no bucket, o `url()`, que já é completo.

Passaram pelo `Armazenamento`: a VIP Class (a fatia 79, o D712) e, na fatia 130, a foto do
colaborador nos sete pontos que a montavam à mão -- o topo do ERP, a lista e a foto do usuário
nas telas antigas, o endereço da foto recortada (`UsuarioService::definirImagem`) e as três
respostas da API (a agenda e o consumo do cliente, a lista de colaboradores da unidade). O
`D33EnderecoDaFotoPeloArmazenamentoTest` vigia os dois lados: no disco, a resposta da API igual
à de sempre; no bucket com a `url_publica`, o endereço dele nas telas e na API. Nas telas, o
`src` passou a sair pelo `escapeHtmlAttr` (a barra vira `&#x2F;`): o navegador pede o mesmo
endereço.

**A `url_publica` deixa de ser opcional na virada** (ver o passo 0 de "Migrar os 346 MB"): o
`ArmazenamentoS3` a aceita vazia, e grava igual, mas sem ela o endereço que as telas e a API
mandam é o do bucket cru, que o navegador e o aplicativo pedem sem assinatura.

**Fora, e por quê: a agenda da API nova (`GET /api/v4/clientes/:id/agenda`).** Ela devolve só
o nome do arquivo (`colaboradorImagem`, por exemplo `avatar_12.jpg?abcd`, o
`ClientesController::agendaAction`), e não um endereço: quem a consome monta o endereço
sozinho, e o monta a partir do site -- que, depois da virada, não tem mais a foto. Não passou
pelo `Armazenamento` porque não há o que trocar sem mudar o contrato (o snapshot
`v4-clientes-agenda-autenticado.json`), e nenhum aplicativo fala o `/api/v4` ainda (o D59).
O caminho, quando o aplicativo novo for escrito: um campo com o endereço pronto pelo
`urlCompleta`, como a convenção manda ("A URL vem do `Armazenamento::url()`", em
`docs/convencao-api.md`). Ela fica no D33 até lá.

**Ainda escritos à mão** (o D33 segue aberto por eles): a URL dos banners na API do aplicativo
(`AplicativoController::bannersSelecionarAction`, que o `BannersPelaApiTest` prende ao
`sistema.url` de propósito, desde a fatia 22), as capas dos assistentes nas duas telas antigas
(que esperam o dono, nas 87 a 89) e o `app.json` (o D36).
