Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ BACKUP_DIR=
BACKUP_KEEP_DAYS=
MYSQLDUMP_BIN=

# e-mail transacional (recuperacao de senha, confirmacao de e-mail). Vazio = nenhum e-mail sai e o log avisa
# (o provedor de producao ainda nao foi escolhido). Em desenvolvimento use MAIL_DRIVER=log: cada e-mail vira uma
# linha json em MAIL_LOG_FILE (vazio = storage/mail.log), com os links. Nunca use "log" em producao.
MAIL_DRIVER=
MAIL_LOG_FILE=

# so para o docker compose: senha do root do MySQL do container e porta publicada no host (vazio = 8080).
# No compose, DB_PASS acima e a senha do usuario "fidelityx" do banco e nao pode ficar vazia.
DB_ROOT_PASS=
Expand Down
15 changes: 13 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,23 +16,28 @@ composer dump-autoload # obrigatorio apos criar classe nova em s
php -S localhost:8000 -t public/ # servidor local (document root = public/)
find src views public tests bin -name "*.php" -exec php -l {} \; # checagem de sintaxe
composer test # phpunit (unidade + integração + fluxo); recria o banco fidelityx_test
composer analyse # phpstan nível 5 (phpstan.neon) com baseline; erro novo quebra
composer audit # dependências com falha de segurança conhecida
composer test -- --testsuite Unit # só os testes sem banco
node tests/js/masks.test.js # testes das mascaras (public/js/masks.js), sem navegador
node tests/js/forms.test.js # testes do public/js/forms.js (envio e campo com erro), sem navegador
node tests/js/customer.test.js # conta do valor da compra do public/js/customer.js (igual ao Money::toCents)
tsc # compila src/ts -> public/js (tsconfig.json)
php bin/migrate.php # roda as migrations pendentes (status | baseline NNN)
php bin/backup.php # backup do banco (mysqldump .sql.gz em BACKUP_DIR)
php bin/create-admin.php email "Nome" # cria um administrador (senha pela entrada padrão)
docker compose up -d --build # aplicacao + mysql em docker (docs/operacao.md)
```

Banco: `mysql -u root -p < database/schema.sql` em instalação nova. Banco já existente: `php bin/migrate.php` roda as migrations de `database/migrations/` que ainda não rodaram (banco anterior ao controle: antes, uma vez, `php bin/migrate.php baseline 003`); o `schema.sql` sempre reflete o estado final. Credenciais em `.env` ou em variáveis de ambiente (`DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASS`; `APP_URL` opcional: endereço público usado no QR do cartaz, vazio usa o host da requisição). No ambiente local do autor o PHP e o MySQL vêm do XAMPP (`C:\xampp\mysql\bin\mysql.exe`).

**Testes (PHPUnit 10.5)** em `tests/`: `Unit` (validators e classes de `src/Support`, sem banco), `Integration` (models, `RateLimiter`, `Migrator` e backup contra o banco) e `Feature` (fluxo completo por HTTP: o teste sobe um `php -S` próprio numa porta livre e usa cookie + `_csrf` como o navegador). O bootstrap **apaga e recria** o banco `fidelityx_test` a partir do `schema.sql` (o `phpunit.xml` força esse nome e o bootstrap recusa nome que não termine em `_test`); credenciais vêm do `.env` local ou das variáveis de ambiente no CI. Cada teste começa com as tabelas vazias (`DatabaseTestCase`). Regra nova de negócio ou bug corrigido = teste junto. O GitHub Actions (`.github/workflows/ci.yml`) roda lint + PHPUnit + `tests/js/masks.test.js` em PHP 8.1 e 8.3 com MySQL 8 em todo PR e push na `main`, **sem `.env`** (só variáveis de ambiente, como em produção), e um job à parte recompila o `masks.ts` e falha se o `masks.js` versionado não bater. O MySQL do XAMPP roda sem `sql_mode` estrito (trunca texto longo sem erro), o do CI é estrito: não escreva teste que dependa disso.

**Análise estática** (task 51): PHPStan nível 5 em `src`, `views`, `bin` e `public`, com `phpstan-baseline.neon` guardando o que já existia (hoje só os "might not be defined" das views, que recebem as variáveis pelo `extract()`). Código novo não entra na baseline: corrija o erro. Variável nova numa view ou erro antigo corrigido: `composer analyse -- --generate-baseline`. Rodar no CI, `composer audit` no workflow e Dependabot ainda dependem de mudar o `.github/` (pendente).

## Arquitetura

**Roteamento** — tudo entra por `public/index.php` com `?url=dominio/acao`. Um `switch` no domínio (`merchant`, `customer`, mais `home` e `privacy` sem ação) instancia o controller e um `match` na ação chama o método `render*()`. Rota nova = novo braço no `match` + método no controller. O `index.php` abre a conexão com o banco antes de rotear, então sem MySQL (ou sem as variáveis obrigatórias do banco) toda rota devolve 503.
**Roteamento** — tudo entra por `public/index.php` com `?url=dominio/acao`. Um `switch` no domínio (`merchant`, `customer`, `admin`, mais `home` e `privacy` sem ação) instancia o controller e um `match` na ação chama o método `render*()`. Rota nova = novo braço no `match` + método no controller. A exceção é `?url=health` (`App\Support\Health`, task 56): responde antes da sessão, só `ok`/`erro` em JSON, conferindo o banco. O `index.php` abre a conexão com o banco antes de rotear, então sem MySQL (ou sem as variáveis obrigatórias do banco) toda rota devolve 503.

**Configuração** — `App\Support\Env::load()` (chamado pelo `index.php`, pelo bootstrap dos testes e pelos scripts de `bin/`) põe tudo no `$_ENV`: variável de ambiente real primeiro, e o `.env`, se existir, só completa o que falta (`safeLoad`: a aplicação sobe sem o arquivo). Variável nova entra em `Env::KEYS` e no `.env.example`; leia com `Env::get()` ou `$_ENV`, nunca com `getenv()` direto.

Expand All @@ -44,12 +49,16 @@ Banco: `mysql -u root -p < database/schema.sql` em instalação nova. Banco já

**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`. 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.
**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. Conta com `merchants.email_verified_at` vazio (cadastro novo, task 50) entra, mas o `authGuard` manda toda rota para `merchant/confirm-email` (reenvia o link, 3/h); só essa tela chama `authGuard(true)`. O link `merchant/verify-email&token=...` (`EmailVerificationModel`, 24 h, uma vez) não exige login. Os links por e-mail (confirmação e nova senha) herdam de `MerchantTokenModel`.

**Balcão** — a home do painel (`merchant/dashboard`) busca o cliente pelo telefone e mostra os 5 últimos atendidos (só cartão com movimentação). Na tela do cliente (`merchant/customer`) o lançamento é em pontos ou, se a loja tem regra (`merchants.points_rule_cents`, configurada em `merchant/points-rule`), pelo valor da compra: `Money::toCents` lê o valor digitado e `Money::pointsFor` converte arredondando para baixo. Depois de lançar, a tela mostra o novo saldo com **Desfazer** por `UNDO_SECONDS` (30 s; o aviso só aparece se o lançamento tiver até `UNDO_BANNER_SECONDS`). O estorno (`action=reverse`, do Desfazer ou do extrato `merchant/statement`) vale por `LoyaltyCardModel::REVERSAL_WINDOW_HOURS` (24 h) e uma vez por movimentação: lançamento só se o saldo atual cobrir os pontos; resgate (task 44, só pelo extrato) devolve os pontos ao saldo sem mexer no total acumulado e deixa de contar nos relatórios. A tela do cliente também corrige o nome e move o cartão para um telefone novo (`LoyaltyCardModel::rename` / `changePhone`, task 43): só o cartão desta loja muda, telefone que já tem cartão nesta loja é recusado e o número antigo some quando não sobra cartão dele. Listas longas (clientes, extrato, relatórios) paginam com `App\Support\Paginator` + `views/partials/pagination.php`, 20 por página. Os relatórios filtram por período (`App\Support\Period`: hoje, 7 dias, mês, intervalo; o saldo em circulação é sempre o atual) e `merchant/export` baixa CSV do histórico do período ou da lista de clientes (`App\Support\Csv`: `;` + BOM, texto que começa com `=`/`+`/`-`/`@` ganha `'` contra injeção de fórmula), sempre só desta loja (task 59).

**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 a nova não pode ser igual à atual). Cadastro e troca seguem `App\Support\PasswordPolicy`: de 8 a 72 **bytes** (o limite do bcrypt); o login não confere tamanho, então senha antiga fora da regra continua entrando. 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.

**Administração** (task 30) — `admin/login` e `admin/dashboard` (`AdminController`): tabela `admins` própria, conta criada só pela linha de comando (`php bin/create-admin.php email "Nome"`, senha pela entrada padrão). Sessão separada da do lojista (`admin_id`, `admin_sig`, expira com 1 h parada) e o mesmo limite de tentativas do login do lojista (buckets `admin_login_*`). O admin vê as lojas (dados da loja e quantos clientes têm, nunca os clientes) e ativa/desativa uma loja (`MerchantModel::setStatus`); desativada perde o acesso na hora.

**Esqueci a senha** (task 21) — `merchant/forgot` manda por e-mail um link `merchant/reset&token=...` (`PasswordResetModel`, sobre o `MerchantTokenModel`: token de 32 bytes aleatórios, só o SHA-256 no banco, vale `TTL_SECONDS` = 1 h e uma vez; pedir outro cancela o anterior). A resposta do pedido é sempre a mesma, exista o e-mail ou não; limite de 10 pedidos/h por IP e 3/h por e-mail. Trocar a senha pelo link derruba as sessões abertas (mesmo `password_sig`).

**Proteções de toda requisição** — o `index.php` chama `RequestGuard` antes de rotear: cabeçalhos de segurança (`X-Frame-Options: DENY`, CSP `RequestGuard::CSP` com `default-src 'self'` e sem nada inline, HSTS em HTTPS via `RequestGuard::sendHsts` depois do `Env::load`, `nosniff`, `Referrer-Policy: same-origin`, sem `X-Powered-By`), `Cache-Control: no-store` em toda rota `merchant/*` e `customer/*` (`RequestGuard::sendNoStore`, depois do `session_start`) 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`) 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`.
Expand All @@ -65,6 +74,8 @@ Banco: `mysql -u root -p < database/schema.sql` em instalação nova. Banco já

**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). O bucket `login_account` (por conta, somando todos os IPs) só gera aviso no log ao chegar em `LoginGuard::MAX_FAILURES_PER_ACCOUNT` (50); o CAPTCHA que deve entrar aí ainda não foi escolhido (task 45). E-mail inexistente roda `password_verify` contra `LoginGuard::DUMMY_HASH` (mesmo tempo da senha errada), e o login gera token CSRF novo (`Csrf::regenerate`). 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.

**E-mail** (task 55) — envie sempre por `App\Mail\MailerFactory::fromEnv()->send(new Message(...))`, nunca com `mail()` direto. `MAIL_DRIVER` vazio = `NullMailer` (nada sai, o log registra só assunto e hash do destinatário); `log` = `LogMailer`, que grava o e-mail inteiro em `MAIL_LOG_FILE` (dev e testes: o `HttpTestCase` liga e o teste lê com `sentMails()`). O provedor de produção ainda não foi escolhido.

**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).

**Produção** — `Dockerfile` + `docker-compose.yml` (Apache, PHP 8.3, MySQL 8; `docker/php.ini` desliga `display_errors`), backup por `bin/backup.php` (`App\Support\Backup`) e o guia em `docs/operacao.md`. Mudou variável de ambiente, comando de operação ou volume: atualize esse guia.
Expand Down
51 changes: 51 additions & 0 deletions bin/create-admin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php
// cria um administrador do FidelityX (task 30), que entra em /index.php?url=admin/login.
//
// php bin/create-admin.php email@dominio.com "Nome"
//
// a senha e pedida no terminal (ou lida da entrada: echo "senha" | php bin/create-admin.php ...),
// nunca vai como argumento (ficaria no historico do shell).
// usa o banco do .env / variaveis de ambiente (DB_NAME).

require __DIR__ . '/../vendor/autoload.php';

use App\Database;
use App\Models\AdminModel;
use App\Support\Env;

if (PHP_SAPI !== 'cli') {
exit(1);
}

Env::load(dirname(__DIR__));
date_default_timezone_set(app_timezone());

$email = trim((string)($argv[1] ?? ''));
$name = trim((string)($argv[2] ?? ''));
if ($email === '' || $name === '') {
fwrite(STDERR, "uso: php bin/create-admin.php email@dominio.com \"Nome\"\n");
exit(1);
}

fwrite(STDOUT, 'senha: ');
$password = rtrim((string)fgets(STDIN), "\r\n");

$problem = AdminModel::problem($email, $name, $password);
if ($problem !== null) {
fwrite(STDERR, "erro: $problem\n");
exit(1);
}

try {
if (Env::missing() !== []) {
throw new RuntimeException('variaveis obrigatorias sem valor: ' . implode(', ', Env::missing()));
}
$id = (new AdminModel(Database::connect()))->create($email, $name, $password);
fwrite(STDOUT, "\nadmin criado (id $id). entre em /index.php?url=admin/login\n");
} catch (PDOException $e) {
fwrite(STDERR, (($e->errorInfo[1] ?? null) === 1062 ? 'erro: ja existe admin com esse e-mail' : 'erro no banco: ' . $e->getMessage()) . "\n");
exit(1);
} catch (Throwable $e) {
fwrite(STDERR, 'erro: ' . $e->getMessage() . "\n");
exit(1);
}
6 changes: 4 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,16 @@
}
},
"scripts": {
"test": "phpunit"
"test": "phpunit",
"analyse": "phpstan analyse --memory-limit=1G --no-progress"
},
"config": {
"platform": {
"php": "8.1.0"
}
},
"require-dev": {
"phpunit/phpunit": "^10.5"
"phpunit/phpunit": "^10.5",
"phpstan/phpstan": "^2.1"
}
}
66 changes: 65 additions & 1 deletion composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading