Bem-vindo à Vupi.us API
Uma plataforma modular, segura e moderna desenvolvida em PHP puro. Simplicidade por fora, poder por dentro.
O que é a Vupi.us API?
A Vupi.us API é um framework PHP modular de alta performance, projetado para construir APIs REST robustas sem a complexidade de frameworks pesados. Cada funcionalidade vive em um módulo independente — uma pasta em src/Modules/.
Crie uma pasta em src/Modules/ e o sistema a detecta automaticamente. Sem registro manual, sem arquivos de configuração centralizados.
Recursos principais
Arquitetura Modular
Cada módulo é independente. Adicione, remova ou desative sem afetar o restante do sistema.
Segurança por Padrão
JWT com rotação de chaves, rate limiting, ThreatScorer, CSP, HSTS e proteção contra OWASP Top 10.
Multi-banco
Suporte nativo a PostgreSQL e MySQL. Dois bancos simultâneos: core e módulos externos.
Injeção de Dependência
Container automático com resolução recursiva. Declare no construtor, o sistema entrega.
CLI Poderosa
Setup automático, geração de módulos, migrations, seeds e gerenciamento de plugins via terminal.
Dashboard Admin
Interface web para monitoramento, gerenciamento de módulos e configuração do ambiente em tempo real.
Stack tecnológica
| Componente | Tecnologia |
|---|---|
| Linguagem | PHP 8.2+ |
| Banco de dados | PostgreSQL 15+ / MySQL 8+ |
| Cache / Rate Limit | Redis (opcional) / File storage |
| Proxy reverso | Caddy (TLS automático) |
| Autenticação | JWT (Firebase JWT) |
| Gerenciador de deps | Composer |
Instalação
Do zero ao servidor rodando em minutos.
Pré-requisitos
Antes de começar, certifique-se de ter instalado:
| Requisito | Versão mínima | Verificar |
|---|---|---|
| PHP | 8.2+ | php -v |
| Composer | 2.x | composer -V |
| Git | 2.x | git --version |
| PostgreSQL ou MySQL | 15+ / 8+ | psql -V / mysql -V |
Instalar PHP 8.2 (Ubuntu)
sudo apt update
sudo apt install -y software-properties-common
sudo add-apt-repository ppa:ondrej/php
sudo apt update
sudo apt install -y php8.2 php8.2-cli php8.2-common php8.2-curl php8.2-mbstring php8.2-xml php8.2-zip
Instalar Composer
curl -sS https://getcomposer.org/installer | php
sudo mv composer.phar /usr/local/bin/composer
Instalar extensões de banco de dados
Para PostgreSQL:
sudo apt-get install php8.2-pgsql
# Windows: habilite 'extension=pgsql' e 'extension=pdo_pgsql' no php.ini
Para MySQL:
sudo apt-get install php8.2-mysql
# Windows: habilite 'extension=mysqli' e 'extension=pdo_mysql' no php.ini
Banco de dados com Docker (alternativa recomendada)
Se não quiser instalar PostgreSQL ou MySQL localmente, use o Docker. O projeto já vem com um docker-compose.yml pronto.
Instalar Docker + Compose (Ubuntu)
# Remove versões antigas
sudo apt-get remove -y docker docker-engine docker.io containerd runc
# Instala dependências
sudo apt-get update
sudo apt-get install -y ca-certificates curl gnupg
# Adiciona chave GPG oficial do Docker
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
# Adiciona repositório
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
# Instala Docker Engine + Compose plugin
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
# Permite rodar sem sudo (requer logout/login)
sudo usermod -aG docker $USER
Windows / macOS
Instale o Docker Desktop — já inclui o Compose.
Subir o banco com docker-compose.yml
Com o repositório clonado, escolha o banco que preferir:
# Apenas PostgreSQL (recomendado)
docker compose up -d postgres
# Apenas MySQL
docker compose up -d mysql
# PostgreSQL + Adminer (interface web na porta 8080)
docker compose up -d postgres adminer
# Tudo (PostgreSQL + MySQL + Adminer)
docker compose up -d
Com o Adminer rodando, acesse http://localhost:8080 para gerenciar o banco via interface web. Servidor: postgres, usuário: admin, senha: conforme DB_SENHA no .env.
Parar os containers
docker compose down # para e remove containers (dados preservados)
docker compose down -v # para e remove containers + volumes (apaga dados)
O Docker Compose lê as variáveis DB_NOME, DB_USUARIO, DB_SENHA e APP_TIMEZONE diretamente do seu .env. Configure-as antes de subir os containers.
Clonar o repositório
git clone https://github.com/vupi.us/api_vupi.us_php.git
cd api_vupi.us_php
Instalar dependências
composer install
Setup automático (recomendado)
O projeto possui um comando de setup completo com menu interativo:
php vupi setup
O menu interativo permite:
- Gerar secrets JWT automaticamente
- Criar banco de dados via Docker
- Executar migrations e seeders
- Subir o servidor PHP em background
- Configurar Caddy para HTTPS automático
Ou tudo de uma vez sem interação:
php vupi setup --auto --db-mode=docker --server=php --jwt=if-empty
php vupi setup --help lista todas as flags disponíveis.
Setup manual (passo a passo)
Copiar o arquivo de ambiente
cp EXEMPLO.env .env
# Windows:
copy EXEMPLO.env .env
Editar o .env com suas configurações
Preencha pelo menos: DB_HOST, DB_NOME, DB_USUARIO, DB_SENHA, JWT_SECRET e JWT_API_SECRET.
Veja a seção Configuração do Ambiente para detalhes de cada variável.
Executar migrations
php vupi migrate --seed
Instalação rápida (Ubuntu)
Para Ubuntu 22.04/24.04, um único script instala tudo:
git clone https://github.com/vupi.us/api_vupi.us_php.git
cd api_vupi.us_php
sudo bash install.sh
Instala PHP 8.2, Docker, drivers de banco, Composer, sobe o banco, roda migrations e seeders, e inicia o servidor — tudo automaticamente.
Configuração do Ambiente
Todas as configurações ficam no arquivo .env. Nunca edite o código para mudar senhas ou chaves.
Criar o arquivo .env
cp EXEMPLO.env .env
O arquivo .env já está no .gitignore. Ele contém senhas e secrets — nunca deve ser versionado.
Aplicação
| Variável | Exemplo | Descrição |
|---|---|---|
APP_NAME | "Vupi.us API" | Nome da aplicação exibido no dashboard e e-mails. |
APP_ENV | production | production, development ou testing. Em produção ativa validações extras de segurança. |
APP_DEBUG | false | true exibe stack traces. Sempre false em produção. |
APP_URL | https://api.vupi.us | URL base da API. Usada em e-mails, CORS e sitemap. |
APP_URL_FRONTEND | https://meusite.com | URL do frontend. Adicionada automaticamente ao CORS. |
APP_PORT | 3005 | Porta do servidor PHP. |
APP_TIMEZONE | America/Bahia | Fuso horário para datas e logs. |
CORS_ALLOWED_ORIGINS | https://meusite.com | Origens permitidas para CORS, separadas por vírgula. |
TRUST_PROXY | true | true quando há proxy reverso (Caddy/Nginx). Confia em X-Forwarded-Proto. |
Banco de dados (core)
| Variável | Exemplo | Descrição |
|---|---|---|
DB_CONEXAO | postgresql | Driver: postgresql ou mysql. |
DB_HOST | localhost | Host do banco de dados. |
DB_PORT | 5432 | Porta. PostgreSQL: 5432, MySQL: 3306. |
DB_NOME | vupi_db | Nome do banco de dados. |
DB_USUARIO | admin | Usuário do banco. |
DB_SENHA | senha_forte | Senha do banco. Mínimo 16 caracteres em produção com banco remoto. |
Banco de dados (modules) — opcional
Permite que módulos externos usem um banco separado do core. Deixe DB2_NOME vazio para usar o mesmo banco do core.
| Variável | Descrição |
|---|---|
DB2_CONEXAO | Driver do segundo banco (postgresql ou mysql). |
DB2_HOST, DB2_PORT | Host e porta do segundo banco. |
DB2_NOME | Nome do banco. Se vazio, usa o banco core. |
DB2_USUARIO, DB2_SENHA | Credenciais do segundo banco. |
JWT e Segurança
JWT_SECRET e JWT_API_SECRET devem ter no mínimo 32 caracteres. O sistema recusa iniciar em produção com valores fracos.
| Variável | Exemplo | Descrição |
|---|---|---|
JWT_SECRET | abc123...64chars | Secret para tokens de usuários. Gere com openssl rand -hex 32. |
JWT_API_SECRET | xyz789...64chars | Secret para tokens de admin_system. Deve ser diferente do JWT_SECRET. |
JWT_ISSUER | https://api.vupi.us | Identificador do emissor do token (claim iss). Validado em toda requisição. |
JWT_AUDIENCE | https://api.vupi.us | Audiência do token (claim aud). Validado em toda requisição. |
JWT_EXPIRATION_TIME | 900 | Expiração do access token em segundos (padrão: 15 min). |
REFRESH_TOKEN_EXPIRATION_SECONDS | 2592000 | Expiração do refresh token (padrão: 30 dias). |
COOKIE_SECURE | true | true em produção com HTTPS. Cookies só enviados via HTTPS. |
COOKIE_SAMESITE | Lax | Lax (mesmo domínio) ou None (domínios diferentes, requer Secure=true). |
E-mail (SMTP)
| Variável | Exemplo | Descrição |
|---|---|---|
MAILER_HOST | smtp.gmail.com | Servidor SMTP. |
MAILER_PORT | 587 | Porta SMTP. 587 para TLS, 465 para SSL. |
MAILER_USERNAME | [email protected] | Usuário SMTP. |
MAILER_PASSWORD | app_password | Senha SMTP. Para Gmail, use uma App Password. |
MAILER_FROM_EMAIL | [email protected] | E-mail remetente. |
MAILER_FROM_NAME | "Vupi.us API" | Nome do remetente. |
Redis (opcional)
Configure Redis para habilitar rate limiting distribuído entre múltiplos containers. Sem Redis, o sistema usa armazenamento em arquivo (servidor único).
| Variável | Padrão | Descrição |
|---|---|---|
REDIS_HOST | vazio | Host do Redis. Deixe vazio para usar file storage. |
REDIS_PORT | 6379 | Porta do Redis. |
REDIS_PASSWORD | vazio | Senha do Redis (se configurada). |
REDIS_PREFIX | vupi: | Prefixo das chaves no Redis. |
Gerar secrets JWT
# Gera JWT_SECRET (64 chars hex)
openssl rand -hex 32
# Gera JWT_API_SECRET (64 chars hex)
openssl rand -hex 32
# Ou via CLI do projeto (gera automaticamente se vazios)
php vupi setup --auto --jwt=if-empty
Arquitetura do Sistema
Entenda como as peças se encaixam.
Visão geral
A Vupi.us API é organizada em duas camadas principais:
Fluxo de uma requisição
index.php — Entry point
Toda requisição chega aqui. O arquivo carrega o .env, monta o Container de DI, registra os bindings e inicializa a Application.
Application::run()
Executa o pipeline de segurança global: HttpsEnforcer → BotBlocker → SecurityHeaders → dispatch da rota.
Router::dispatch()
Encontra a rota correspondente e executa os middlewares em cadeia (onion model). Cada middleware pode bloquear ou passar adiante.
Controller → Service → Repository
O Controller recebe a Request, delega ao Service (regras de negócio), que usa o Repository (acesso ao banco). Retorna um Response.
Response::Enviar()
A resposta percorre o pipeline de volta, headers de segurança são injetados, e o JSON é enviado ao cliente.
Módulos nativos
O sistema vem com dois módulos pré-instalados em src/Modules/:
| Módulo | Responsabilidade |
|---|---|
| Auth | Login, logout, refresh token, recuperação de senha, verificação de e-mail. |
| Usuario | Registro, perfil, gerenciamento de usuários (admin), upload de avatar. |
Camada Kernel
O núcleo do sistema. Entenda cada componente de src/Kernel/.
Você raramente precisará modificar o Kernel. Ele foi projetado para ser estável e extensível via módulos.
Nucleo/ — O coração
Container.php
Container de Injeção de Dependência. Resolve classes automaticamente via Reflection. Suporta singletons, bindings e resolução recursiva.
Router.php
Roteador HTTP. Suporta GET, POST, PUT, PATCH, DELETE. Parâmetros dinâmicos ({uuid}), middlewares por rota e pipeline em cadeia.
Application.php
Orquestra o boot: carrega módulos, registra rotas, executa BotBlocker e SecurityHeaders globalmente, despacha a requisição.
ModuleLoader.php
Descobre e carrega módulos automaticamente de src/Modules/. Chama boot() e registerRoutes() de cada provider.
PluginManager.php
Gerencia plugins externos (instalação, ativação, desativação, remoção). Persiste estado em storage/.
CapabilityResolver.php
Sistema de capacidades. Permite que módulos declarem e consumam serviços (ex: email-sender) sem acoplamento direto.
Middlewares/
Todos os middlewares implementam MiddlewareInterface e seguem o padrão onion (cebola).
| Middleware | Função |
|---|---|
AuthHybridMiddleware | Autentica via JWT (cookie ou header Authorization). Valida assinatura, claims, blacklist e UUID. |
AdminOnlyMiddleware | Restringe acesso a admin_system com token assinado por JWT_API_SECRET. |
AuthCookieMiddleware | Autentica exclusivamente via cookie auth_token. Valida JWT e blacklist. |
AuthPageMiddleware | Autenticação para páginas HTML do dashboard. Redireciona para / se não autenticado. |
OptionalAuthHybridMiddleware | Autentica se token presente, mas não obriga. Útil para rotas públicas com dados extras para autenticados. |
ApiTokenMiddleware | Autenticação via token de API (tipo api no JWT, assinado com JWT_API_SECRET). |
RateLimitMiddleware | Rate limiting por IP + usuário. Redis (distribuído) ou File (servidor único). Configurável por rota. |
BotBlockerMiddleware | Bloqueia User-Agents de scanners (sqlmap, nikto, nmap...). Aplica delay progressivo por ThreatScore. |
SecurityHeadersMiddleware | Injeta CSP, HSTS, X-Frame-Options, CORP, COEP, COOP em todas as respostas globalmente. |
CircuitBreakerMiddleware | Protege contra falhas em cascata. Estados: CLOSED → OPEN → HALF. Persiste em Redis ou File. |
RouteProtectionMiddleware | Proteção genérica de rotas com validação de JWT e roles. Aceita token de usuário ou de API. |
HttpsEnforcerMiddleware | Redireciona HTTP → HTTPS quando COOKIE_SECURE=true. |
Como usar um middleware em uma rota
// Rota pública — sem middleware
$router->get('/api/status', [StatusController::class, 'index']);
// Rota autenticada
$router->get('/api/perfil', [PerfilController::class, 'index'], [
AuthHybridMiddleware::class,
]);
// Rota admin com rate limit
$router->post('/api/usuarios', [UsuarioController::class, 'criar'], [
AuthHybridMiddleware::class,
AdminOnlyMiddleware::class,
[RateLimitMiddleware::class, ['limit' => 5, 'window' => 60, 'key' => 'usuario.criar']],
]);
Support/ — Utilitários
| Arquivo | Função |
|---|---|
JwtDecoder.php | Decodifica e valida tokens JWT. Suporta key rotation via kid, validação de iss e aud. |
ThreatScorer.php | Acumula pontos de comportamento suspeito por IP. Threshold 150 = bloqueio. TTL 1h. |
AuditLogger.php | Registra eventos de segurança no banco e stderr (Fail2Ban). Detecta brute force automaticamente. |
SecurityEventLogger.php | Logger estruturado JSON para THREAT, AUTH, BUSINESS_LOGIC, ABUSE. Integra com Datadog/CloudWatch. |
IdempotencyLock.php | Distributed lock via Redis SET NX ou flock. Previne race conditions em operações críticas. |
IpResolver.php | Resolve IP real do cliente respeitando TRUST_PROXY e X-Forwarded-For. |
OwnershipGuard.php | Verifica se o usuário autenticado é dono do recurso. Previne IDOR. |
MailerService.php | Envio de e-mails via SMTP (PHPMailer). Suporta HTML, templates e throttling. |
EmailHistory.php | Persiste historico de e-mails enviados. Busca, filtra e deleta registros. |
EmailThrottle.php | Controla throttle de envio por tipo e e-mail. Cooldown configuravel (padrao 120s). |
CookieConfig.php | Centraliza configuracao de cookies. Detecta HTTPS via porta, header e TRUST_PROXY. |
TokenExtractor.php | Extrai token JWT do header Authorization: Bearer ou X-API-KEY. |
RequestContext.php | Contexto da requisicao: request_id, IP, user_agent. Injetado em Logger e AuditLogger. |
Logger.php | Logger estruturado JSON em stderr. Suprime logs em testing. Compativel com Docker/K8s. |
Storage/ | RateLimitStorageFactory, RedisRateLimitStorage, FileRateLimitStorage. |
DB/ | Migrator, PluginMigrator - execucao de migrations do core e plugins. |
Utils/ - Utilitarios de dominio
| Arquivo | Funcao |
|---|---|
Sanitizer.php | Sanitizacao de inputs: string, email, username, positiveInt, nivelAcesso, uuid, search, url, text, password. |
ImageProcessor.php | Redimensiona e salva imagens (JPEG, PNG, WebP). Preserva transparencia. Fallback se GD indisponivel. |
RelogioTimeZone.php | Singleton para timezone. Le APP_TIMEZONE do .env. Retorna DateTimeImmutable com timezone correto. |
Http/ — Request e Response
// Response JSON
return Response::json(['status' => 'ok', 'data' => $dados]);
return Response::json(['error' => 'Não encontrado'], 404);
// Response HTML
return Response::html($html);
// Acessar dados da requisição
$body = $request->body; // array do JSON body
$query = $request->query; // query string params
$usuario = $request->attribute('auth_user'); // injetado pelo AuthMiddleware
$uuid = $request->param('uuid'); // parâmetro de rota {uuid}
Contracts/ — Interfaces
As interfaces definem contratos que o Kernel usa internamente. Módulos podem implementá-las para integrar com o sistema:
| Interface | Implementação padrão |
|---|---|
UserRepositoryInterface | Modules/Usuario/Repositories/UsuarioRepository |
TokenBlacklistInterface | Modules/Auth/Repositories/AccessTokenBlacklistRepository |
EmailSenderInterface | Kernel/Support/MailerService |
RateLimitStorageInterface | RedisRateLimitStorage ou FileRateLimitStorage |
ModuleProviderInterface | Implementada por cada módulo em seu ServiceProvider |
RouterInterface | Kernel/Nucleo/Router |
ContainerInterface | Kernel/Nucleo/Container |
MiddlewareInterface | Todos os middlewares em Kernel/Middlewares/ |
TenantResolverInterface | Resolução de tenant (subdomínio, path, header, JWT) |
Módulos
Onde sua aplicação vive. Cada pasta em src/Modules/ é um módulo independente.
O conceito Zero Config
No Vupi.us, uma pasta é um módulo. Não existe registro manual. O ModuleLoader varre src/Modules/ automaticamente e carrega tudo que encontrar.
Crie a pasta → crie o ServiceProvider → crie o Routes/web.php. O sistema faz o resto.
Estrutura de um módulo
Criando um Módulo
Guia completo para construir o módulo Usuario do zero — idêntico ao módulo nativo do sistema.
Ao seguir todos os passos, você terá o módulo Usuario completo: registro, perfil, gerenciamento admin, alteração de senha/e-mail, upload de avatar, verificação de e-mail e perfil público — exatamente como o módulo nativo.
Arquivos que serão criados
Passo 1 — Criar a estrutura de pastas
mkdir -p src/Modules/Usuario/{Controllers,Services,Repositories,Entities,Exceptions,Database/Migrations,Database/Seeders,Routes}
Ou via CLI:
php vupi make:module Usuario
Passo 2 — Criar as Exceptions
O módulo usa exceções próprias para erros de domínio. Crie os quatro arquivos abaixo.
Arquivo: src/Modules/Usuario/Exceptions/DomainException.php
<?php
namespace Src\Modules\Usuario\Exceptions;
class DomainException extends \DomainException {}
Arquivo: src/Modules/Usuario/Exceptions/InvalidEmailException.php
<?php
namespace Src\Modules\Usuario\Exceptions;
class InvalidEmailException extends DomainException {}
Arquivo: src/Modules/Usuario/Exceptions/InvalidPasswordException.php
<?php
namespace Src\Modules\Usuario\Exceptions;
class InvalidPasswordException extends DomainException {}
Arquivo: src/Modules/Usuario/Exceptions/InvalidUsernameException.php
<?php
namespace Src\Modules\Usuario\Exceptions;
class InvalidUsernameException extends DomainException {}
Passo 3 — Criar a Entity
A Entity representa o domínio. Contém todas as validações de negócio: formato de e-mail, regras de username, complexidade de senha e níveis de acesso válidos.
Arquivo: src/Modules/Usuario/Entities/Usuario.php
<?php
namespace Src\Modules\Usuario\Entities;
use DateTimeImmutable;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;
use Src\Kernel\Utils\RelogioTimeZone;
use Src\Modules\Usuario\Exceptions\InvalidEmailException;
use Src\Modules\Usuario\Exceptions\InvalidPasswordException;
use Src\Modules\Usuario\Exceptions\InvalidUsernameException;
final class Usuario
{
private const NIVEIS_VALIDOS = ['usuario', 'admin', 'moderador', 'admin_system'];
private function __construct(
private UuidInterface $uuid,
private string $nomeCompleto,
private string $username,
private string $email,
private string $senhaHash,
private string $nivelAcesso,
private bool $ativo,
private bool $verificado_email,
private DateTimeImmutable $criadoEm,
private ?string $urlAvatar = null,
private ?string $urlCapa = null,
private ?string $biografia = null,
private ?string $tokenRecuperacaoSenha = null,
private ?string $tokenVerificacaoEmail = null,
private ?DateTimeImmutable $atualizadoEm = null,
private string $statusVerificacao = 'Não verificado'
) {}
// ── Factories ────────────────────────────────────────────────────────
/** Cria um novo usuário com validações completas. */
public static function registrar(
string $nomeCompleto,
string $username,
string $email,
string $senha,
?string $urlAvatar = null,
?string $urlCapa = null,
?string $biografia = null,
string $nivelAcesso = 'usuario',
bool $verificado_email = false,
string $statusVerificacao = 'Não verificado'
): self {
$username = strtolower(trim($username));
self::validarUsername($username);
self::validarEmail($email);
self::validarSenha($senha);
self::validarNivelAcesso($nivelAcesso);
return new self(
Uuid::uuid4(),
$nomeCompleto,
$username,
$email,
password_hash($senha, PASSWORD_ARGON2ID),
$nivelAcesso,
true,
$verificado_email,
RelogioTimeZone::agora(),
$urlAvatar,
$urlCapa,
$biografia,
null,
null,
null,
$statusVerificacao
);
}
/** Reconstitui um usuário a partir dos dados do banco. */
public static function reconstituir(
UuidInterface $uuid,
string $nomeCompleto,
string $username,
string $email,
string $senhaHash,
string $nivelAcesso,
bool $ativo,
bool $verificado_email,
DateTimeImmutable $criadoEm,
?string $urlAvatar = null,
?string $urlCapa = null,
?string $biografia = null,
?string $tokenRecuperacaoSenha = null,
?string $tokenVerificacaoEmail = null,
?DateTimeImmutable $atualizadoEm = null,
string $statusVerificacao = 'Não verificado'
): self {
return new self(
$uuid, $nomeCompleto, strtolower(trim($username)), $email, $senhaHash,
$nivelAcesso, $ativo, $verificado_email, $criadoEm,
$urlAvatar, $urlCapa, $biografia,
$tokenRecuperacaoSenha, $tokenVerificacaoEmail, $atualizadoEm, $statusVerificacao
);
}
// ── Validações privadas ───────────────────────────────────────────────
private static function validarEmail(string $email): void
{
if (trim($email) === '') throw new InvalidEmailException('E-mail não informado.');
if (!filter_var($email, FILTER_VALIDATE_EMAIL))
throw new InvalidEmailException('Formato de e-mail inválido.');
}
private static function validarUsername(string $u): void
{
if (trim($u) === '') throw new InvalidUsernameException('Username não informado.');
if (preg_match('/^[._]/', $u))
throw new InvalidUsernameException('Username não pode iniciar com ponto ou underline.');
if (strlen($u) < 3)
throw new InvalidUsernameException('Username deve ter ao menos 3 caracteres.');
if (!preg_match('/^[a-z0-9._]+$/', $u))
throw new InvalidUsernameException('Username só pode conter letras minúsculas, números, ponto ou underline.');
if (preg_match_all('/[._]/', $u) > 1)
throw new InvalidUsernameException('Username pode conter apenas um caractere especial (ponto ou underline).');
}
private static function validarSenha(string $s): void
{
if (trim($s) === '') throw new InvalidPasswordException('Senha não informada.');
if (strlen($s) < 8) throw new InvalidPasswordException('Senha muito curta. Mínimo 8 caracteres.');
if (!preg_match('/[A-Z]/', $s)) throw new InvalidPasswordException('Senha deve conter ao menos uma letra maiúscula.');
if (!preg_match('/[a-z]/', $s)) throw new InvalidPasswordException('Senha deve conter ao menos uma letra minúscula.');
if (!preg_match('/[0-9]/', $s)) throw new InvalidPasswordException('Senha deve conter ao menos um número.');
if (!preg_match('/[^a-zA-Z0-9]/', $s)) throw new InvalidPasswordException('Senha deve conter ao menos um caractere especial.');
}
private static function validarNivelAcesso(string $nivel): void
{
if (!in_array($nivel, self::NIVEIS_VALIDOS, true))
throw new \InvalidArgumentException('Nível de acesso inválido.');
}
// ── Comportamentos ────────────────────────────────────────────────────
public function verificarSenha(string $senhaPlana): bool
{
return password_verify($senhaPlana, $this->senhaHash);
}
public function alterarSenha(string $senhaPlana): void
{
self::validarSenha($senhaPlana);
$this->senhaHash = password_hash($senhaPlana, PASSWORD_ARGON2ID);
$this->atualizadoEm = RelogioTimeZone::agora();
}
public function promoverPara(string $nivelAcesso): void
{
self::validarNivelAcesso($nivelAcesso);
$this->nivelAcesso = $nivelAcesso;
$this->atualizadoEm = RelogioTimeZone::agora();
}
public function ativar(): void { $this->ativo = true; $this->atualizadoEm = RelogioTimeZone::agora(); }
public function desativar(): void { $this->ativo = false; $this->atualizadoEm = RelogioTimeZone::agora(); }
public function gerarTokenVerificacaoEmail(string $token): void
{
$this->tokenVerificacaoEmail = $token;
$this->atualizadoEm = RelogioTimeZone::agora();
}
public function gerarTokenRecuperacaoSenha(string $token): void
{
$this->tokenRecuperacaoSenha = $token;
$this->atualizadoEm = RelogioTimeZone::agora();
}
// ── Getters ───────────────────────────────────────────────────────────
public function getUuid(): UuidInterface { return $this->uuid; }
public function getNomeCompleto(): string { return $this->nomeCompleto; }
public function getUsername(): string { return $this->username; }
public function getEmail(): string { return $this->email; }
public function getSenhaHash(): string { return $this->senhaHash; }
public function getNivelAcesso(): string { return $this->nivelAcesso; }
public function isAtivo(): bool { return $this->ativo; }
public function isEmailVerificado(): bool { return $this->verificado_email; }
public function getStatusVerificacao(): string { return $this->statusVerificacao; }
public function getUrlAvatar(): ?string { return $this->urlAvatar; }
public function getUrlCapa(): ?string { return $this->urlCapa; }
public function getBiografia(): ?string { return $this->biografia; }
public function getCriadoEm(): DateTimeImmutable { return $this->criadoEm; }
public function getAtualizadoEm(): ?DateTimeImmutable { return $this->atualizadoEm; }
public function getTokenVerificacaoEmail(): ?string { return $this->tokenVerificacaoEmail; }
public function getTokenRecuperacaoSenha(): ?string { return $this->tokenRecuperacaoSenha; }
// ── Setters ───────────────────────────────────────────────────────────
public function setNomeCompleto(string $v): void { $this->nomeCompleto = $v; }
public function setUsername(string $v): void { self::validarUsername(strtolower(trim($v))); $this->username = strtolower(trim($v)); }
public function setEmail(string $v): void { self::validarEmail($v); $this->email = $v; }
public function setUrlAvatar(?string $v): void { $this->urlAvatar = $v; }
public function setUrlCapa(?string $v): void { $this->urlCapa = $v; }
public function setBiografia(?string $v): void { $this->biografia = $v; }
public function setEmailVerificado(bool $v): void { $this->verificado_email = $v; }
public function setStatusVerificacao(string $v): void { $this->statusVerificacao = $v; }
public function setAtualizadoEm(?DateTimeImmutable $v): void { $this->atualizadoEm = $v; }
public function setCriadoEm(DateTimeImmutable $v): void { $this->criadoEm = $v; }
}
A senha deve ter ao menos 8 caracteres, uma maiúscula, uma minúscula, um número e um caractere especial. O hash usa PASSWORD_ARGON2ID.
Mínimo 3 caracteres, apenas letras minúsculas, números, ponto ou underline. Não pode iniciar com ponto/underline. Máximo de um caractere especial.
Passo 4 — Criar as Interfaces
As interfaces definem o contrato que o Container usa para injeção de dependência. O UsuarioRepositoryInterface estende UserRepositoryInterface do Kernel — isso é obrigatório para que o AuthHybridMiddleware consiga buscar usuários.
Arquivo: src/Modules/Usuario/Repositories/UsuarioRepositoryInterface.php
<?php
namespace Src\Modules\Usuario\Repositories;
use Src\Kernel\Contracts\UserRepositoryInterface;
use Src\Modules\Usuario\Entities\Usuario;
interface UsuarioRepositoryInterface extends UserRepositoryInterface
{
public function salvar(Usuario $usuario): void;
public function deletar(string $uuid): void;
public function buscarPorUuid(string $uuid): ?Usuario;
public function buscarPorEmail(string $email): ?Usuario;
public function buscarPorUsername(string $username): ?Usuario;
public function buscarTodos(int $limite = 100, int $offset = 0): array;
public function contar(): int;
public function emailExiste(string $email, ?string $excluirUuid = null): bool;
public function usernameExiste(string $username, ?string $excluirUuid = null): bool;
public function salvarTokenVerificacaoEmail(string $uuid, string $token): void;
public function buscarPorTokenVerificacaoEmail(string $token): ?Usuario;
public function marcarEmailComoVerificado(string $uuid, bool $verificado = true): void;
public function salvarTokenRecuperacaoSenha(string $uuid, string $token): void;
public function buscarPorTokenRecuperacaoSenha(string $token): ?Usuario;
public function limparTokenRecuperacaoSenha(string $uuid): void;
public function buscarPorNomePaginado(string $nome, int $pagina = 1, int $porPagina = 10): array;
public function buscarComFiltro(int $pagina, int $porPagina, string $busca = '', string $nivel = ''): array;
public function listarUsernamesAtivos(int $limite = 50000, int $offset = 0): array;
}
Arquivo: src/Modules/Usuario/Services/UsuarioServiceInterface.php
<?php
namespace Src\Modules\Usuario\Services;
use Src\Modules\Usuario\Entities\Usuario;
interface UsuarioServiceInterface
{
public function emailExiste(string $email): bool;
public function usernameExiste(string $username): bool;
public function criar(Usuario $usuario): void;
public function atualizar(string $uuid, array $dados): void;
public function buscarPorUuid(string $uuid): ?Usuario;
public function buscarPorUsername(string $username): ?Usuario;
public function buscarPorEmail(string $email): ?Usuario;
public function salvarTokenVerificacaoEmail(string $uuid, string $token): void;
public function buscarPorTokenVerificacaoEmail(string $token): ?Usuario;
public function marcarEmailComoVerificado(string $uuid): void;
public function resetarVerificacaoEmail(string $uuid): void;
public function salvarTokenRecuperacaoSenha(string $uuid, string $token): void;
public function buscarPorTokenRecuperacaoSenha(string $token): ?Usuario;
public function limparTokenRecuperacaoSenha(string $uuid): void;
public function verificarSenha(string $uuid, string $senha): bool;
public function alterarSenha(string $uuid, string $novaSenha, bool $logoutAll = false): void;
public function listar(int $pagina = 1, int $porPagina = 20): array;
public function listarComFiltro(int $pagina, int $porPagina, string $busca = '', string $nivel = ''): array;
public function desativar(string $uuid): void;
public function ativar(string $uuid): void;
public function deletar(string $uuid): void;
public function listarUsernamesAtivosParaSitemap(int $limite = 50000, int $offset = 0): array;
}
Passo 5 — Criar o AbstractRepository
A classe abstrata centraliza as operações comuns de banco (busca, delete, contagem) e o mapeamento de rows para Entity. O UsuarioRepository concreto herda dela e só precisa implementar o salvar().
Arquivo: src/Modules/Usuario/Repositories/UsuarioAbstractRepository.php
<?php
namespace Src\Modules\Usuario\Repositories;
use PDO;
use PDOException;
use DateTimeImmutable;
use Ramsey\Uuid\Uuid;
use Src\Modules\Usuario\Entities\Usuario;
abstract class UsuarioAbstractRepository implements UsuarioRepositoryInterface
{
protected string $tabela = 'usuarios';
protected string $colunaId = 'uuid';
protected array $colunasPermitidas = [
'email' => 'email',
'username' => 'username',
'nivel_acesso' => 'nivel_acesso',
'ativo' => 'ativo',
'status_verificacao' => 'status_verificacao',
];
public function __construct(protected PDO $pdo) {}
protected function executarQuery(callable $op, string $msg = 'Erro no banco')
{
try {
return $op();
} catch (PDOException $e) {
throw new \RuntimeException($msg . ': ' . $e->getMessage(), (int) $e->getCode(), $e);
}
}
public function buscarTodos(int $limite = 100, int $offset = 0): array
{
return $this->executarQuery(function () use ($limite, $offset) {
$stmt = $this->pdo->prepare(
"SELECT * FROM {$this->tabela} ORDER BY criado_em DESC LIMIT :l OFFSET :o"
);
$stmt->bindValue(':l', $limite, PDO::PARAM_INT);
$stmt->bindValue(':o', $offset, PDO::PARAM_INT);
$stmt->execute();
return array_map([$this, 'mapearParaEntity'], $stmt->fetchAll(PDO::FETCH_ASSOC));
}, 'Erro ao buscar usuários');
}
public function buscarPorUuid(string $uuid): ?Usuario { return $this->buscarUmPor($this->colunaId, $uuid); }
public function buscarPorUsername(string $u): ?Usuario { return $this->buscarUmPor('username', $u); }
public function buscarPorEmail(string $e): ?Usuario { return $this->buscarUmPor('email', $e); }
public function deletar(string $uuid): void
{
$this->executarQuery(function () use ($uuid) {
$stmt = $this->pdo->prepare("DELETE FROM {$this->tabela} WHERE {$this->colunaId} = :uuid");
$stmt->bindValue(':uuid', $uuid);
$stmt->execute();
}, 'Erro ao deletar usuário');
}
public function contar(): int
{
return $this->executarQuery(function () {
return (int) $this->pdo->query("SELECT COUNT(*) FROM {$this->tabela}")->fetchColumn();
}, 'Erro ao contar usuários');
}
public function emailExiste(string $email, ?string $excluirUuid = null): bool
{
$sql = "SELECT 1 FROM {$this->tabela} WHERE email = :email";
$params = [':email' => $email];
if ($excluirUuid) { $sql .= " AND {$this->colunaId} != :uuid"; $params[':uuid'] = $excluirUuid; }
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return $stmt->fetchColumn() !== false;
}
public function usernameExiste(string $username, ?string $excluirUuid = null): bool
{
$sql = "SELECT 1 FROM {$this->tabela} WHERE username = :username";
$params = [':username' => $username];
if ($excluirUuid) { $sql .= " AND {$this->colunaId} != :uuid"; $params[':uuid'] = $excluirUuid; }
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return $stmt->fetchColumn() !== false;
}
public function salvarTokenVerificacaoEmail(string $uuid, string $token): void
{
$stmt = $this->pdo->prepare(
"UPDATE {$this->tabela} SET token_verificacao_email = :token WHERE {$this->colunaId} = :uuid"
);
$stmt->execute([':token' => $token, ':uuid' => $uuid]);
}
public function buscarPorTokenVerificacaoEmail(string $token): ?Usuario
{
if (empty($token)) return null;
$stmt = $this->pdo->prepare(
"SELECT * FROM {$this->tabela} WHERE token_verificacao_email = :token LIMIT 1"
);
$stmt->execute([':token' => $token]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ? $this->mapearParaEntity($row) : null;
}
public function marcarEmailComoVerificado(string $uuid, bool $verificado = true): void
{
$sql = $verificado
? "UPDATE {$this->tabela} SET verificado_email = :v, token_verificacao_email = NULL WHERE {$this->colunaId} = :uuid"
: "UPDATE {$this->tabela} SET verificado_email = :v WHERE {$this->colunaId} = :uuid";
$stmt = $this->pdo->prepare($sql);
$stmt->bindValue(':v', $verificado, PDO::PARAM_BOOL);
$stmt->bindValue(':uuid', $uuid);
$stmt->execute();
}
public function salvarTokenRecuperacaoSenha(string $uuid, string $token): void
{
$stmt = $this->pdo->prepare(
"UPDATE {$this->tabela} SET token_recuperacao_senha = :token WHERE {$this->colunaId} = :uuid"
);
$stmt->execute([':token' => $token, ':uuid' => $uuid]);
}
public function buscarPorTokenRecuperacaoSenha(string $token): ?Usuario
{
$stmt = $this->pdo->prepare(
"SELECT * FROM {$this->tabela} WHERE token_recuperacao_senha = :token LIMIT 1"
);
$stmt->execute([':token' => $token]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ? $this->mapearParaEntity($row) : null;
}
public function limparTokenRecuperacaoSenha(string $uuid): void
{
$stmt = $this->pdo->prepare(
"UPDATE {$this->tabela} SET token_recuperacao_senha = NULL WHERE {$this->colunaId} = :uuid"
);
$stmt->execute([':uuid' => $uuid]);
}
public function buscarPorNomePaginado(string $nome, int $pagina = 1, int $porPagina = 10): array
{
$offset = ($pagina - 1) * $porPagina;
$stmt = $this->pdo->prepare(
"SELECT * FROM {$this->tabela} WHERE nome_completo LIKE :nome ORDER BY criado_em DESC LIMIT :l OFFSET :o"
);
$stmt->bindValue(':nome', "%{$nome}%");
$stmt->bindValue(':l', $porPagina, PDO::PARAM_INT);
$stmt->bindValue(':o', $offset, PDO::PARAM_INT);
$stmt->execute();
return array_map([$this, 'mapearParaEntity'], $stmt->fetchAll(PDO::FETCH_ASSOC));
}
public function buscarComFiltro(int $pagina, int $porPagina, string $busca = '', string $nivel = ''): array
{
$offset = ($pagina - 1) * $porPagina;
$params = [];
$where = [];
if ($busca !== '') { $where[] = "(username LIKE :busca OR email LIKE :busca)"; $params[':busca'] = '%' . $busca . '%'; }
if ($nivel !== '') { $where[] = "nivel_acesso = :nivel"; $params[':nivel'] = $nivel; }
$wc = $where ? 'WHERE ' . implode(' AND ', $where) : '';
$stmtCount = $this->pdo->prepare("SELECT COUNT(*) FROM {$this->tabela} {$wc}");
$stmtCount->execute($params);
$total = (int) $stmtCount->fetchColumn();
$stmtRows = $this->pdo->prepare(
"SELECT * FROM {$this->tabela} {$wc} ORDER BY criado_em DESC LIMIT :l OFFSET :o"
);
foreach ($params as $k => $v) $stmtRows->bindValue($k, $v);
$stmtRows->bindValue(':l', $porPagina, PDO::PARAM_INT);
$stmtRows->bindValue(':o', $offset, PDO::PARAM_INT);
$stmtRows->execute();
return [
'usuarios' => array_map([$this, 'mapearParaEntity'], $stmtRows->fetchAll(PDO::FETCH_ASSOC) ?: []),
'total' => $total,
'total_paginas' => max(1, (int) ceil($total / $porPagina)),
];
}
public function listarUsernamesAtivos(int $limite = 50000, int $offset = 0): array
{
$stmt = $this->pdo->prepare(
"SELECT username, atualizado_em, criado_em FROM {$this->tabela}
WHERE ativo = :ativo AND username IS NOT NULL AND username <> ''
ORDER BY criado_em DESC LIMIT :l OFFSET :o"
);
$stmt->bindValue(':ativo', true, PDO::PARAM_BOOL);
$stmt->bindValue(':l', $limite, PDO::PARAM_INT);
$stmt->bindValue(':o', $offset, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
}
protected function buscarUmPor(string $coluna, mixed $valor): ?Usuario
{
$col = $this->resolverColuna($coluna);
$sql = $col === 'username'
? "SELECT * FROM {$this->tabela} WHERE LOWER({$col}) = LOWER(:valor) LIMIT 1"
: "SELECT * FROM {$this->tabela} WHERE {$col} = :valor LIMIT 1";
$stmt = $this->pdo->prepare($sql);
$stmt->bindValue(':valor', $valor);
$stmt->execute();
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ? $this->mapearParaEntity($row) : null;
}
protected function resolverColuna(string $coluna): string
{
if ($coluna === $this->colunaId) return $this->colunaId;
if (isset($this->colunasPermitidas[$coluna])) return $this->colunasPermitidas[$coluna];
throw new \InvalidArgumentException('Coluna inválida para busca');
}
protected function mapearParaEntity(array $dados): Usuario
{
return Usuario::reconstituir(
Uuid::fromString($dados['uuid']),
$dados['nome_completo'],
$dados['username'],
$dados['email'],
$dados['senha_hash'],
$dados['nivel_acesso'],
(bool) $dados['ativo'],
(bool) $dados['verificado_email'],
new DateTimeImmutable($dados['criado_em']),
$dados['url_avatar'] ?? null,
$dados['url_capa'] ?? null,
$dados['biografia'] ?? null,
$dados['token_recuperacao_senha'] ?? null,
$dados['token_verificacao_email'] ?? null,
isset($dados['atualizado_em']) ? new DateTimeImmutable((string) $dados['atualizado_em']) : null,
$dados['status_verificacao'] ?? 'Não verificado'
);
}
abstract public function salvar(Usuario $usuario): void;
}
Passo 6 — Criar o Repository concreto
O UsuarioRepository herda do abstract e implementa apenas o salvar(), que usa upsert para criar ou atualizar com uma única query.
Arquivo: src/Modules/Usuario/Repositories/UsuarioRepository.php
<?php
namespace Src\Modules\Usuario\Repositories;
use PDO;
use Src\Kernel\Contracts\UserRepositoryInterface;
use Src\Modules\Usuario\Entities\Usuario;
use Src\Kernel\Utils\RelogioTimeZone;
class UsuarioRepository extends UsuarioAbstractRepository implements UserRepositoryInterface
{
public function __construct(PDO $pdo)
{
parent::__construct($pdo);
}
public function salvar(Usuario $usuario): void
{
$this->executarQuery(function () use ($usuario) {
$agora = RelogioTimeZone::agora()->format('Y-m-d H:i:s');
$uuid = $usuario->getUuid()->toString();
$driver = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
if ($driver === 'pgsql') {
$sql = "INSERT INTO {$this->tabela}
(uuid, nome_completo, email, username, senha_hash, url_avatar, url_capa,
biografia, nivel_acesso, ativo, status_verificacao, token_verificacao_email, criado_em)
VALUES
(:uuid, :nome_completo, :email, :username, :senha_hash, :url_avatar, :url_capa,
:biografia, :nivel_acesso, :ativo, :status_verificacao, :token_verificacao_email, :criado_em)
ON CONFLICT ({$this->colunaId}) DO UPDATE SET
nome_completo = EXCLUDED.nome_completo,
email = EXCLUDED.email,
username = EXCLUDED.username,
senha_hash = EXCLUDED.senha_hash,
url_avatar = EXCLUDED.url_avatar,
url_capa = EXCLUDED.url_capa,
biografia = EXCLUDED.biografia,
nivel_acesso = EXCLUDED.nivel_acesso,
ativo = EXCLUDED.ativo,
status_verificacao = EXCLUDED.status_verificacao,
token_verificacao_email = EXCLUDED.token_verificacao_email,
atualizado_em = NOW()";
} else {
$sql = "INSERT INTO {$this->tabela}
(uuid, nome_completo, email, username, senha_hash, url_avatar, url_capa,
biografia, nivel_acesso, ativo, status_verificacao, token_verificacao_email, criado_em)
VALUES
(:uuid, :nome_completo, :email, :username, :senha_hash, :url_avatar, :url_capa,
:biografia, :nivel_acesso, :ativo, :status_verificacao, :token_verificacao_email, :criado_em)
ON DUPLICATE KEY UPDATE
nome_completo = VALUES(nome_completo),
email = VALUES(email),
username = VALUES(username),
senha_hash = VALUES(senha_hash),
url_avatar = VALUES(url_avatar),
url_capa = VALUES(url_capa),
biografia = VALUES(biografia),
nivel_acesso = VALUES(nivel_acesso),
ativo = VALUES(ativo),
status_verificacao = VALUES(status_verificacao),
token_verificacao_email = VALUES(token_verificacao_email),
atualizado_em = :atualizado_em";
}
$stmt = $this->pdo->prepare($sql);
$stmt->bindValue(':uuid', $uuid);
$stmt->bindValue(':nome_completo', $usuario->getNomeCompleto());
$stmt->bindValue(':email', $usuario->getEmail());
$stmt->bindValue(':username', $usuario->getUsername());
$stmt->bindValue(':senha_hash', $usuario->getSenhaHash());
$stmt->bindValue(':url_avatar', $usuario->getUrlAvatar());
$stmt->bindValue(':url_capa', $usuario->getUrlCapa());
$stmt->bindValue(':biografia', $usuario->getBiografia());
$stmt->bindValue(':nivel_acesso', $usuario->getNivelAcesso());
$stmt->bindValue(':ativo', $usuario->isAtivo(), PDO::PARAM_BOOL);
$stmt->bindValue(':status_verificacao', $usuario->getStatusVerificacao());
$stmt->bindValue(':token_verificacao_email', $usuario->getTokenVerificacaoEmail());
$stmt->bindValue(':criado_em', $agora);
if ($driver !== 'pgsql') {
$stmt->bindValue(':atualizado_em', $agora);
}
$stmt->execute();
}, 'Erro ao salvar usuário');
}
}
Passo 7 — Criar o Service
O Service contém todas as regras de negócio. Recebe a interface do repository (não a implementação concreta) — o Container resolve automaticamente.
Arquivo: src/Modules/Usuario/Services/UsuarioService.php
<?php
namespace Src\Modules\Usuario\Services;
use DomainException;
use DateTimeImmutable;
use Src\Modules\Usuario\Entities\Usuario;
use Src\Modules\Usuario\Repositories\UsuarioRepositoryInterface;
class UsuarioService implements UsuarioServiceInterface
{
public function __construct(
private UsuarioRepositoryInterface $repository
) {}
public function emailExiste(string $email): bool { return $this->repository->emailExiste($email); }
public function usernameExiste(string $u): bool { return $this->repository->usernameExiste($u); }
public function buscarPorUuid(string $uuid): ?Usuario { return $this->repository->buscarPorUuid($uuid); }
public function buscarPorUsername(string $u): ?Usuario { return $this->repository->buscarPorUsername($u); }
public function buscarPorEmail(string $e): ?Usuario { return $this->repository->buscarPorEmail($e); }
public function criar(Usuario $usuario): void
{
if ($this->repository->emailExiste($usuario->getEmail()))
throw new DomainException('E-mail já cadastrado.', 409);
if ($this->repository->usernameExiste($usuario->getUsername()))
throw new DomainException('Username já cadastrado.', 409);
$this->repository->salvar($usuario);
}
public function atualizar(string $uuid, array $data): void
{
$usuario = $this->repository->buscarPorUuid($uuid);
if (!$usuario) throw new DomainException('Usuário não encontrado.', 404);
if (isset($data['nome_completo'])) $usuario->setNomeCompleto($data['nome_completo']);
if (isset($data['username'])) $usuario->setUsername($data['username']);
if (isset($data['email'])) $usuario->setEmail($data['email']);
if (isset($data['senha'])) $usuario->alterarSenha($data['senha']);
if (isset($data['url_avatar'])) $usuario->setUrlAvatar($data['url_avatar']);
if (isset($data['url_capa'])) $usuario->setUrlCapa($data['url_capa']);
if (isset($data['biografia'])) $usuario->setBiografia($data['biografia']);
if (isset($data['nivel_acesso'])) $usuario->promoverPara($data['nivel_acesso']);
$usuario->setAtualizadoEm(new DateTimeImmutable());
if ($this->repository->emailExiste($usuario->getEmail(), $uuid))
throw new DomainException('E-mail já cadastrado.', 409);
if ($this->repository->usernameExiste($usuario->getUsername(), $uuid))
throw new DomainException('Username já cadastrado.', 409);
$this->repository->salvar($usuario);
}
public function alterarSenha(string $uuid, string $novaSenha, bool $logoutAll = false): void
{
$usuario = $this->repository->buscarPorUuid($uuid);
if (!$usuario) throw new DomainException('Usuário não encontrado.', 404);
$usuario->alterarSenha($novaSenha);
$this->repository->salvar($usuario);
}
public function verificarSenha(string $uuid, string $senha): bool
{
$usuario = $this->repository->buscarPorUuid($uuid);
return $usuario ? $usuario->verificarSenha($senha) : false;
}
public function desativar(string $uuid): void
{
$usuario = $this->repository->buscarPorUuid($uuid);
if (!$usuario) throw new DomainException('Usuário não encontrado.', 404);
$usuario->desativar();
$this->repository->salvar($usuario);
}
public function ativar(string $uuid): void
{
$usuario = $this->repository->buscarPorUuid($uuid);
if (!$usuario) throw new DomainException('Usuário não encontrado.', 404);
$usuario->ativar();
$this->repository->salvar($usuario);
}
public function deletar(string $uuid): void
{
$usuario = $this->repository->buscarPorUuid($uuid);
if (!$usuario) throw new DomainException('Usuário não encontrado.', 404);
$this->repository->deletar($uuid);
}
public function listar(int $pagina = 1, int $porPagina = 20): array
{
return $this->repository->buscarPorNomePaginado('', $pagina, $porPagina);
}
public function listarComFiltro(int $pagina, int $porPagina, string $busca = '', string $nivel = ''): array
{
return $this->repository->buscarComFiltro($pagina, $porPagina, $busca, $nivel);
}
public function salvarTokenVerificacaoEmail(string $uuid, string $token): void
{
$usuario = $this->repository->buscarPorUuid($uuid);
if (!$usuario) throw new DomainException('Usuário não encontrado.', 404);
$usuario->gerarTokenVerificacaoEmail($token);
$this->repository->salvarTokenVerificacaoEmail($uuid, $token);
}
public function buscarPorTokenVerificacaoEmail(string $token): ?Usuario
{
return $this->repository->buscarPorTokenVerificacaoEmail($token);
}
public function marcarEmailComoVerificado(string $uuid): void
{
if (!$this->repository->buscarPorUuid($uuid))
throw new DomainException('Usuário não encontrado.', 404);
$this->repository->marcarEmailComoVerificado($uuid);
}
public function resetarVerificacaoEmail(string $uuid): void
{
$this->repository->marcarEmailComoVerificado($uuid, false);
}
public function salvarTokenRecuperacaoSenha(string $uuid, string $token): void
{
$usuario = $this->repository->buscarPorUuid($uuid);
if (!$usuario) throw new DomainException('Usuário não encontrado.', 404);
$usuario->gerarTokenRecuperacaoSenha($token);
$this->repository->salvarTokenRecuperacaoSenha($uuid, $token);
}
public function buscarPorTokenRecuperacaoSenha(string $token): ?Usuario
{
return $this->repository->buscarPorTokenRecuperacaoSenha($token);
}
public function limparTokenRecuperacaoSenha(string $uuid): void
{
$this->repository->limparTokenRecuperacaoSenha($uuid);
}
public function listarUsernamesAtivosParaSitemap(int $limite = 50000, int $offset = 0): array
{
return $this->repository->listarUsernamesAtivos($limite, $offset);
}
}
Passo 8 — Criar o Controller
O Controller recebe as dependências via construtor. O EmailSenderInterface é opcional — se não estiver configurado, o envio de e-mail é silenciosamente ignorado. O método pdo() usa ModuleConnectionResolver para obter a conexão correta do módulo.
Arquivo: src/Modules/Usuario/Controllers/UsuarioController.php
<?php
namespace Src\Modules\Usuario\Controllers;
use DomainException;
use Src\Kernel\Http\Request\Request;
use Src\Kernel\Http\Response\Response;
use Src\Kernel\Utils\ImageProcessor;
use Src\Kernel\Utils\Sanitizer;
use Src\Modules\Usuario\Entities\Usuario;
use Src\Modules\Usuario\Exceptions\DomainException as ModuleDomainException;
use Src\Modules\Usuario\Services\UsuarioServiceInterface;
class UsuarioController
{
private ?\PDO $pdo = null;
public function __construct(
private UsuarioServiceInterface $service,
private ?\Src\Kernel\Contracts\EmailSenderInterface $emailSender = null
) {}
private function pdo(): \PDO
{
if ($this->pdo === null) {
$this->pdo = \Src\Kernel\Database\ModuleConnectionResolver::forModule('Usuario');
}
return $this->pdo;
}
// ── Registro público ──────────────────────────────────────────────────
public function criar(Request $request): Response
{
try {
$body = $request->body ?? [];
$nome = Sanitizer::string($body['nome_completo'] ?? $body['nome'] ?? '', 150);
$username = Sanitizer::username($body['username'] ?? '');
$email = Sanitizer::email($body['email'] ?? '');
$senha = Sanitizer::password($body['senha'] ?? $body['password'] ?? '');
// nivel_acesso nunca vem do body em registro público — sempre 'usuario'
$nivel = 'usuario';
if ($nome === '' || $username === '' || $email === '' || $senha === '') {
return Response::json(['status' => 'error', 'message' => 'Campos obrigatórios: nome_completo, username, email, senha.'], 422);
}
$usuario = Usuario::registrar($nome, $username, $email, $senha, null, null, null, $nivel);
$this->service->criar($usuario);
// Gera token e envia e-mail de confirmação se o módulo estiver disponível
$this->enviarEmailConfirmacaoRegistro($usuario);
return Response::json([
'status' => 'success',
'message' => 'Usuário criado com sucesso. Verifique seu e-mail para confirmar o cadastro.',
'usuario' => $this->serializar($usuario),
], 201);
} catch (DomainException | ModuleDomainException $e) {
$status = $e->getCode() >= 400 && $e->getCode() <= 599 ? $e->getCode() : 422;
return Response::json(['status' => 'error', 'message' => $e->getMessage()], $status);
} catch (\Throwable $e) {
return Response::json(['status' => 'error', 'message' => 'Erro ao criar usuário.', 'details' => $this->debug($e)], 500);
}
}
/**
* Endpoint público: reenviar e-mail de verificação.
* POST /api/auth/reenviar-verificacao { "email": "..." }
*/
public function reenviarVerificacaoEmail(Request $request): Response
{
try {
$body = $request->body ?? [];
$email = Sanitizer::email($body['email'] ?? '');
if ($email === '') {
return Response::json(['status' => 'error', 'message' => 'E-mail inválido ou não informado.'], 422);
}
$usuario = $this->service->buscarPorEmail($email);
// Resposta genérica em todos os casos para não revelar se o e-mail existe
$msgGenerica = 'Se o e-mail existir e não estiver verificado, um novo link será enviado em breve.';
if (!$usuario) {
// Registra throttle mesmo assim para evitar enumeração por timing
$this->registrarReenvioVerificacao($email);
return Response::json(['status' => 'success', 'message' => $msgGenerica]);
}
if ($usuario->isEmailVerificado()) {
return Response::json(['status' => 'success', 'message' => 'E-mail já verificado. Você pode fazer login normalmente.']);
}
// enviarEmailConfirmacaoRegistro já verifica throttle e e-mail verificado internamente
$this->enviarEmailConfirmacaoRegistro($usuario);
return Response::json(['status' => 'success', 'message' => $msgGenerica]);
} catch (\Throwable $e) {
return Response::json(['status' => 'error', 'message' => 'Erro ao reenviar verificação.'], 500);
}
}
private function podeReenviarVerificacao(string $email): bool
{
try {
return (new \Src\Kernel\Support\EmailThrottle($this->pdo()))->canSend('verification', $email);
} catch (\Throwable) {
return true;
}
}
private function registrarReenvioVerificacao(string $email): void
{
try {
(new \Src\Kernel\Support\EmailThrottle($this->pdo()))->record('verification', $email);
} catch (\Throwable $e) {
error_log('[UsuarioController] throttle record failed: ' . $e->getMessage());
}
}
/**
* Gera token de verificação e envia e-mail de confirmação.
* Não envia se o e-mail já estiver verificado.
* Não envia se o throttle de 120s ainda estiver ativo.
*/
private function enviarEmailConfirmacaoRegistro(\Src\Modules\Usuario\Entities\Usuario $usuario): void
{
if ($this->emailSender === null) {
return;
}
// Não envia se já verificado
if ($usuario->isEmailVerificado()) {
return;
}
// Throttle: evita disparos duplicados em menos de 120s
if (!$this->podeReenviarVerificacao($usuario->getEmail())) {
return;
}
try {
$token = bin2hex(random_bytes(32));
$this->service->salvarTokenVerificacaoEmail($usuario->getUuid()->toString(), $token);
// Registra throttle antes de enviar para evitar race condition
$this->registrarReenvioVerificacao($usuario->getEmail());
$base = rtrim($_ENV['APP_URL_FRONTEND'] ?? $_ENV['APP_URL'] ?? '', '/');
if ($base === '') {
$scheme = $_SERVER['REQUEST_SCHEME'] ?? 'http';
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
$base = $scheme . '://' . $host;
}
// Link aponta para o frontend — o frontend chama a API com o token
$link = $base . '/verificar-email?token=' . urlencode($token);
$this->emailSender->sendConfirmation(
$usuario->getEmail(),
$usuario->getNomeCompleto(),
$link,
$_ENV['APP_LOGO_URL'] ?? null
);
} catch (\Throwable $e) {
error_log('[UsuarioController] Falha ao enviar e-mail de confirmação: ' . $e->getMessage());
}
}
// ── Admin: listagem e gerenciamento ───────────────────────────────────
public function listar(Request $request): Response
{
try {
$pagina = Sanitizer::positiveInt($request->query['pagina'] ?? $request->query['page'] ?? 1, 1, 10000);
$porPagina = Sanitizer::positiveInt($request->query['por_pagina'] ?? $request->query['per_page'] ?? 20, 1, 100);
$busca = Sanitizer::search($request->query['q'] ?? '');
$nivel = Sanitizer::nivelAcesso($request->query['nivel'] ?? '');
$resultado = $this->service->listarComFiltro($pagina, $porPagina, $busca, $nivel);
return Response::json([
'status' => 'success',
'pagina' => $pagina,
'por_pagina' => $porPagina,
'total' => $resultado['total'],
'total_paginas' => $resultado['total_paginas'],
'usuarios' => array_map([$this, 'serializar'], $resultado['usuarios']),
]);
} catch (\Throwable $e) {
return Response::json(['status' => 'error', 'message' => 'Erro ao listar usuários.', 'details' => $this->debug($e)], 500);
}
}
public function buscar(Request $request, string $uuid): Response
{
try {
$usuario = $this->service->buscarPorUuid($uuid);
if (!$usuario) {
return Response::json(['status' => 'error', 'message' => 'Usuário não encontrado.'], 404);
}
return Response::json(['status' => 'success', 'usuario' => $this->serializar($usuario)]);
} catch (\Throwable $e) {
return Response::json(['status' => 'error', 'message' => 'Erro ao buscar usuário.', 'details' => $this->debug($e)], 500);
}
}
public function atualizar(Request $request, string $uuid): Response
{
try {
$uuid = Sanitizer::uuid($uuid);
if ($uuid === '') {
return Response::json(['status' => 'error', 'message' => 'UUID inválido.'], 422);
}
$body = $request->body ?? [];
if (empty($body)) {
return Response::json(['status' => 'error', 'message' => 'Nenhum dado enviado.'], 422);
}
$dados = $this->sanitizarCamposUsuario($body);
if (isset($body['nivel_acesso'])) {
// Impede que o usuário logado altere seu próprio nível de acesso
$authUser = $request->attribute('auth_user');
if ($authUser && $authUser->getUuid()->toString() === $uuid) {
return Response::json(['status' => 'error', 'message' => 'Você não pode alterar seu próprio nível de acesso.'], 403);
}
$nivelError = $this->sanitizarNivelAcesso($body['nivel_acesso'], $dados);
if ($nivelError !== null) {
return $nivelError;
}
}
if (empty($dados)) {
return Response::json(['status' => 'error', 'message' => 'Nenhum campo válido enviado.'], 422);
}
$this->service->atualizar($uuid, $dados);
$usuario = $this->service->buscarPorUuid($uuid);
return Response::json([
'status' => 'success',
'message' => 'Usuário atualizado.',
'usuario' => $usuario ? $this->serializar($usuario) : null,
]);
} catch (DomainException | ModuleDomainException $e) {
$status = $e->getCode() >= 400 && $e->getCode() <= 599 ? $e->getCode() : 422;
return Response::json(['status' => 'error', 'message' => $e->getMessage()], $status);
} catch (\Throwable $e) {
return Response::json(['status' => 'error', 'message' => 'Erro ao atualizar usuário.', 'details' => $this->debug($e)], 500);
}
}
/** Sanitiza os campos comuns de usuário a partir do body da requisição. */
private function sanitizarCamposUsuario(array $body): array
{
$map = [
'nome_completo' => static fn($v) => Sanitizer::string($v, 150),
'username' => static fn($v) => Sanitizer::username($v),
'email' => static fn($v) => Sanitizer::email($v),
'senha' => static fn($v) => Sanitizer::password($v),
'url_avatar' => static fn($v) => Sanitizer::url($v),
'url_capa' => static fn($v) => Sanitizer::url($v),
'biografia' => static fn($v) => Sanitizer::text($v, 500),
];
$dados = [];
foreach ($map as $campo => $sanitize) {
if (isset($body[$campo])) {
$dados[$campo] = $sanitize($body[$campo]);
}
}
return $dados;
}
/** Valida e adiciona nivel_acesso em $dados. Retorna Response de erro ou null se ok. */
private function sanitizarNivelAcesso(mixed $valor, array &$dados): ?Response
{
$nivel = Sanitizer::nivelAcesso($valor);
if ($nivel === '') {
return Response::json(['status' => 'error', 'message' => 'Nível de acesso inválido.'], 422);
}
$dados['nivel_acesso'] = $nivel;
return null;
}
public function deletar(Request $request, string $uuid): Response
{
try {
$authUser = $request->attribute('auth_user');
if ($authUser && $authUser->getUuid()->toString() === $uuid) {
return Response::json(['status' => 'error', 'message' => 'Você não pode excluir sua própria conta por aqui.'], 403);
}
$this->service->deletar($uuid);
return Response::json(['status' => 'success', 'message' => 'Usuário removido.']);
} catch (DomainException | ModuleDomainException $e) {
$status = $e->getCode() >= 400 && $e->getCode() <= 599 ? $e->getCode() : 422;
return Response::json(['status' => 'error', 'message' => $e->getMessage()], $status);
} catch (\Throwable $e) {
return Response::json(['status' => 'error', 'message' => 'Erro ao remover usuário.', 'details' => $this->debug($e)], 500);
}
}
public function desativar(Request $request, string $uuid): Response
{
try {
$this->service->desativar($uuid);
return Response::json(['status' => 'success', 'message' => 'Usuário desativado.']);
} catch (DomainException | ModuleDomainException $e) {
$status = $e->getCode() >= 400 && $e->getCode() <= 599 ? $e->getCode() : 422;
return Response::json(['status' => 'error', 'message' => $e->getMessage()], $status);
} catch (\Throwable $e) {
return Response::json(['status' => 'error', 'message' => 'Erro ao desativar usuário.', 'details' => $this->debug($e)], 500);
}
}
public function ativar(Request $request, string $uuid): Response
{
try {
$this->service->ativar($uuid);
return Response::json(['status' => 'success', 'message' => 'Usuário ativado.']);
} catch (DomainException | ModuleDomainException $e) {
$status = $e->getCode() >= 400 && $e->getCode() <= 599 ? $e->getCode() : 422;
return Response::json(['status' => 'error', 'message' => $e->getMessage()], $status);
} catch (\Throwable $e) {
return Response::json(['status' => 'error', 'message' => 'Erro ao ativar usuário.', 'details' => $this->debug($e)], 500);
}
}
// ── Perfil do usuário autenticado ─────────────────────────────────────
public function perfil(Request $request): Response
{
try {
$authUser = $request->attribute('auth_user');
if (!$authUser) {
return Response::json(['status' => 'error', 'message' => 'Não autenticado.'], 401);
}
return Response::json(['status' => 'success', 'usuario' => $this->serializar($authUser)]);
} catch (\Throwable $e) {
return Response::json(['status' => 'error', 'message' => 'Erro ao buscar perfil.', 'details' => $this->debug($e)], 500);
}
}
public function atualizarPerfil(Request $request): Response
{
try {
$authUser = $request->attribute('auth_user');
if (!$authUser) {
return Response::json(['status' => 'error', 'message' => 'Não autenticado.'], 401);
}
$body = $request->body ?? [];
$dados = [];
if (isset($body['nome_completo'])) $dados['nome_completo'] = Sanitizer::string($body['nome_completo'], 150);
if (isset($body['username'])) $dados['username'] = Sanitizer::username($body['username']);
if (isset($body['url_avatar'])) $dados['url_avatar'] = Sanitizer::url($body['url_avatar']);
if (isset($body['url_capa'])) $dados['url_capa'] = Sanitizer::url($body['url_capa']);
if (isset($body['biografia'])) $dados['biografia'] = Sanitizer::text($body['biografia'], 500);
if (empty($dados)) {
return Response::json(['status' => 'error', 'message' => 'Nenhum campo válido enviado.'], 422);
}
$uuid = $authUser->getUuid()->toString();
$this->service->atualizar($uuid, $dados);
$usuario = $this->service->buscarPorUuid($uuid);
return Response::json([
'status' => 'success',
'message' => 'Perfil atualizado.',
'usuario' => $usuario ? $this->serializar($usuario) : null,
]);
} catch (DomainException | ModuleDomainException $e) {
$status = $e->getCode() >= 400 && $e->getCode() <= 599 ? $e->getCode() : 422;
return Response::json(['status' => 'error', 'message' => $e->getMessage()], $status);
} catch (\Throwable $e) {
return Response::json(['status' => 'error', 'message' => 'Erro ao atualizar perfil.', 'details' => $this->debug($e)], 500);
}
}
public function alterarEmail(Request $request): Response
{
try {
$authUser = $request->attribute('auth_user');
if (!$authUser) {
return Response::json(['status' => 'error', 'message' => 'Não autenticado.'], 401);
}
$body = $request->body ?? [];
$email = Sanitizer::email($body['email'] ?? '');
$senha = Sanitizer::password($body['senha'] ?? $body['password'] ?? '');
if ($email === '') {
return Response::json(['status' => 'error', 'message' => 'E-mail inválido ou não informado.'], 422);
}
if ($senha === '' || !$authUser->verificarSenha($senha)) {
return Response::json(['status' => 'error', 'message' => 'Senha incorreta.'], 403);
}
$uuid = $authUser->getUuid()->toString();
$this->service->atualizar($uuid, ['email' => $email]);
// Reseta verificação e envia novo e-mail de confirmação para o novo endereço
$usuarioAtualizado = $this->service->buscarPorUuid($uuid);
if ($usuarioAtualizado) {
$this->service->resetarVerificacaoEmail($uuid);
$usuarioAtualizado->setEmailVerificado(false);
$this->enviarEmailConfirmacaoRegistro($usuarioAtualizado);
}
return Response::json(['status' => 'success', 'message' => 'E-mail atualizado. Verifique sua caixa de entrada para confirmar o novo endereço.']);
} catch (DomainException | ModuleDomainException $e) {
$status = $e->getCode() >= 400 && $e->getCode() <= 599 ? $e->getCode() : 422;
return Response::json(['status' => 'error', 'message' => $e->getMessage()], $status);
} catch (\Throwable $e) {
return Response::json(['status' => 'error', 'message' => 'Erro ao alterar e-mail.', 'details' => $this->debug($e)], 500);
}
}
public function alterarSenha(Request $request): Response
{
try {
$authUser = $request->attribute('auth_user');
if (!$authUser) {
return Response::json(['status' => 'error', 'message' => 'Não autenticado.'], 401);
}
$body = $request->body ?? [];
$senhaAtual = Sanitizer::password($body['senha_atual'] ?? $body['current_password'] ?? '');
$novaSenha = Sanitizer::password($body['nova_senha'] ?? $body['new_password'] ?? '');
if ($senhaAtual === '' || $novaSenha === '') {
return Response::json(['status' => 'error', 'message' => 'senha_atual e nova_senha são obrigatórios.'], 422);
}
if (!$authUser->verificarSenha($senhaAtual)) {
return Response::json(['status' => 'error', 'message' => 'Senha atual incorreta.'], 403);
}
$this->service->alterarSenha($authUser->getUuid()->toString(), $novaSenha);
return Response::json(['status' => 'success', 'message' => 'Senha alterada com sucesso.']);
} catch (DomainException | ModuleDomainException $e) {
$status = $e->getCode() >= 400 && $e->getCode() <= 599 ? $e->getCode() : 422;
return Response::json(['status' => 'error', 'message' => $e->getMessage()], $status);
} catch (\Throwable $e) {
return Response::json(['status' => 'error', 'message' => 'Erro ao alterar senha.', 'details' => $this->debug($e)], 500);
}
}
public function uploadProfileImage(Request $request): Response
{
try {
$authUser = $request->attribute('auth_user');
if (!$authUser) {
return Response::json(['status' => 'error', 'message' => 'Não autenticado.'], 401);
}
$tipo = $this->resolverTipoImagem($request->body['tipo'] ?? 'avatar');
$file = $_FILES['imagem'] ?? $_FILES['file'] ?? null;
$validationError = $this->validarArquivoImagem($file);
if ($validationError !== null) {
return $validationError;
}
$mime = mime_content_type($file['tmp_name']) ?: '';
$mimeError = $this->validarMimeImagem($mime);
if ($mimeError !== null) {
return $mimeError;
}
$uuid = $authUser->getUuid()->toString();
$filename = $this->gerarNomeArquivo($uuid, $tipo, $mime);
$uploadDir = dirname(__DIR__, 5) . '/public/uploads/perfil/';
if (!is_dir($uploadDir) && !mkdir($uploadDir, 0755, true) && !is_dir($uploadDir)) {
return Response::json(['status' => 'error', 'message' => 'Erro ao criar diretório de upload.'], 500);
}
[$maxW, $maxH] = $tipo === 'capa' ? [1200, 400] : [400, 400];
if (!ImageProcessor::resizeAndSave($file['tmp_name'], $uploadDir . $filename, $mime, $maxW, $maxH)) {
return Response::json(['status' => 'error', 'message' => 'Falha ao processar imagem.'], 500);
}
$url = '/uploads/perfil/' . $filename;
$campo = $tipo === 'capa' ? 'url_capa' : 'url_avatar';
$this->service->atualizar($uuid, [$campo => $url]);
return Response::json(['status' => 'success', 'url' => $url]);
} catch (\Throwable $e) {
return Response::json(['status' => 'error', 'message' => 'Erro no upload.', 'details' => $this->debug($e)], 500);
}
}
private function resolverTipoImagem(mixed $tipo): string
{
$tipo = trim((string) $tipo);
return in_array($tipo, ['avatar', 'capa'], true) ? $tipo : 'avatar';
}
private function validarArquivoImagem(?array $file): ?Response
{
if (!$file || ($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
return Response::json(['status' => 'error', 'message' => 'Nenhuma imagem enviada ou erro no upload.'], 422);
}
if (($file['size'] ?? 0) > 5 * 1024 * 1024) {
return Response::json(['status' => 'error', 'message' => 'Imagem muito grande. Máximo 5MB.'], 422);
}
// Validação real de conteúdo — mime_content_type é falsificável
set_error_handler(static function (): bool { return true; });
$imageInfo = getimagesize($file['tmp_name']);
restore_error_handler();
if ($imageInfo === false) {
return Response::json(['status' => 'error', 'message' => 'Arquivo inválido: não é uma imagem.'], 422);
}
return null;
}
private function validarMimeImagem(string $mime): ?Response
{
$allowed = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'];
if (!in_array($mime, $allowed, true)) {
return Response::json(['status' => 'error', 'message' => 'Formato não suportado. Use JPEG, PNG ou WebP.'], 422);
}
return null;
}
private function gerarNomeArquivo(string $uuid, string $tipo, string $mime): string
{
$extMap = ['image/jpeg' => 'jpg', 'image/jpg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp'];
$ext = $extMap[$mime] ?? 'jpg';
return $uuid . '_' . $tipo . '_' . time() . '.' . $ext;
}
public function deletarMinhaConta(Request $request): Response
{
try {
$authUser = $request->attribute('auth_user');
if (!$authUser) {
return Response::json(['status' => 'error', 'message' => 'Não autenticado.'], 401);
}
$body = $request->body ?? [];
$senha = (string) ($body['senha'] ?? $body['password'] ?? '');
if ($senha === '' || !$authUser->verificarSenha($senha)) {
return Response::json(['status' => 'error', 'message' => 'Senha incorreta.'], 403);
}
$this->service->deletar($authUser->getUuid()->toString());
return Response::json(['status' => 'success', 'message' => 'Conta removida.']);
} catch (DomainException | ModuleDomainException $e) {
$status = $e->getCode() >= 400 && $e->getCode() <= 599 ? $e->getCode() : 422;
return Response::json(['status' => 'error', 'message' => $e->getMessage()], $status);
} catch (\Throwable $e) {
return Response::json(['status' => 'error', 'message' => 'Erro ao remover conta.', 'details' => $this->debug($e)], 500);
}
}
// ── Perfil público ────────────────────────────────────────────────────
public function buscarPorUsername(Request $request, string $username): Response
{
try {
$usuario = $this->service->buscarPorUsername($username);
if (!$usuario || !$usuario->isAtivo()) {
return Response::json(['status' => 'error', 'message' => 'Usuário não encontrado.'], 404);
}
return Response::json(['status' => 'success', 'usuario' => $this->serializarPublico($usuario)]);
} catch (\Throwable $e) {
return Response::json(['status' => 'error', 'message' => 'Erro ao buscar perfil.', 'details' => $this->debug($e)], 500);
}
}
public function exibirPerfilHtml(Request $request, string $username): Response
{
try {
$usuario = $this->service->buscarPorUsername($username);
if (!$usuario || !$usuario->isAtivo()) {
return Response::html('<h1>Usuário não encontrado</h1>', 404);
}
$nome = htmlspecialchars($usuario->getNomeCompleto(), ENT_QUOTES, 'UTF-8');
$usernameHtml = htmlspecialchars($usuario->getUsername(), ENT_QUOTES, 'UTF-8');
$avatar = htmlspecialchars($usuario->getUrlAvatar() ?? '', ENT_QUOTES, 'UTF-8');
$bio = htmlspecialchars($usuario->getBiografia() ?? '', ENT_QUOTES, 'UTF-8');
$html = "<!doctype html><meta charset='utf-8'><title>{$nome}</title>"
. ($avatar ? "<img src='{$avatar}' alt='Avatar'>" : '')
. "<h1>{$nome}</h1><p>@{$usernameHtml}</p>"
. ($bio ? "<p>{$bio}</p>" : '');
return Response::html($html);
} catch (\Throwable $e) {
return Response::html('<h1>Erro interno</h1>', 500);
}
}
// ── Verificação de e-mail ─────────────────────────────────────────────
public function enviarVerificacaoEmail(Request $request, string $uuid): Response
{
try {
$usuario = $this->service->buscarPorUuid($uuid);
if (!$usuario) {
return Response::json(['status' => 'error', 'message' => 'Usuário não encontrado.'], 404);
}
if ($usuario->isEmailVerificado()) {
return Response::json(['status' => 'success', 'message' => 'E-mail já verificado.']);
}
// Força envio ignorando throttle (ação administrativa explícita)
$this->enviarEmailVerificacaoForcado($usuario);
return Response::json(['status' => 'success', 'message' => 'E-mail de verificação enviado.']);
} catch (\Throwable $e) {
return Response::json(['status' => 'error', 'message' => 'Erro ao enviar e-mail.', 'details' => $this->debug($e)], 500);
}
}
public function enviarVerificacaoEmailPorEmail(Request $request): Response
{
try {
$body = $request->body ?? [];
$email = trim((string) ($body['email'] ?? ''));
if ($email === '') {
return Response::json(['status' => 'error', 'message' => 'E-mail é obrigatório.'], 422);
}
$usuario = $this->service->buscarPorEmail($email);
if (!$usuario) {
return Response::json(['status' => 'success', 'message' => 'Se o e-mail existir, o link será enviado.']);
}
if ($usuario->isEmailVerificado()) {
return Response::json(['status' => 'success', 'message' => 'E-mail já verificado.']);
}
// Força envio ignorando throttle (ação administrativa explícita)
$this->enviarEmailVerificacaoForcado($usuario);
return Response::json(['status' => 'success', 'message' => 'E-mail de verificação enviado.']);
} catch (\Throwable $e) {
return Response::json(['status' => 'error', 'message' => 'Erro ao gerar token.', 'details' => $this->debug($e)], 500);
}
}
public function verificarEmailStatus(Request $request): Response
{
try {
$uuid = trim((string) ($request->query['uuid'] ?? ''));
if ($uuid === '') {
return Response::json(['status' => 'error', 'message' => 'UUID é obrigatório.'], 422);
}
$usuario = $this->service->buscarPorUuid($uuid);
if (!$usuario) {
return Response::json(['status' => 'error', 'message' => 'Usuário não encontrado.'], 404);
}
return Response::json(['status' => 'success', 'verificado' => $usuario->isEmailVerificado()]);
} catch (\Throwable $e) {
return Response::json(['status' => 'error', 'message' => 'Erro ao verificar status.', 'details' => $this->debug($e)], 500);
}
}
public function verificarEmail(Request $request, string $token): Response
{
try {
$token = trim($token);
if ($token === '') {
return Response::json(['status' => 'error', 'message' => 'Token inválido.'], 400);
}
$usuario = $this->service->buscarPorTokenVerificacaoEmail($token);
if (!$usuario) {
return Response::json(['status' => 'error', 'message' => 'Token inválido ou expirado.'], 400);
}
if ($usuario->isEmailVerificado()) {
return Response::json(['status' => 'success', 'message' => 'E-mail já verificado.']);
}
$this->service->marcarEmailComoVerificado($usuario->getUuid()->toString());
return Response::json(['status' => 'success', 'message' => 'E-mail verificado com sucesso.']);
} catch (\Throwable $e) {
return Response::json(['status' => 'error', 'message' => 'Erro ao verificar e-mail.', 'details' => $this->debug($e)], 500);
}
}
// ── Helpers ───────────────────────────────────────────────────────────
/**
* Envia e-mail de verificação sem verificar throttle.
* Usado por ações administrativas explícitas.
*/
private function enviarEmailVerificacaoForcado(\Src\Modules\Usuario\Entities\Usuario $usuario): void
{
if ($this->emailSender === null) {
return;
}
try {
$token = bin2hex(random_bytes(32));
$this->service->salvarTokenVerificacaoEmail($usuario->getUuid()->toString(), $token);
$this->registrarReenvioVerificacao($usuario->getEmail());
$base = rtrim($_ENV['APP_URL_FRONTEND'] ?? $_ENV['APP_URL'] ?? '', '/');
if ($base === '') {
$scheme = $_SERVER['REQUEST_SCHEME'] ?? 'http';
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
$base = $scheme . '://' . $host;
}
$link = $base . '/verificar-email?token=' . urlencode($token);
$this->emailSender->sendConfirmation(
$usuario->getEmail(),
$usuario->getNomeCompleto(),
$link,
$_ENV['APP_LOGO_URL'] ?? null
);
} catch (\Throwable $e) {
error_log('[UsuarioController] Falha ao enviar e-mail de verificação: ' . $e->getMessage());
}
}
private function serializar(Usuario $u): array
{
return [
'uuid' => $u->getUuid()->toString(),
'nome_completo' => $u->getNomeCompleto(),
'username' => $u->getUsername(),
'email' => $u->getEmail(),
'nivel_acesso' => $u->getNivelAcesso(),
'ativo' => $u->isAtivo(),
'verificado_email' => $u->isEmailVerificado(),
'url_avatar' => $u->getUrlAvatar(),
'url_capa' => $u->getUrlCapa(),
'biografia' => $u->getBiografia(),
'criado_em' => $u->getCriadoEm()->format('Y-m-d\TH:i:sP'),
'atualizado_em' => $u->getAtualizadoEm()?->format('Y-m-d\TH:i:sP'),
];
}
private function serializarPublico(Usuario $u): array
{
return [
'username' => $u->getUsername(),
'nome_completo' => $u->getNomeCompleto(),
'url_avatar' => $u->getUrlAvatar(),
'url_capa' => $u->getUrlCapa(),
'biografia' => $u->getBiografia(),
];
}
private function debug(\Throwable $e): ?string
{
return ($_ENV['APP_DEBUG'] ?? 'false') === 'true' ? $e->getMessage() : null;
}
}
Passo 9 — Criar a Migration
A migration cria a tabela usuarios com suporte a PostgreSQL e MySQL. O campo nivel_acesso usa CHECK constraint no PostgreSQL. Retorne um array com as chaves up e down.
Arquivo: src/Modules/Usuario/Database/Migrations/001_create_usuarios.php
<?php
/**
* Migration: Módulo Usuario — Tabela usuarios
*/
return [
'up' => function (PDO $pdo): void {
$driver = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
if ($driver === 'pgsql') {
$pdo->exec("
CREATE TABLE IF NOT EXISTS usuarios (
uuid UUID NOT NULL PRIMARY KEY,
nome_completo VARCHAR(255) NOT NULL,
username VARCHAR(50) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL UNIQUE,
senha_hash VARCHAR(255) NOT NULL,
url_avatar VARCHAR(255),
url_capa VARCHAR(255),
biografia TEXT,
nivel_acesso VARCHAR(20) DEFAULT 'usuario'
CHECK (nivel_acesso IN ('usuario','admin','moderador','admin_system')),
token_recuperacao_senha VARCHAR(255),
token_verificacao_email VARCHAR(255),
ativo BOOLEAN NOT NULL DEFAULT TRUE,
verificado_email BOOLEAN NOT NULL DEFAULT FALSE,
criado_em TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
atualizado_em TIMESTAMP,
status_verificacao VARCHAR(30) DEFAULT 'Não verificado'
)
");
$pdo->exec("CREATE INDEX IF NOT EXISTS idx_usuarios_email ON usuarios (email)");
$pdo->exec("CREATE INDEX IF NOT EXISTS idx_usuarios_username ON usuarios (username)");
$pdo->exec("CREATE INDEX IF NOT EXISTS idx_usuarios_ativo ON usuarios (ativo)");
} else {
$pdo->exec("
CREATE TABLE IF NOT EXISTS usuarios (
uuid CHAR(36) NOT NULL PRIMARY KEY,
nome_completo VARCHAR(255) NOT NULL,
username VARCHAR(50) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL UNIQUE,
senha_hash VARCHAR(255) NOT NULL,
url_avatar VARCHAR(255),
url_capa VARCHAR(255),
biografia TEXT,
nivel_acesso VARCHAR(20) DEFAULT 'usuario',
token_recuperacao_senha VARCHAR(255),
token_verificacao_email VARCHAR(255),
ativo TINYINT(1) NOT NULL DEFAULT 1,
verificado_email TINYINT(1) NOT NULL DEFAULT 0,
criado_em DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
atualizado_em DATETIME,
status_verificacao VARCHAR(30) DEFAULT 'Não verificado',
INDEX idx_email (email),
INDEX idx_username (username),
INDEX idx_ativo (ativo)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
");
}
},
'down' => function (PDO $pdo): void {
$pdo->exec("DROP TABLE IF EXISTS usuarios");
},
];
Passo 10 — Criar o Seeder
O Seeder cria o usuário admin_system inicial. As credenciais podem ser sobrescritas via variáveis de ambiente. O seeder verifica duplicidade antes de inserir.
Defina ADMIN_EMAIL, ADMIN_PASSWORD, ADMIN_NAME e ADMIN_USERNAME no .env antes de rodar o seeder em produção.
Arquivo: src/Modules/Usuario/Database/Seeders/001_admin_user.php
<?php
/**
* Seeder: Módulo Usuario — Usuário admin padrão
*
* Cria o usuário admin_system inicial se não existir.
*
* Credenciais padrão:
* E-mail: [email protected]
* Senha: admin123
*
* Sobrescreva via variáveis de ambiente:
* ADMIN_EMAIL, ADMIN_PASSWORD, ADMIN_NAME, ADMIN_USERNAME
*
* ⚠ TROQUE A SENHA EM PRODUÇÃO!
*/
return function (PDO $pdo): void {
$email = $_ENV['ADMIN_EMAIL'] ?? '[email protected]';
$senha = $_ENV['ADMIN_PASSWORD'] ?? 'admin123';
$nome = $_ENV['ADMIN_NAME'] ?? 'Administrador';
$username = $_ENV['ADMIN_USERNAME'] ?? 'admin';
// Verifica se já existe pelo e-mail
$stmt = $pdo->prepare("SELECT 1 FROM usuarios WHERE email = :email LIMIT 1");
$stmt->execute([':email' => $email]);
if ($stmt->fetchColumn()) {
echo " ⊘ Admin já existe: $email
";
return;
}
// Verifica se o username já está em uso
$stmt = $pdo->prepare("SELECT 1 FROM usuarios WHERE username = :username LIMIT 1");
$stmt->execute([':username' => $username]);
if ($stmt->fetchColumn()) {
$username = 'admin_' . bin2hex(random_bytes(3));
}
$uuid = \Ramsey\Uuid\Uuid::uuid4()->toString();
// Usa PASSWORD_BCRYPT para compatibilidade máxima
// O seeder cria o hash diretamente — sem passar pela validação de complexidade
// da entidade Usuario, pois é um usuário de bootstrap do sistema
$hash = password_hash($senha, PASSWORD_BCRYPT, ['cost' => 12]);
$driver = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
if ($driver === 'pgsql') {
$stmt = $pdo->prepare("
INSERT INTO usuarios
(uuid, nome_completo, username, email, senha_hash,
nivel_acesso, ativo, verificado_email, status_verificacao, criado_em)
VALUES
(:uuid, :nome, :username, :email, :hash,
'admin_system', TRUE, TRUE, 'verificado', NOW())
");
} else {
$stmt = $pdo->prepare("
INSERT INTO usuarios
(uuid, nome_completo, username, email, senha_hash,
nivel_acesso, ativo, verificado_email, status_verificacao, criado_em)
VALUES
(:uuid, :nome, :username, :email, :hash,
'admin_system', 1, 1, 'verificado', NOW())
");
}
$stmt->execute([
':uuid' => $uuid,
':nome' => $nome,
':username' => $username,
':email' => $email,
':hash' => $hash,
]);
echo " ✔ Admin criado com sucesso!\n";
echo " E-mail: $email\n";
echo " Username: $username\n";
echo " Senha: $senha\n";
echo " Nível: admin_system\n";
echo "\n ⚠ TROQUE A SENHA EM PRODUÇÃO!\n";
};
Passo 11 — Criar o connection.php
Este arquivo declara qual banco de dados o módulo usa. O valor core aponta para as variáveis DB_* do .env. Use modules para DB2_* ou auto para detecção automática.
Arquivo: src/Modules/Usuario/Database/connection.php
<?php
// Define qual banco de dados este módulo usa.
// Opções: 'core' (DB_*) | 'modules' (DB2_*) | 'auto'
return 'core';
Passo 12 — Definir as Rotas
O arquivo Routes/web.php é obrigatório para que o sistema registre as rotas HTTP do módulo. A variável \ é injetada automaticamente pelo Kernel.
Rotas protegidas por AdminOnlyMiddleware (nível admin_system) exigem um token JWT assinado com JWT_API_SECRET. Use o endpoint POST /api/auth/login (não /api/login) para obter esse token.
Arquivo: src/Modules/Usuario/Routes/web.php
<?php
use Src\Kernel\Middlewares\AuthHybridMiddleware;
use Src\Kernel\Middlewares\AdminOnlyMiddleware;
use Src\Kernel\Middlewares\RateLimitMiddleware;
use Src\Kernel\Middlewares\CircuitBreakerMiddleware;
use Src\Modules\Usuario\Controllers\UsuarioController;
/** @var \Src\Kernel\Contracts\RouterInterface $router */
// Middlewares
$adminProtected = [AuthHybridMiddleware::class, AdminOnlyMiddleware::class];
$userProtected = [AuthHybridMiddleware::class];
// Circuit breaker para rotas que dependem de DB
$dbCircuit = [CircuitBreakerMiddleware::class, ['service' => 'database', 'threshold' => 5, 'cooldown' => 20]];
// Rate limits
$registroRateLimit = [RateLimitMiddleware::class, ['limit' => 5, 'window' => 60, 'key' => 'usuario.registro']];
$reenvioRateLimit = [RateLimitMiddleware::class, ['limit' => 3, 'window' => 300, 'key' => 'usuario.reenvio']];
$perfilRateLimit = [RateLimitMiddleware::class, ['limit' => 30, 'window' => 60, 'key' => 'usuario.perfil']];
$verificaRateLimit = [RateLimitMiddleware::class, ['limit' => 10, 'window' => 60, 'key' => 'usuario.verifica']];
// Registro público de usuário
$router->post('/api/criar/usuario', [UsuarioController::class, 'criar'], [$registroRateLimit, $dbCircuit]);
$router->post('/api/registrar', [UsuarioController::class, 'criar'], [$registroRateLimit, $dbCircuit]);
// Reenvio de e-mail de verificação (público, sem autenticação)
$router->post('/api/auth/reenviar-verificacao', [UsuarioController::class, 'reenviarVerificacaoEmail'], [$reenvioRateLimit]);
// Gerenciamento de usuários (admin)
$router->get('/api/usuarios', [UsuarioController::class, 'listar'], $adminProtected);
$router->get('/api/usuario/{uuid}', [UsuarioController::class, 'buscar'], $adminProtected);
$router->put('/api/usuario/atualizar/{uuid}', [UsuarioController::class, 'atualizar'], $adminProtected);
$router->delete('/api/usuario/deletar/{uuid}', [UsuarioController::class, 'deletar'], $adminProtected);
$router->patch('/api/usuario/{uuid}/desativar', [UsuarioController::class, 'desativar'], $adminProtected);
$router->patch('/api/usuario/{uuid}/ativar', [UsuarioController::class, 'ativar'], $adminProtected);
// Perfil do usuário autenticado
$router->get('/api/perfil', [UsuarioController::class, 'perfil'], $userProtected);
$router->put('/api/perfil', [UsuarioController::class, 'atualizarPerfil'], $userProtected);
$router->put('/api/perfil/email', [UsuarioController::class, 'alterarEmail'], $userProtected);
$router->put('/api/perfil/senha', [UsuarioController::class, 'alterarSenha'], $userProtected);
$router->post('/api/perfil/upload', [UsuarioController::class, 'uploadProfileImage'], $userProtected);
$router->delete('/api/perfil', [UsuarioController::class, 'deletarMinhaConta'], $userProtected);
// Perfil público
$router->get('/api/perfil/{username}', [UsuarioController::class, 'buscarPorUsername'], [$perfilRateLimit]);
$router->get('/perfil/{username}', [UsuarioController::class, 'exibirPerfilHtml'], [$perfilRateLimit]);
// Verificação de e-mail
$router->post('/api/usuarios/{uuid}/enviar-verificacao-email', [UsuarioController::class, 'enviarVerificacaoEmail'], $adminProtected);
$router->post('/api/usuarios/enviar-verificacao-email', [UsuarioController::class, 'enviarVerificacaoEmailPorEmail'], $adminProtected);
$router->get('/api/usuarios/verificar-email-status', [UsuarioController::class, 'verificarEmailStatus'], $adminProtected);
$router->post('/api/usuarios/verificar-email/{token}', [UsuarioController::class, 'verificarEmail'], [$verificaRateLimit]);
Resumo dos Endpoints
| Método | Rota | Acesso | Descrição |
|---|---|---|---|
| POST | /api/criar/usuario | Público | Registro de usuário |
| POST | /api/registrar | Público | Alias de registro |
| POST | /api/auth/reenviar-verificacao | Público | Reenviar e-mail de verificação |
| GET | /api/usuarios | Admin | Listar usuários com filtros e paginação |
| GET | /api/usuario/{uuid} | Admin | Buscar usuário por UUID |
| PUT | /api/usuario/atualizar/{uuid} | Admin | Atualizar dados de usuário |
| DELETE | /api/usuario/deletar/{uuid} | Admin | Remover usuário |
| PATCH | /api/usuario/{uuid}/desativar | Admin | Desativar usuário |
| PATCH | /api/usuario/{uuid}/ativar | Admin | Ativar usuário |
| GET | /api/perfil | Autenticado | Ver próprio perfil |
| PUT | /api/perfil | Autenticado | Atualizar próprio perfil |
| PUT | /api/perfil/email | Autenticado | Alterar e-mail |
| PUT | /api/perfil/senha | Autenticado | Alterar senha |
| POST | /api/perfil/upload | Autenticado | Upload de avatar ou capa |
| DELETE | /api/perfil | Autenticado | Deletar própria conta |
| GET | /api/perfil/{username} | Público | Perfil público JSON |
| GET | /perfil/{username} | Público | Perfil público HTML |
| POST | /api/usuarios/{uuid}/enviar-verificacao-email | Admin | Enviar verificação por UUID |
| POST | /api/usuarios/enviar-verificacao-email | Admin | Enviar verificação por e-mail |
| GET | /api/usuarios/verificar-email-status | Admin | Verificar status de e-mail |
| POST | /api/usuarios/verificar-email/{token} | Público | Confirmar e-mail via token |
email_throttle
O Controller usa EmailThrottle para evitar disparos duplicados de e-mail. Essa classe depende da tabela email_throttle, que é criada automaticamente pela migration do Kernel (002_email_tables.sql) quando você roda php vupi migrate. Você não precisa criar essa tabela manualmente — ela já faz parte do core do sistema.
Com todos os arquivos criados, rode as migrations e o seeder para inicializar o banco:
php vupi migrate --seedDepois teste o registro com:
curl -X POST http://localhost/api/registrar \
-H "Content-Type: application/json" \
-d '{"nome_completo":"Teste","username":"teste","email":"[email protected]","senha":"Senha@123"}'Rotas e Middlewares
Como definir endpoints e protegê-los.
Definindo rotas
O arquivo Routes/web.php do módulo é carregado automaticamente. A variável $router já está disponível.
// Métodos disponíveis
$router->get('/api/recurso', [Controller::class, 'listar']);
$router->post('/api/recurso', [Controller::class, 'criar']);
$router->put('/api/recurso/{uuid}', [Controller::class, 'atualizar']);
$router->patch('/api/recurso/{uuid}', [Controller::class, 'parcial']);
$router->delete('/api/recurso/{uuid}',[Controller::class, 'deletar']);
Parâmetros de rota
// Definição
$router->get('/api/usuario/{uuid}', [UsuarioController::class, 'buscar']);
// No Controller
public function buscar(Request $request): Response
{
$uuid = $request->param('uuid'); // captura {uuid}
// ...
}
Middlewares disponíveis
| Middleware | Quando usar |
|---|---|
AuthHybridMiddleware | Qualquer rota que exige usuário logado. |
AdminOnlyMiddleware | Rotas exclusivas para admin_system. |
RateLimitMiddleware | Limitar requisições por IP/usuário. |
CircuitBreakerMiddleware | Rotas que dependem de banco ou serviços externos. |
ApiTokenMiddleware | Rotas acessadas por tokens de API (integrações). |
Configurando Rate Limit
$loginLimit = [RateLimitMiddleware::class, [
'limit' => 10, // max requisições
'window' => 60, // janela em segundos
'key' => 'auth.login', // chave única para este limite
'user_limit' => 5, // limite por usuário autenticado (opcional)
]];
$router->post('/api/login', [AuthController::class, 'login'], [$loginLimit]);
Rotas nativas do sistema
Os módulos Auth e Usuario já vêm com as seguintes rotas registradas:
Módulo Auth
| Método | Rota | Acesso | Descrição |
|---|---|---|---|
| POST | /api/auth/login | Pública | Login. Rate limit: 10/min. |
| POST | /api/login | Pública | Alias de login. |
| POST | /api/auth/refresh | Pública | Renova access token via refresh token. Rate limit: 20/min. |
| GET | /api/auth/me | Auth | Dados do usuário autenticado. |
| POST | /api/auth/logout | Auth | Logout — revoga tokens. |
| POST | /api/auth/recuperacao-senha | Pública | Solicita reset de senha. Rate limit: 5/min. |
| POST | /api/auth/resetar-senha | Pública | Redefine senha com token. |
| GET | /api/recuperar-senha/validar/{token} | Pública | Valida token de recuperação. |
| GET | /api/auth/verify-email | Pública | Verifica e-mail via token. |
| GET | /api/auth/email-verification | Auth | Política de verificação de e-mail. |
Módulo Usuario
| Método | Rota | Acesso | Descrição |
|---|---|---|---|
| POST | /api/registrar | Pública | Registro de novo usuário. Rate limit: 5/min. |
| GET | /api/perfil | Auth | Dados do perfil autenticado. |
| PUT | /api/perfil | Auth | Atualiza perfil. |
| PUT | /api/perfil/email | Auth | Altera e-mail. |
| PUT | /api/perfil/senha | Auth | Altera senha. |
| POST | /api/perfil/upload | Auth | Upload de avatar/capa. |
| DELETE | /api/perfil | Auth | Deleta a própria conta. |
| GET | /api/perfil/{username} | Pública | Perfil público. Rate limit: 30/min. |
| GET | /api/usuarios | Admin | Lista todos os usuários. |
| GET | /api/usuario/{uuid} | Admin | Busca usuário por UUID. |
| PUT | /api/usuario/atualizar/{uuid} | Admin | Atualiza usuário. |
| DELETE | /api/usuario/deletar/{uuid} | Admin | Remove usuário. |
| PATCH | /api/usuario/{uuid}/ativar | Admin | Ativa usuário. |
| PATCH | /api/usuario/{uuid}/desativar | Admin | Desativa usuário. |
Banco de Dados
Migrations, seeders e conexões — com exemplos reais do módulo Usuario.
Migrations
Uma migration é um arquivo PHP em Database/Migrations/ que retorna um array com duas chaves: up (aplica a mudança) e down (reverte). O nome do arquivo define a ordem de execução — use prefixo numérico.
Nunca edite uma migration já executada. O sistema guarda um hash de cada arquivo e lança erro se detectar alteração. Para mudar o schema, crie uma nova migration.
Criando uma tabela (exemplo: usuarios)
Arquivo: src/Modules/Usuario/Database/Migrations/001_create_usuarios.php
<?php
return [
'up' => function (PDO $pdo): void {
$driver = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
if ($driver === 'pgsql') {
$pdo->exec("
CREATE TABLE IF NOT EXISTS usuarios (
uuid UUID NOT NULL PRIMARY KEY,
nome_completo VARCHAR(255) NOT NULL,
username VARCHAR(50) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL UNIQUE,
senha_hash VARCHAR(255) NOT NULL,
nivel_acesso VARCHAR(20) DEFAULT 'usuario'
CHECK (nivel_acesso IN ('usuario','admin','moderador','admin_system')),
ativo BOOLEAN NOT NULL DEFAULT TRUE,
verificado_email BOOLEAN NOT NULL DEFAULT FALSE,
criado_em TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
atualizado_em TIMESTAMP
)
");
$pdo->exec("CREATE INDEX IF NOT EXISTS idx_usuarios_email ON usuarios (email)");
$pdo->exec("CREATE INDEX IF NOT EXISTS idx_usuarios_username ON usuarios (username)");
} else {
$pdo->exec("
CREATE TABLE IF NOT EXISTS usuarios (
uuid CHAR(36) NOT NULL PRIMARY KEY,
nome_completo VARCHAR(255) NOT NULL,
username VARCHAR(50) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL UNIQUE,
senha_hash VARCHAR(255) NOT NULL,
nivel_acesso VARCHAR(20) DEFAULT 'usuario',
ativo TINYINT(1) NOT NULL DEFAULT 1,
verificado_email TINYINT(1) NOT NULL DEFAULT 0,
criado_em DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
atualizado_em DATETIME,
INDEX idx_email (email),
INDEX idx_username (username)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
");
}
},
'down' => function (PDO $pdo): void {
$pdo->exec("DROP TABLE IF EXISTS usuarios");
},
];
Alterando uma tabela (nova migration)
Para adicionar ou modificar colunas, crie um novo arquivo — nunca edite o anterior.
Arquivo: src/Modules/Usuario/Database/Migrations/002_add_biografia_to_usuarios.php
<?php
return [
'up' => function (PDO $pdo): void {
$driver = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
if ($driver === 'pgsql') {
$pdo->exec("ALTER TABLE usuarios ADD COLUMN IF NOT EXISTS biografia TEXT");
$pdo->exec("ALTER TABLE usuarios ADD COLUMN IF NOT EXISTS url_avatar VARCHAR(255)");
} else {
// MySQL não tem ADD COLUMN IF NOT EXISTS — verifica antes
$cols = $pdo->query("SHOW COLUMNS FROM usuarios LIKE 'biografia'")->fetchAll();
if (empty($cols)) {
$pdo->exec("ALTER TABLE usuarios ADD COLUMN biografia TEXT");
$pdo->exec("ALTER TABLE usuarios ADD COLUMN url_avatar VARCHAR(255)");
}
}
},
'down' => function (PDO $pdo): void {
$pdo->exec("ALTER TABLE usuarios DROP COLUMN IF EXISTS biografia");
$pdo->exec("ALTER TABLE usuarios DROP COLUMN IF EXISTS url_avatar");
},
];
Comandos de migration
php vupi migrate # executa todas as migrations pendentes
php vupi migrate --seed # migrations + seeders
php vupi migrate --rollback # desfaz a última migration
php vupi migrate --status # lista status de cada migration (done/pending)
php vupi migrate --core # apenas conexão core (DB_*)
php vupi migrate --modules # apenas conexão modules (DB2_*)
# Alias equivalente
php db migrate
php db seed
php db rollback
O Migrator cria automaticamente uma tabela migrations no banco e guarda o nome e hash de cada arquivo executado. Se você alterar um arquivo já executado, o sistema detecta a mudança e lança erro antes de continuar.
Seeders
Um seeder é um arquivo PHP em Database/Seeders/ que retorna uma callable (função anônima). Ele recebe o PDO e insere dados iniciais. Seeders também são rastreados — cada arquivo roda apenas uma vez.
Use ON CONFLICT DO NOTHING (PostgreSQL) ou ON DUPLICATE KEY UPDATE / verificação prévia (MySQL) para que o seeder não falhe se rodar mais de uma vez.
Criando um seeder (exemplo: admin inicial)
Arquivo: src/Modules/Usuario/Database/Seeders/001_admin_user.php
<?php
/**
* Seeder: cria o usuário admin_system inicial.
* Credenciais configuráveis via .env:
* ADMIN_EMAIL, ADMIN_PASSWORD, ADMIN_NAME, ADMIN_USERNAME
*/
return function (PDO $pdo): void {
$email = $_ENV['ADMIN_EMAIL'] ?? '[email protected]';
$senha = $_ENV['ADMIN_PASSWORD'] ?? 'admin123';
$nome = $_ENV['ADMIN_NAME'] ?? 'Administrador';
$username = $_ENV['ADMIN_USERNAME'] ?? 'admin';
// Verifica se já existe — idempotência
$stmt = $pdo->prepare("SELECT 1 FROM usuarios WHERE email = :email LIMIT 1");
$stmt->execute([':email' => $email]);
if ($stmt->fetchColumn()) {
echo " ⊘ Admin já existe: {$email}\n";
return;
}
// Verifica conflito de username
$stmt = $pdo->prepare("SELECT 1 FROM usuarios WHERE username = :username LIMIT 1");
$stmt->execute([':username' => $username]);
if ($stmt->fetchColumn()) {
$username = 'admin_' . bin2hex(random_bytes(3));
}
$uuid = \Ramsey\Uuid\Uuid::uuid4()->toString();
$hash = password_hash($senha, PASSWORD_BCRYPT, ['cost' => 12]);
$driver = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
if ($driver === 'pgsql') {
$stmt = $pdo->prepare("
INSERT INTO usuarios (uuid, nome_completo, username, email, senha_hash,
nivel_acesso, ativo, verificado_email, criado_em)
VALUES (:uuid, :nome, :username, :email, :hash,
'admin_system', TRUE, TRUE, NOW())
");
} else {
$stmt = $pdo->prepare("
INSERT INTO usuarios (uuid, nome_completo, username, email, senha_hash,
nivel_acesso, ativo, verificado_email, criado_em)
VALUES (:uuid, :nome, :username, :email, :hash,
'admin_system', 1, 1, NOW())
");
}
$stmt->execute([
':uuid' => $uuid,
':nome' => $nome,
':username' => $username,
':email' => $email,
':hash' => $hash,
]);
echo " ✔ Admin criado: {$email} / {$username}\n";
echo " ⚠ TROQUE A SENHA EM PRODUÇÃO!\n";
};
Executando seeders
php vupi migrate --seed # migrations + seeders juntos (recomendado)
php db seed # apenas seeders
Dois bancos simultâneos
O sistema suporta dois bancos: core (DB_*) para módulos nativos e modules (DB2_*) para módulos externos. Declare qual usar no connection.php do módulo:
<?php
// src/Modules/MeuModulo/Database/connection.php
// Opções: 'core' (DB_*) | 'modules' (DB2_*) | 'auto'
return 'core';
| Valor | Usa | Quando usar |
|---|---|---|
'core' | DB_* | Módulos nativos em src/Modules/ (padrão) |
'modules' | DB2_* | Módulos externos / plugins que precisam de banco separado |
'auto' | Detecta automaticamente | Módulos em vendor/ usam modules; os demais usam core |
Sempre use Prepared Statements
Concatenar variáveis em SQL abre brecha para SQL Injection. Sempre use prepare() + bindValue().
// ERRADO — vulnerável a SQL Injection
$pdo->query("SELECT * FROM usuarios WHERE email = '$email'");
// CORRETO — sempre assim
$stmt = $pdo->prepare("SELECT * FROM usuarios WHERE email = :email");
$stmt->bindValue(':email', $email);
$stmt->execute();
$row = $stmt->fetch(PDO::FETCH_ASSOC);
Comunicação Entre Módulos
Como módulos conversam sem acoplamento.
Dependência obrigatória
Use quando seu módulo não funciona sem o outro. Se o módulo dependente for removido, o sistema para com erro claro.
use Src\Modules\Usuario\Services\UsuarioService;
class FaturaService
{
public function __construct(
private UsuarioService $usuarioService // obrigatorio
) {}
public function emitir(string $userUuid): void
{
$usuario = $this->usuarioService->buscarPorUuid($userUuid);
// ...
}
}
Dependência opcional
Use quando seu módulo pode funcionar sem o outro. O Container injeta null se o módulo não existir.
use Src\Kernel\Contracts\EmailSenderInterface;
class FaturaService
{
public function __construct(
private ?EmailSenderInterface $email = null // opcional
) {}
public function processar(): void
{
$this->salvarNoBanco();
// Operador nullsafe: se $email for null, linha ignorada sem erro
$this->email?->sendCustom('[email protected]', 'Fatura', '<p>Gerada!</p>');
}
}
Nunca acesse a tabela de outro módulo diretamente via SQL. Sempre use o Service do outro módulo para obter dados.
Segurança
O sistema foi construído com segurança em cada camada.
Autenticação JWT
O sistema usa dois secrets JWT distintos:
| Secret | Usado para |
|---|---|
JWT_SECRET | Tokens de usuários comuns. |
JWT_API_SECRET | Tokens de admin_system e tokens de API. |
Todo token é validado com: assinatura, expiração, iss, aud, jti (blacklist) e UUID do usuário.
Rate Limiting
Dupla camada: por IP e por usuário autenticado. Usa Redis (distribuído) ou File (servidor único). Configurável por rota.
ThreatScorer
Acumula pontos de comportamento suspeito por IP:
| Evento | Pontos |
|---|---|
| Acesso a honeypot | +100 |
| User-Agent malicioso | +50 |
| Falha de login | +30 |
| Rate limit excedido | +20 |
| Sem User-Agent | +15 |
Score ≥ 50: delay progressivo. Score ≥ 150: bloqueio (403). TTL: 1 hora.
Boas práticas
- Sempre use
APP_DEBUG=falseem produção. - Nunca concatene variáveis em SQL — use prepared statements.
- Nunca acesse tabelas de outros módulos diretamente.
- Valide todos os inputs no Controller ou Service.
- Use
OwnershipGuardpara verificar posse de recursos. - Gere secrets JWT com
openssl rand -hex 32. - Configure
COOKIE_SECURE=trueeCOOKIE_HTTPONLY=trueem produção.
Comandos CLI
Referência completa de todos os comandos disponíveis via php vupi e php db.
Setup
php vupi setup # menu interativo (23 opções)
php vupi setup --help # lista todas as flags
php vupi setup --auto # modo automático sem interação
Flags do modo --auto
| Flag | Valores | Padrão | Descrição |
|---|---|---|---|
--db-mode | compose, docker, skip | compose | Como subir o banco: docker-compose, container avulso ou pular. |
--server | background, php, pm2, pm2+caddy | background | Como subir o servidor PHP. |
--jwt | if-empty, skip | if-empty | Gera JWT_SECRET e JWT_API_SECRET se estiverem vazios. |
--api-token | generate, skip | skip | Gera token JWT de API após o setup. |
--caddy | production, dev, skip | skip | Inicia Caddy: produção (Let's Encrypt), dev (mkcert) ou pula. |
# Exemplos de uso
php vupi setup --auto --db-mode=docker --server=php --jwt=if-empty
php vupi setup --auto --server=pm2+caddy --caddy=production
php vupi setup --auto --db-mode=skip --caddy=dev
php vupi setup --auto --jwt=if-empty --api-token=generate
Migrations e banco
php vupi migrate # executa migrations pendentes (core + módulos)
php vupi migrate --seed # migrations + seeders
php vupi migrate --rollback # desfaz a última migration
php vupi migrate --core # apenas conexão core (DB_*)
php vupi migrate --modules # apenas conexão modules (DB2_*)
php vupi migrate --status # status de todas as migrations
php vupi migrate --status --json # status em formato JSON
# Alias: php db faz a mesma coisa
php db migrate
php db seed
php db rollback
Geração de código
php vupi make:module NomeDoModulo
php vupi make:plugin NomeDoPlugin
php vupi make:plugin NomeDoPlugin --capability=email-sender --description="Envio de e-mails"
Plugins
php vupi plugin:install nome-do-plugin # instala
php vupi plugin:enable nome-do-plugin # ativa
php vupi plugin:disable nome-do-plugin # desativa
php vupi plugin:uninstall nome-do-plugin # remove
php vupi plugin:migrate # migrations de todos os plugins
php vupi plugin:rollback nome-do-plugin # reverte migrations do plugin
php vupi plugin:validate # valida estrutura dos plugins
php vupi plugin:inspect # lista plugins instalados
Capacidades
php vupi capability:list # lista todas as capacidades
php vupi capability:list email-sender # detalha uma capacidade específica
php vupi plugin:provider:set email-sender nome-do-plugin # define o provider
Makefile
Atalhos para tarefas comuns. Execute make help para ver todos.
# Docker
make up-pg # sobe PostgreSQL + Adminer
make up-mysql # sobe MySQL + Adminer
make up-all # sobe tudo
make down # para containers
make restart # reinicia containers
make logs # logs em tempo real
make ps # lista containers
make shell-pg # abre psql no PostgreSQL
make shell-mysql # abre mysql no MySQL
# Projeto
make install # composer install
make migrate # php vupi migrate
make seed # php vupi migrate --seed
make test # testes de segurança
make analyse # PHPStan nível 6
make setup # setup completo (banco + deps + migrations)
# Caddy (proxy reverso / HTTPS)
make caddy-install # instala Caddy no Ubuntu/Debian
make caddy-start # inicia Caddy em produção (HTTPS Let's Encrypt)
make caddy-stop # para Caddy
make caddy-reload # recarrega config sem downtime
make caddy-dev # HTTPS local com mkcert (desenvolvimento)
# Limpeza
make clean # remove cache e arquivos temporários
make reset # para containers e apaga volumes (CUIDADO: apaga dados!)
Servidor
php -S localhost:3005 index.php # desenvolvimento
php vupi setup --auto --server=pm2+caddy # produção: PM2 + Caddy
php vupi setup --auto --server=pm2 # produção: apenas PM2
Testes
php vendor/bin/phpunit # todos os testes unitários
php tests/SecurityTest.php http://localhost:3005 # OWASP API Top 10
php tests/FuzzTest.php http://localhost:3005 # fuzzing de endpoints
php tests/LoadAttackTest.php http://localhost:3005 # carga + ataque combinado
php tests/PerformanceTest.php http://localhost:3005 # testes de performance
ADMIN_PASSWORD=senha php tests/EnvConfigTest.php # testes de configuração do .env
Sobre o Desenvolvedor
Informações sobre o criador da Vupi.us API.
Desenvolvedor
Adimael S.