Skip to content
10 changes: 6 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,17 @@ Banco: `mysql -u root -p < database/schema.sql` em instalação nova. Banco já

**Post/Redirect/Get com códigos de flash** — handlers terminam com `redirect('rota', ['error' => 'codigo'])` ou `['success' => 'codigo']` (`redirect()` tem tipo `never`, faz `exit`). `views/partials/flash.php` traduz o código via um mapa fixo; **código novo precisa ser adicionado nesse mapa**, senão nada aparece. Nunca exibir o valor cru da query string.

**Views** — PHP puro em `views/`, recebem variáveis via `extract()` do `View::render($view, $data)`. Toda saída passa por `e()`. Links e actions usam `url('rota', [...])`; formulários POST incluem `<?= Csrf::field() ?>`. Páginas do painel incluem `partials/merchant-header.php` / `merchant-footer.php` (definir `$title` antes). Helpers globais (`e`, `redirect`, `url`, `format_phone`, `format_datetime`) ficam em `src/Support/helpers.php`, carregado pelo autoload `files` do Composer.
**Views** — PHP puro em `views/`, recebem variáveis via `extract()` do `View::render($view, $data)`. Toda saída passa por `e()`. Links e actions usam `url('rota', [...])`; formulários POST incluem `<?= Csrf::field() ?>`. Páginas do painel incluem `partials/merchant-header.php` / `merchant-footer.php` (definir `$title` antes). Helpers globais (`e`, `redirect`, `url`, `format_phone`, `format_document`, `format_datetime`) ficam em `src/Support/helpers.php`, carregado pelo autoload `files` do Composer.

**Fuso horário** — `app_timezone()` (`APP_TIMEZONE` do `.env`, padrão `America/Sao_Paulo`) é aplicado no PHP pelo `index.php` e pelo `tests/bootstrap.php`, e o `Database` faz `SET time_zone` com o mesmo offset em toda conexão. Não dependa do fuso do `php.ini` nem do servidor MySQL.

**Sessão do lojista** — o `authGuard` confere a cada requisição se a conta ainda existe e está `active` (desativada encerra a sessão na hora, com `conta_inativa`), expira a sessão parada há mais de `SessionGuard::IDLE_SECONDS` (8 h) e atualiza nome da loja e `last_seen`. A sessão usa `use_strict_mode` (id inventado não é aceito), `cookie_secure` quando a requisição é HTTPS e `gc_maxlifetime` igual ao limite de inatividade.
**Sessão do lojista** — o `authGuard` confere a cada requisição se a conta ainda existe e está `active` (desativada encerra a sessão na hora, com `conta_inativa`), expira a sessão parada há mais de `SessionGuard::IDLE_SECONDS` (8 h) e atualiza nome da loja e `last_seen`. O login guarda na sessão `password_sig` (`SessionGuard::passwordSignature`) e o `authGuard` compara com a senha do banco: senha trocada derruba as sessões abertas com a antiga. Quem altera `password_hash` precisa atualizar o `password_sig` da própria sessão (ver `handlePasswordChange`), senão se desloga.

**Perfil** — `merchant/profile` tem dois formulários na mesma rota: dados da loja (padrão) e troca de senha (`action=password`, exige a senha atual, com limite de erros no bucket `password_change`). E-mail e CPF/CNPJ aparecem só para leitura e nunca são lidos do POST. A sessão usa `use_strict_mode` (id inventado não é aceito), `cookie_secure` quando a requisição é HTTPS e `gc_maxlifetime` igual ao limite de inatividade.

**Proteções de toda requisição** — o `index.php` chama `RequestGuard` antes de rotear: cabeçalhos de segurança (`X-Frame-Options: DENY`, `frame-ancestors 'none'`, `nosniff`, `Referrer-Policy: same-origin`, sem `X-Powered-By`) e parâmetros em formato de lista (`campo[]=`) viram texto vazio, então nenhum `(string)` de `$_GET`/`$_POST` gera warning. O logout é POST com CSRF (o "Sair" do menu é um formulário). Os testes de fluxo rodam com `display_errors` ligado (`tests/Support/router.php`): qualquer warning aparece no HTML e quebra o teste.

**Erros** — `ErrorController::handle($code)` renderiza `views/errors/{code}.php` (fallback `default.php`). Exceções não tratadas e erros fatais são logados e viram 500 pelo `App\Support\ErrorLog` (registrado no `index.php`): uma linha por erro com um código curto, que a página 500 também mostra. Do contexto da requisição o log leva só método e rota, nunca query string, corpo ou argumentos (têm telefone e senha). Detalhes técnicos vão só para `error_log`, cujo destino é `LOG_FILE` ou o do `php.ini`.
**Erros** — `ErrorController::handle($code)` renderiza `views/errors/{code}.php` (fallback `default.php`) e entrega às views o botão de voltar (`$backUrl`, `$backLabel`): lojista logado volta ao painel, qualquer outra pessoa à home pública; página de erro nova usa essas variáveis, nunca link fixo. Exceções não tratadas e erros fatais são logados e viram 500 pelo `App\Support\ErrorLog` (registrado no `index.php`): uma linha por erro com um código curto, que a página 500 também mostra. Do contexto da requisição o log leva só método e rota, nunca query string, corpo ou argumentos (têm telefone e senha). Detalhes técnicos vão só para `error_log`, cujo destino é `LOG_FILE` ou o do `php.ini`.

**Modelo de dados** (detalhes em `docs/db/schema-explanation.md`):
- `customers` é **só o telefone**, global e único (só dígitos, 10–11, via `PhoneValidator::sanitize`); o mesmo cliente pode ter cartão em várias lojas.
Expand All @@ -56,7 +58,7 @@ Banco: `mysql -u root -p < database/schema.sql` em instalação nova. Banco já

**Área pública** — a home (`HomeController`, view `views/home.php`, `public/css/home.css`) apresenta o produto e leva ao cadastro; lojista logado vai direto ao painel. `customer/balance&loja=CODIGO` consulta o saldo pelo telefone sem login, **só na loja do código** (`merchants.public_code`, impresso no cartaz e embutido no QR), com limite por IP (5/min → 429) e exibindo só o primeiro nome do cartão daquela loja. Sem código válido, a página pede o código (nunca lista lojas). `privacy` é a política de privacidade (rascunho pendente de revisão jurídica).

**Limite de tentativas** — `App\Support\RateLimiter` conta tentativas na tabela `rate_limit_hits` (chave guardada só como hash SHA-256), então o limite sobrevive a apagar o cookie. Usos: login (5 erros em 15 min por e-mail ou IP → 429) e consulta pública (5/min por IP). O IP vem de `RateLimiter::clientIp()` (`App\Support\ClientIp`): é o `REMOTE_ADDR`, e o `X-Forwarded-For` só vale quando a requisição chega de um proxy listado em `TRUSTED_PROXIES` (o mesmo vale para o `X-Forwarded-Proto` em `RequestGuard::isHttps()`). Nunca leia esses cabeçalhos direto.
**Limite de tentativas** — `App\Support\RateLimiter` conta tentativas na tabela `rate_limit_hits` (chave guardada só como hash SHA-256), então o limite sobrevive a apagar o cookie. Usos: login (em 15 min, 5 erros do mesmo e-mail vindos do mesmo IP ou 20 erros do mesmo IP em qualquer conta → 429), troca de senha (5 senhas atuais erradas por conta) e consulta pública (5/min por IP). **O login nunca bloqueia só pelo e-mail**: isso deixaria qualquer pessoa trancar a dona da conta errando a senha de propósito (task 38). Nos buckets a chave do IP é `RateLimiter::clientKey()`, que em IPv6 vale pela rede /64 (um assinante tem o bloco inteiro e trocaria de endereço a cada tentativa). O endereço exato vem de `RateLimiter::clientIp()` (`App\Support\ClientIp`): é o `REMOTE_ADDR`, e o `X-Forwarded-For` só vale quando a requisição chega de um proxy listado em `TRUSTED_PROXIES` (o mesmo vale para o `X-Forwarded-Proto` em `RequestGuard::isHttps()`). Nunca leia esses cabeçalhos direto.

**Migrations** — `App\Support\Migrator` + `bin/migrate.php` registram em `schema_migrations` o nome de cada arquivo aplicado (o controle é pelo nome, não pelo maior número: migration de outro PR com número menor ainda roda). Migration nova = arquivo `NNN_descricao.sql` + a mesma mudança no `schema.sql` + o nome no `INSERT INTO schema_migrations` do fim do `schema.sql` (o `MigratorTest` cobra).

Expand Down
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Recentemente refatorado para adotar o padrão **MVC com separação clara de res

**Painel do lojista**
- ✅ **Cadastro de Lojistas** — Registro completo com validação de documentos (CPF/CNPJ), e-mail, telefone e confirmação de senha; máscaras de telefone e documento
- ✅ **Autenticação Segura** — Login com hash BCRYPT, renovação do ID de sessão, logout, bloqueio de contas inativas e limite de 5 erros em 15 min
- ✅ **Autenticação Segura** — Login com hash BCRYPT, renovação do ID de sessão, logout, bloqueio de contas inativas e limite de tentativas que trava só quem errou a senha, nunca a dona da conta
- ✅ **Atendimento pelo telefone** — A home é um único campo de telefone: cliente da loja vai direto para a tela do cliente; qualquer outro telefone vai para o cadastro rápido
- ✅ **Tela do cliente** — Nome e saldo no topo, progresso até o próximo prêmio, lançar pontos (atalhos +1/+5/+10) e resgatar os prêmios que o saldo já paga
- ✅ **Cadastro rápido** — Telefone já preenchido, só o nome e o consentimento do cliente (gravado com data e versão do texto)
Expand All @@ -33,6 +33,7 @@ Recentemente refatorado para adotar o padrão **MVC com separação clara de res
- ✅ **Clientes** — Lista paginada com busca por nome ou telefone; extrato de pontos de cada cliente
- ✅ **Relatórios** — Clientes, pontos emitidos, resgates, pontos em circulação e histórico paginado
- ✅ **Cartaz com QR code** — Pronto para imprimir, leva o cliente à consulta de saldo da loja
- ✅ **Perfil** — O lojista edita os dados da loja e troca a senha informando a atual; a troca encerra as sessões abertas em outros aparelhos
- ✅ **Primeiros passos** — Guia para a loja nova até o primeiro prêmio e os primeiros pontos
- ✅ **Funciona do celular ao PC** — Layout mobile-first (360 px a 1440 px), menu de celular e alvos de toque de 44 px

Expand All @@ -55,6 +56,7 @@ Recentemente refatorado para adotar o padrão **MVC com separação clara de res
| `merchant/rewards` · `merchant/reward-edit?id=` | lojista | Catálogo de prêmios e edição |
| `merchant/reports?page=` | lojista | Indicadores e histórico de movimentações |
| `merchant/poster` | lojista | Cartaz com QR code e código da loja |
| `merchant/profile` | lojista | Dados da loja e troca de senha |
| `customer/balance?loja=` | público | Consulta de saldo pelo telefone, na loja do código |

---
Expand Down Expand Up @@ -318,7 +320,7 @@ public function renderCustomer() {
- ✅ **CSRF** — Token por sessão em todos os formulários POST
- ✅ **XSS** — Toda saída nas views passa por `e()` (`htmlspecialchars`)
- ✅ **Sessão** — Cookie `HttpOnly` + `SameSite=Lax` e `session_regenerate_id()` no login
- ✅ **Limite de tentativas** — Login bloqueia com 429 após 5 erros em 15 min por e-mail ou IP; consulta pública limitada a 5/min por IP. Contagem no banco (sobrevive a apagar o cookie), com e-mail e IP guardados só como hash SHA-256
- ✅ **Limite de tentativas** — Login bloqueia com 429, por 15 min, quem errar 5 vezes a senha de uma conta (contado por e-mail + IP) ou 20 vezes somando todas as contas (por IP). O bloqueio nunca é só pelo e-mail, então errar a senha de uma loja de propósito não tranca a dona dela. Consulta pública limitada a 5/min por IP. Em IPv6 o limite vale pela rede /64. Contagem no banco (sobrevive a apagar o cookie), com e-mail e IP guardados só como hash SHA-256
- ✅ **Isolamento entre lojas** — Consultas filtram pelo `merchant_id` da sessão
- ✅ **Erros** — Detalhes técnicos vão para o log (sem query string nem dados de formulário); o usuário vê só as páginas de erro, com um código para localizar o erro no log
- ✅ **Atrás de proxy** — O IP do cliente só é lido do `X-Forwarded-For` quando a requisição vem de um proxy listado em `TRUSTED_PROXIES`
Expand Down Expand Up @@ -348,7 +350,8 @@ public function renderCustomer() {
### Próximos passos

- [ ] Análise estática (PHPStan) e padronização de código (PHP-CS-Fixer)
- [ ] Edição de perfil do lojista e recuperação de senha
- [x] Perfil do lojista e troca de senha
- [ ] Recuperação de senha por e-mail
- [ ] Máscaras de input no front-end (TypeScript)
- [ ] Planos Free/Pro com limites
- [ ] Área do cliente com login
Expand Down
4 changes: 2 additions & 2 deletions docs/db/schema-explanation.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,8 @@ merchants 1───N loyalty_cards N───1 customers
### `rate_limit_hits` — tentativas (limite de abuso)
| Coluna | Observação |
|---|---|
| `bucket` | Tipo de limite: `login_email`, `login_ip`, `balance_ip`. |
| `key_hash` | SHA-256 da chave (e-mail ou IP em minúsculas). O dado pessoal não é gravado. |
| `bucket` | Tipo de limite: `login_pair` (erros de login do mesmo e-mail vindos do mesmo IP), `login_ip` (erros de login do mesmo IP em qualquer conta), `balance_ip` (consulta pública) e `password_change` (senha atual errada na troca de senha, por conta). Não existe bloqueio de login só por e-mail. |
| `key_hash` | SHA-256 da chave em minúsculas (e-mail + IP, IP ou id da conta, conforme o bucket; IP em IPv6 entra como a rede /64). O dado pessoal não é gravado. |
| `created_at` | Cada linha é uma tentativa; só contam as que estão dentro da janela. Linhas com mais de 1 dia são apagadas aos poucos. |

### `schema_migrations` — controle das migrations
Expand Down
2 changes: 2 additions & 0 deletions docs/operacao.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ Para colocar na internet, ponha um proxy com HTTPS na frente (Nginx, Caddy, Trae

Atrás de proxy, a conexão que o PHP enxerga vem do proxy, não do cliente. Sem configurar, o limite de tentativas contaria todos os clientes como um só IP e o cookie de sessão não seria marcado como `Secure`.

Isso pesa no login: o bloqueio por tentativas vale para quem errou a senha (e-mail + IP), justamente para ninguém conseguir trancar a dona de uma conta errando a senha dela de propósito. Se todos os clientes aparecerem com o IP do proxy, esse cuidado se perde (o erro de um estranho volta a bloquear a dona) e 20 erros de login de qualquer pessoa bloqueiam o login de todas as lojas por 15 minutos. **Atrás de proxy, `TRUSTED_PROXIES` é obrigatório.**

`TRUSTED_PROXIES` lista quem pode informar o IP e o protocolo reais:

```
Expand Down
7 changes: 7 additions & 0 deletions public/css/components.css
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@
box-shadow: 0 0 0 3px var(--focus-ring);
}

/* campo so de leitura (ex.: e-mail e documento no perfil): mesma caixa, texto apagado */
.form-group input:disabled {
color: var(--text-muted);
background: var(--bg-body);
cursor: not-allowed;
}

/* Botão */
.btn-primary {
width: 100%;
Expand Down
1 change: 1 addition & 0 deletions public/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@
'rewards' => $controller->renderRewards(),
'reward-edit' => $controller->renderRewardEdit(),
'customers' => $controller->renderCustomers(),
'profile' => $controller->renderProfile(),
default => (new ErrorController())->handle(404),
};
break;
Expand Down
2 changes: 1 addition & 1 deletion src/Controllers/CustomerController.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ public function renderBalance() {
$data = ['store' => $store, 'phone' => '', 'card' => null, 'error' => null];

if ($isPost) {
if ($this->tooManyLookups(RateLimiter::clientIp())) {
if ($this->tooManyLookups(RateLimiter::clientKey())) {
(new ErrorController())->handle(429);
return;
}
Expand Down
12 changes: 12 additions & 0 deletions src/Controllers/ErrorController.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ class ErrorController {
public function handle($code = 404, $errorId = null) {
http_response_code($code);

// botao de voltar de todas as paginas de erro ($backUrl e $backLabel sao usados nas views).
// a sessao pode nem ter comecado (erro antes do session_start): ai vale como visitante.
[$backUrl, $backLabel] = self::backLink(isset($_SESSION['merchant_id']));

$viewPath = __DIR__ . "/../../views/errors/{$code}.php";

if (file_exists($viewPath)) {
Expand All @@ -16,4 +20,12 @@ public function handle($code = 404, $errorId = null) {
}
return;
}

// para onde o botao de voltar leva: lojista logado volta pro painel; qualquer outra pessoa
// (ex.: cliente na consulta publica de saldo) volta pra home publica, e nao pro login do lojista.
public static function backLink(bool $loggedIn): array {
return $loggedIn
? [url('merchant/dashboard'), 'Voltar ao Painel']
: [url('home'), 'Voltar ao início'];
}
}
Loading
Loading