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
11 changes: 9 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ node tests/js/customer.test.js # conta do valor da compra do public/js/c
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)
```

Expand All @@ -36,7 +37,7 @@ Banco: `mysql -u root -p < database/schema.sql` em instalação nova. Banco já

## 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. 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.
**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 @@ -48,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 @@ -69,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);
}
27 changes: 27 additions & 0 deletions database/migrations/008_password_resets.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
-- fidelityx - migration 008 (recuperacao de senha por e-mail, task 21)
-- para bancos criados antes desta versao do schema.sql. rodar uma unica vez:
-- php bin/migrate.php
-- (ou, a mao: mysql -u root -p fidelityx < database/migrations/008_password_resets.sql)

USE fidelityx;

-- link de redefinicao de senha: o token vai so no e-mail; aqui fica o hash sha-256 dele
-- (quem ler o banco nao consegue usar). vale 1 hora e uma vez so.
CREATE TABLE IF NOT EXISTS password_resets (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,

merchant_id BIGINT UNSIGNED NOT NULL,
token_hash CHAR(64) NOT NULL,
expires_at TIMESTAMP NOT NULL,
used_at TIMESTAMP NULL, -- usado ou cancelado (pedido novo ou senha trocada)

created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,

UNIQUE KEY uq_password_resets_token (token_hash),
KEY idx_password_resets_merchant (merchant_id),

CONSTRAINT fk_password_resets_merchant
FOREIGN KEY (merchant_id) REFERENCES merchants(id)
ON DELETE CASCADE
ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
32 changes: 32 additions & 0 deletions database/migrations/009_email_verifications.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
-- fidelityx - migration 009 (confirmacao do e-mail do lojista, task 50)
-- para bancos criados antes desta versao do schema.sql. rodar uma unica vez:
-- php bin/migrate.php
-- (ou, a mao: mysql -u root -p fidelityx < database/migrations/009_email_verifications.sql)

USE fidelityx;

-- conta nova so usa o painel depois de confirmar o e-mail (NULL = ainda nao confirmou)
ALTER TABLE merchants ADD COLUMN email_verified_at TIMESTAMP NULL AFTER email;

-- contas que ja existiam contam como confirmadas: nao da pra travar quem ja usa o sistema
UPDATE merchants SET email_verified_at = created_at WHERE email_verified_at IS NULL;

-- link de confirmacao: como o de nova senha, so o hash do token fica aqui. vale 24 horas e uma vez.
CREATE TABLE IF NOT EXISTS email_verifications (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,

merchant_id BIGINT UNSIGNED NOT NULL,
token_hash CHAR(64) NOT NULL,
expires_at TIMESTAMP NOT NULL,
used_at TIMESTAMP NULL, -- usado ou cancelado (reenvio)

created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,

UNIQUE KEY uq_email_verifications_token (token_hash),
KEY idx_email_verifications_merchant (merchant_id),

CONSTRAINT fk_email_verifications_merchant
FOREIGN KEY (merchant_id) REFERENCES merchants(id)
ON DELETE CASCADE
ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
21 changes: 21 additions & 0 deletions database/migrations/010_admins.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
-- fidelityx - migration 010 (painel administrativo, task 30)
-- para bancos criados antes desta versao do schema.sql. rodar uma unica vez:
-- php bin/migrate.php
-- (ou, a mao: mysql -u root -p fidelityx < database/migrations/010_admins.sql)

USE fidelityx;

-- administradores do FidelityX (nao sao lojistas): login proprio em admin/login, conta criada
-- pela linha de comando (php bin/create-admin.php). veem as lojas, nunca os clientes delas.
CREATE TABLE IF NOT EXISTS admins (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,

email VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NOT NULL,

created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,

UNIQUE KEY uq_admins_email (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
Loading
Loading