Files
Felipe 34cbd4a861 feat: implement Google OAuth support for Android and iOS
- Added support for Google OAuth with separate client IDs for Android and iOS.
- Updated `verify_google_id_token` to validate `aud` against both client IDs and check `email_verified`.
- Modified `google_oauth_handler` to accept and process the new client IDs.
- Enhanced security by enforcing explicit JWT algorithm validation.
- Updated mobile app to handle Google OAuth flow using `expo-auth-session`.
- Fixed API request to send `id_token` in snake_case as expected by the backend.
- Added necessary environment variables for Google client IDs in mobile app.
- Implemented intent filter for Google OAuth redirect in AndroidManifest.xml.
2026-03-19 15:24:08 -03:00

775 lines
33 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# MeowSpool Backend — Agent Guide
## Visão Geral
Backend da aplicação MeowSpool escrito em **Rust**, utilizando **Axum** como framework HTTP e **SQLx** com **PostgreSQL** como banco de dados. A arquitetura segue o padrão **Hexagonal (Ports & Adapters)**, garantindo que o núcleo do domínio seja completamente isolado de detalhes de infraestrutura.
---
## Stack
| Componente | Biblioteca | Versão mínima |
| ---------------- | ------------------------------------- | ------------- |
| HTTP Framework | `axum` | 0.7 |
| Async Runtime | `tokio` (full) | 1.x |
| ORM / Query | `sqlx` (postgres, uuid, time, macros) | 0.8 |
| Autenticação JWT | `jsonwebtoken` | 9.x |
| Hash de senha | `argon2` | 0.5 |
| OAuth Google | `oauth2` | 4.x |
| UUID | `uuid` (v4, serde) | 1.x |
| Serialização | `serde`, `serde_json` | 1.x |
| Erros | `thiserror` | 1.x |
| Env vars | `dotenvy` | 0.15 |
| Logging | `tracing`, `tracing-subscriber` | 0.1 |
| Validação | `validator` | 0.18 |
| HTTP Client | `reqwest` (json, rustls-tls) | 0.12 |
| Email (SMTP) | `lettre` (smtp-transport, tokio1-rustls-tls, builder) | 0.11 |
| Geração QR Code | `qrcode` | 0.14 |
| Geração de imagem| `image` | 0.25 |
| Encode Base64 | `base64` | 0.22 |
| Geração PDF | `printpdf` | 0.7 |
---
## Estrutura de Pastas
```
backend/
├── Cargo.toml
├── Cargo.lock
├── .env.example
├── agent.md <- este arquivo
├── migrations/ <- SQL puro, gerenciado pelo SQLx CLI
│ ├── 20240101000001_create_users.sql
│ ├── 20240101000002_create_spool_presets.sql
│ ├── 20240101000003_create_filaments.sql
│ └── 20240101000004_create_token_tables.sql <- password_reset_tokens + email_verification_tokens
└── src/
├── main.rs <- entry point: inicializa config, DB, router e servidor
├── config.rs <- struct Config lida de variáveis de ambiente
├── error.rs <- AppError unificado com IntoResponse
├── router.rs <- composição de todas as rotas Axum
├── domain/ <- NÚCLEO: entidades puras, sem dependências externas
│ ├── mod.rs
│ ├── user.rs <- struct User, enum AuthProvider
│ ├── filament.rs <- struct Filament, enum Material
│ └── spool_preset.rs <- struct SpoolPreset
├── ports/ <- INTERFACES: traits que o domínio exige
│ ├── mod.rs
│ ├── user_repository.rs <- trait UserRepository
│ ├── filament_repository.rs <- trait FilamentRepository
│ └── spool_preset_repository.rs <- trait SpoolPresetRepository
├── application/ <- CASOS DE USO: orquestram domínio + ports
│ ├── mod.rs
│ ├── auth_service.rs <- login, register, OAuth, refresh, logout
│ ├── email_token_service.rs <- forgot_password, verify_email, reset_password (tokens DB + email)
│ ├── filament_service.rs <- CRUD, cálculo de peso líquido, QR, SVG
│ └── spool_preset_service.rs <- CRUD presets (system read-only, user CRUD)
├── infrastructure/ <- Serviços externos (SMTP, etc.)
│ ├── mod.rs
│ └── email_service.rs <- EmailService: SMTP via lettre (STARTTLS)
└── adapters/
├── inbound/ <- HTTP: recebe requisições, delega ao application
│ ├── mod.rs
│ ├── auth_handler.rs
│ ├── filament_handler.rs
│ ├── spool_preset_handler.rs
│ ├── user_handler.rs
│ └── middleware/
│ └── auth.rs <- extrator JWT que popula CurrentUser no estado
└── outbound/ <- INFRAESTRUTURA: implementa as traits de ports
├── mod.rs
├── postgres_user_repo.rs
├── postgres_filament_repo.rs
└── postgres_spool_preset_repo.rs
```
---
## Arquitetura Hexagonal — Regras de Dependência
```
adapters/inbound (HTTP)
|
v
application (services)
|
v
domain (entidades)
|
^
ports (traits)
|
^
adapters/outbound (PostgreSQL)
```
**Regra fundamental:** O `domain` e os `ports` **nunca** importam nada de `adapters` ou `application`. Dependências permitidas no domínio: `uuid`, `serde`, `time`. Qualquer violação é um bug arquitetural.
---
## Rotas da API
Todas as rotas são prefixadas com `/api/v1`.
### Auth — `/api/v1/auth`
| Método | Rota | Handler | Acesso |
| ------ | ------------------ | ------------------------- | ------------------------------- |
| POST | `/register` | `register_handler` | Público |
| POST | `/login` | `login_handler` | Público |
| POST | `/oauth/google` | `google_oauth_handler` | Público |
| POST | `/refresh` | `refresh_token_handler` | Público (requer refresh token) |
| POST | `/logout` | `logout_handler` | Autenticado |
| POST | `/resend-verification` | `resend_verification_handler` | Público (sempre 200) |
| POST | `/forgot-password` | `forgot_password_handler` | Público |
| POST | `/verify-email` | `verify_email_handler` | Público |
| POST | `/reset-password` | `reset_password_handler` | Público (requer token de reset) |
### Redirects para Deep Links — `/api/v1`
| Método | Rota | Handler | Descrição |
| ------ | ----------------- | --------------------------------- | ---------------------------------------------------------- |
| GET | `/verify-email` | `verify_email_redirect_handler` | Redireciona 302 → `{APP_SCHEME}://verify-email?token=xxx` |
| GET | `/reset-password` | `reset_password_redirect_handler` | Redireciona 302 → `{APP_SCHEME}://reset-password?token=xxx`|
> Esses endpoints são os **destinos dos links nos e-mails**. Clientes de e-mail (Gmail etc.) aceitam URLs `https://` normalmente; o backend redireciona para o deep link do app. O OS reconhece o scheme e abre o MeowSpool.
### Users — `/api/v1/users`
| Método | Rota | Handler | Acesso |
| ------ | ----- | ------------------- | ----------- |
| GET | `/me` | `get_me_handler` | Autenticado |
| PUT | `/me` | `update_me_handler` | Autenticado |
### Filaments — `/api/v1/filaments`
| Método | Rota | Handler | Acesso |
| ------ | ---------------- | ------------------------- | ----------- |
| GET | `/` | `list_filaments_handler` | Autenticado |
| POST | `/` | `create_filament_handler` | Autenticado |
| GET | `/:id` | `get_filament_handler` | Autenticado |
| PUT | `/:id` | `update_filament_handler` | Autenticado |
| DELETE | `/:id` | `delete_filament_handler` | Autenticado |
| GET | `/:id/qrcode` | `get_qrcode_handler` | Autenticado |
| GET | `/:id/label.svg` | `export_label_handler` | Autenticado |
| GET | `/:id/label.pdf` | `export_label_pdf_handler`| Autenticado |
**Query params de listagem (`GET /filaments`):**
- `material` — filtra por tipo (PLA, ABS, PETG, TPU, ASA, PA, PC...)
- `brand` — filtra por marca
- `search` — busca em marca, modelo e notas
- `stock_level``low` (<=15%), `medium` (<=35%), `ok` (>35%)
- `sort``net_weight_asc`, `net_weight_desc`, `created_at_desc` (padrão)
- `page` / `per_page` — paginação (padrão: página 1, 20 itens)
### Spool Presets — `/api/v1/spool-presets`
| Método | Rota | Handler | Acesso |
| ------ | ------ | ----------------------- | ------------------------------------ |
| GET | `/` | `list_presets_handler` | Autenticado |
| POST | `/` | `create_preset_handler` | Autenticado |
| PUT | `/:id` | `update_preset_handler` | Autenticado (apenas presets do user) |
| DELETE | `/:id` | `delete_preset_handler` | Autenticado (apenas presets do user) |
### Dashboard — `/api/v1/dashboard`
| Método | Rota | Handler | Acesso |
| ------ | ---- | ------------------- | ----------- |
| GET | `/` | `dashboard_handler` | Autenticado |
**Resposta do dashboard:**
```json
{
"total_stock_kg": 14.2,
"low_stock_count": 3,
"by_material": [
{ "material": "PLA", "count": 4, "total_kg": 5.8 }
],
"low_stock_filaments": [
{ "id": "...", "model": "PETG", "net_weight_g": 120, "percentage": 12 }
],
"recent_filaments": [...]
}
```
---
## Padrão de Erros
Use `thiserror` em todos os módulos e um `AppError` central em `src/error.rs`:
```rust
#[derive(Debug, thiserror::Error)]
pub enum AppError {
#[error("not found")]
NotFound,
#[error("unauthorized")]
Unauthorized,
#[error("forbidden")]
Forbidden,
#[error("validation error: {0}")]
Validation(String),
#[error("bad request: {0}")]
BadRequest(String), // token inválido, expirado, já utilizado
#[error("conflict: {0}")]
Conflict(String),
#[error("unprocessable entity: {0}")]
UnprocessableEntity(String),
#[error("internal error")]
Internal(#[from] anyhow::Error),
}
```
`AppError` implementa `IntoResponse` do Axum, mapeando cada variante para o status HTTP correto e body JSON consistente:
```json
{ "error": "not found", "code": "NOT_FOUND" }
```
---
## Convenções de Código
### Nomenclatura
- **Structs de domínio:** `PascalCase``User`, `Filament`, `SpoolPreset`
- **Traits (ports):** `PascalCase` com sufixo `Repository``UserRepository`
- **Implementações concretas:** prefixo do banco — `PostgresUserRepository`
- **Serviços:** sufixo `Service``AuthService`, `FilamentService`
- **Handlers:** sufixo `_handler``login_handler`, `create_filament_handler`
- **DTOs de entrada:** sufixo `Request``CreateFilamentRequest`
- **DTOs de saída:** sufixo `Response``FilamentResponse`
### Estrutura de um Handler
Todo handler deve ser uma função async pequena. A lógica de negócio **nunca** fica no handler — ela fica no `application/*_service.rs`.
```rust
// adapters/inbound/filament_handler.rs
pub async fn create_filament_handler(
State(state): State<AppState>,
Extension(current_user): Extension<CurrentUser>,
Json(req): Json<CreateFilamentRequest>,
) -> Result<impl IntoResponse, AppError> {
req.validate()?;
let filament = state.filament_service.create(current_user.id, req).await?;
Ok((StatusCode::CREATED, Json(FilamentResponse::from(filament))))
}
```
### Estrutura de um Service
O service recebe repositórios via injeção de dependência (trait objects em Arc):
```rust
// application/filament_service.rs
pub struct FilamentService {
repo: Arc<dyn FilamentRepository>,
preset_repo: Arc<dyn SpoolPresetRepository>,
}
impl FilamentService {
pub async fn create(&self, user_id: Uuid, req: CreateFilamentRequest) -> Result<Filament, AppError> {
// 1. buscar preset para calcular net_weight
// 2. calcular net_weight_g = total_weight_g - spool_weight_g
// 3. construir entidade Filament
// 4. persistir via repo
// 5. retornar entidade
}
}
```
### Estrutura de um Repository (Port)
```rust
// ports/filament_repository.rs
#[async_trait::async_trait]
pub trait FilamentRepository: Send + Sync {
async fn find_by_id(&self, id: Uuid, user_id: Uuid) -> Result<Option<Filament>, AppError>;
async fn list(&self, user_id: Uuid, filter: FilamentFilter) -> Result<Vec<Filament>, AppError>;
async fn create(&self, filament: &Filament) -> Result<Filament, AppError>;
async fn update(&self, filament: &Filament) -> Result<Filament, AppError>;
async fn delete(&self, id: Uuid, user_id: Uuid) -> Result<(), AppError>;
}
```
---
## Migrations
Use o **SQLx CLI** para gerenciar migrations:
```bash
# instalar
cargo install sqlx-cli --no-default-features --features postgres
# criar nova migration
sqlx migrate add <nome_descritivo>
# aplicar
sqlx migrate run
# reverter
sqlx migrate revert
```
Arquivos ficam em `backend/migrations/`. Nomeie com timestamp e descrição clara.
---
## Variáveis de Ambiente
Copie `.env.example` para `.env` antes de rodar. Variáveis principais:
| Variável | Obrigatória | Padrão | Descrição |
| ------------------------- | ----------- | ----------------------- | ---------------------------------------------- |
| `DATABASE_URL` | ✅ | — | Connection string PostgreSQL |
| `JWT_SECRET` | ✅ | — | Segredo de assinatura JWT |
| `JWT_EXPIRY_SECS` | ❌ | `3600` | TTL do access token (segundos) |
| `JWT_REFRESH_EXPIRY_SECS` | ❌ | `2592000` | TTL do refresh token (segundos) |
| `GOOGLE_CLIENT_ID` | ❌ | — | Client ID OAuth Google |
| `GOOGLE_CLIENT_SECRET` | ❌ | — | Client Secret OAuth Google |
| `HOST` | ❌ | `0.0.0.0` | Endereço de bind do servidor |
| `PORT` | ❌ | `8080` | Porta do servidor |
| `APP_ENV` | ❌ | `development` | `development` ou `production` |
| `SMTP_HOST` | ❌ | `smtp.gmail.com` | Servidor SMTP |
| `SMTP_PORT` | ❌ | `587` | Porta SMTP (STARTTLS) |
| `SMTP_USER` | ❌ | — | Usuário SMTP (e-mail) |
| `SMTP_PASS` | ❌ | — | Senha / App Password SMTP |
| `EMAIL_FROM` | ❌ | `noreply@meowspool.app` | Endereço remetente dos e-mails |
| `APP_BASE_URL` | ❌ | `http://localhost:8080` | URL base usada nos links de e-mail. Em produção: `https://meowspool.felipecncloud.com/api/v1` |
| `APP_SCHEME` | ❌ | `meowspool` | Scheme do deep link do app mobile. Usado nos redirects de e-mail |
---
## Como Adicionar um Novo Endpoint (Passo a Passo)
1. **Domain** — adicione ou modifique a entidade em `src/domain/`.
2. **Port** — adicione o método necessário no trait em `src/ports/`.
3. **Outbound Adapter** — implemente o método no repositório PostgreSQL em `src/adapters/outbound/`.
4. **Application Service** — adicione o caso de uso em `src/application/`, chamando o port.
5. **Request/Response DTOs** — defina structs com `serde` e `validator` no handler.
6. **Inbound Handler** — crie o handler em `src/adapters/inbound/`, delegando para o service.
7. **Router** — registre a rota em `src/router.rs`.
8. **Migration** — se necessário, crie um arquivo SQL em `migrations/`.
---
## Como Rodar Localmente
```bash
# na raiz do backend/
cp .env.example .env
# edite .env com suas credenciais locais
# subir PostgreSQL via Docker
docker run -d \
--name meowspool-db \
-e POSTGRES_USER=meowspool \
-e POSTGRES_PASSWORD=meowspool \
-e POSTGRES_DB=meowspool \
-p 5432:5432 \
postgres:16-alpine
# rodar migrations
sqlx migrate run
# rodar em modo watch (requer cargo-watch)
cargo watch -x run
```
---
## Sincronização Offline (Estratégia)
O backend implementa **last-write-wins com timestamp**:
- Toda entidade possui `updated_at` (timestamp UTC).
- O mobile envia o `updated_at` local no body do PUT.
- Se o `updated_at` do servidor for mais recente, retorna `409 Conflict` com a versão do servidor.
- Se o `updated_at` do cliente for mais recente (ou igual), o servidor aceita a atualização.
---
## Geração de QR Code e Etiqueta SVG
### QR Code (`GET /filaments/:id/qrcode`)
- Gera QR Code com deep link: `meowspool://filament/:id` (singular)
- Retorna PNG (`image/png`) por padrão, ou SVG com `?format=svg`
- Biblioteca: crate `qrcode`
### Etiqueta SVG (`GET /filaments/:id/label.svg`)
- Query params: `width_mm` (padrão: 50), `height_mm` (padrão: 30), `fields` (opcional), `dpi` (opcional, padrão 96)
- Layout adaptativo: modo **mini** para `width_mm < 30` ou `height_mm < 20`
- `dpi` afeta `px_per_mm` e portanto as dimensões em pixels do SVG gerado
- `Content-Type: image/svg+xml`
- `Content-Disposition: attachment; filename="meowspool-label-{id}.svg"`
### Etiqueta PDF (`GET /filaments/:id/label.pdf`)
- Query params: `width_mm` (padrão: 50), `height_mm` (padrão: 30), `fields` (opcional), `dpi` (opcional, reservado)
- Layout adaptativo: modo **mini** para `width_mm < 30` ou `height_mm < 20` — fontes e posições proporcionais, garante que nenhum elemento saia da página
- Gerado com `printpdf`: fundo escuro `#1E1B18`, barra de cor do filamento, texto com Helvetica builtin, QR Code PNG luma embutido com `interpolate: false` (bordas nítidas em impressoras térmicas)
- `Content-Type: application/pdf`
- `Content-Disposition: attachment; filename="meowspool-label-{id}.pdf"`
### Parâmetro `fields` (SVG e PDF)
Controla quais elementos aparecem na etiqueta. Valor: string com itens separados por vírgula.
| Valor | Elemento |
| --------------- | ------------------------------- |
| `color` | Barra de cor lateral |
| `name` | Nome do modelo |
| `material_brand`| Texto "Marca · Material" |
| `net_weight` | Peso líquido disponível |
| `print_temp` | Temperatura de impressão |
| `qrcode` | QR Code com deep link |
Se `fields` for omitido, todos os elementos são incluídos.
Implementado em `LabelFields` (`filament_service.rs`) — `LabelFields::from_str(Option<&str>)`.
### Parâmetro `dpi` (SVG e PDF)
Controla o DPI alvo da impressora. Afeta o cálculo de pixels do SVG e garante QR nítido em impressoras térmicas.
| Impressora | `dpi` recomendado |
| --------------------- | ----------------- |
| Tela / padrão | 96 (omitir param) |
| Niimbot D11/D110 | 203 |
| Brother QL / Dymo | 300 |
### Layout adaptativo ("mini" vs padrão)
Etiquetas com `width_mm < 30` **ou** `height_mm < 20` ativam o modo **mini**, que:
- Usa fontes menores (proporcionais à altura física)
- Posiciona QR Code com margem de 1mm (vs 2mm no padrão)
- Estreita a barra de cor (~11% da largura vs ~5% + 6px no padrão)
- Encurta o rótulo de peso: `"320g"` em vez de `"320g disponível"`
- Separador de marca/material: espaço simples em vez de ` · ` (economiza espaço)
- No SVG, o separador de meta usa espaço em vez de ` · `
- Garante que nenhum texto extrapole a borda inferior da etiqueta
Exemplo para Niimbot 22×14mm:
```
GET /api/v1/filaments/:id/label.pdf?width_mm=22&height_mm=14&fields=color,name,qrcode&dpi=203
GET /api/v1/filaments/:id/label.svg?width_mm=22&height_mm=14&fields=color,name,qrcode&dpi=203
```
---
## Segurança
- Senhas hasheadas com **Argon2id** (crate `argon2`).
- JWT assinado com **HS256** — nunca exponha o `JWT_SECRET`.
- Toda rota autenticada passa pelo middleware `auth.rs` que valida o token e popula `Extension<CurrentUser>`.
- Queries usam **bind parameters** do SQLx — nunca interpolação de string em SQL.
- `user_id` é sempre extraído do token JWT, nunca aceito como parâmetro de URL ou body.
- Presets do sistema (`is_system = true`) são protegidos no nível de serviço: edição ou deleção retorna `403 Forbidden`.
---
## Mudanças Recentes (19/03/2026) — segunda entrada
### ✅ Google OAuth — suporte a client IDs Android e iOS
**Arquivos modificados**: `src/config.rs`, `src/adapters/inbound/auth_handler.rs`
**Problema**: o backend lia apenas `GOOGLE_CLIENT_ID` (Android). Tokens emitidos pelo fluxo iOS tinham `aud` diferente e eram rejeitados.
**Mudanças**:
- `Config` ganhou campo `google_client_id_ios: String` lido de `GOOGLE_CLIENT_ID_APPLE`
- `verify_google_id_token` aceita agora dois client IDs e valida `aud` contra ambos:
```rust
let valid_ids = [client_id_android, client_id_ios];
let audience_valid = valid_ids.iter().any(|id| !id.is_empty() && *id == aud);
if !audience_valid { return Err(AppError::Unauthorized); }
```
- Assinatura atualizada: `verify_google_id_token(id_token, client_id_android, client_id_ios)`
- Call site em `google_oauth_handler` passa `state.config.google_client_id` e `state.config.google_client_id_ios`
**Variável de ambiente adicionada**:
| Variável | Valor no .env |
| ---------------------- | ------------------------------------------------------------------ |
| `GOOGLE_CLIENT_ID` | `724520558909-bg5a7e4u24jmis8lgg41ucs0kv0nfp1v.apps.googleusercontent.com` |
| `GOOGLE_CLIENT_ID_APPLE` | `724520558909-v3kubsvmf3vda7fep8qaabenmdap53hs.apps.googleusercontent.com` |
---
## Mudanças Recentes (19/03/2026)
### ✅ Hardening de segurança — Google OAuth e JWT
**Arquivos modificados**: `src/adapters/inbound/auth_handler.rs`, `src/application/auth_service.rs`
#### Google OAuth — validação de `aud` e `email_verified`
**Problema**: a função `verify_google_id_token` recebia `_client_id` como parâmetro mas nunca o usava. Qualquer portador de um token Google válido — emitido para qualquer app — podia autenticar no MeowSpool.
**Correção** (`auth_handler.rs`):
```rust
// Valida audience: o token deve ter sido emitido para este app
let aud = payload["aud"].as_str().ok_or(AppError::Unauthorized)?;
if !client_id.is_empty() && aud != client_id {
tracing::warn!(aud, client_id, "Google id_token audience mismatch");
return Err(AppError::Unauthorized);
}
// Rejeita contas Google com e-mail não verificado
let email_verified = payload["email_verified"].as_str().unwrap_or("false");
if email_verified != "true" {
tracing::warn!("Google account email not verified");
return Err(AppError::Unauthorized);
}
```
- Parâmetro renomeado de `_client_id` para `client_id`
- Rejeições geram `tracing::warn!` para auditoria
#### JWT — algoritmo explícito
**Problema**: `Validation::default()` não fixava o algoritmo permitido, abrindo margem para ataques com algoritmos inesperados.
**Correção** (`auth_service.rs`): substituído `Validation::default()` por `Validation::new(Algorithm::HS256)` em ambos os pontos de decodificação:
```rust
// validate_access_token e decode_refresh_token:
let validation = Validation::new(Algorithm::HS256);
decode::<Claims>(token, &key, &validation)
```
`Algorithm` importado de `jsonwebtoken`.
---
## Mudanças Recentes (14/03/2026)
### ✅ Reenvio de e-mail de verificação
**Arquivos modificados**: `src/application/email_token_service.rs`, `src/adapters/inbound/auth_handler.rs`, `src/router.rs`
**Novo endpoint**: `POST /api/v1/auth/resend-verification` — body: `{ "email": "..." }`
- Sempre retorna `200 OK` (não vaza se e-mail existe ou já foi verificado)
- Ignora silenciosamente se: e-mail não cadastrado, conta já verificada
- Reutiliza `send_verification_email` internamente (gera novo token com TTL 24h)
**Novo método** (`email_token_service.rs`):
```rust
pub async fn resend_verification_email(&self, email: &str) -> Result<(), AppError> {
let Some(user) = self.user_repo.find_by_email(email).await? else { return Ok(()); };
if user.email_verified { return Ok(()); }
self.send_verification_email(user.id, email).await
}
```
---
### ✅ Registro não emite tokens — login bloqueado sem verificação de e-mail
**Arquivos modificados**: `src/application/auth_service.rs`, `src/adapters/inbound/auth_handler.rs`, `src/error.rs`
**Problema**: após o registro, o app autenticava o usuário imediatamente (emitia tokens) sem exigir verificação de e-mail. Login também aceitava contas não verificadas.
**Mudanças**:
- `AppError::EmailNotVerified` adicionado → HTTP 403, code `"EMAIL_NOT_VERIFIED"`
- `AuthService::register` passa a retornar apenas `User` (sem `TokenPair`) — o handler responde `201` sem body de auth
- `AuthService::login` verifica `user.email_verified` antes de emitir tokens:
```rust
if !user.email_verified {
return Err(AppError::EmailNotVerified);
}
```
**Fluxo resultante**:
1. Registro → `201 Created` (sem tokens) → backend envia e-mail de verificação em background
2. Login com e-mail não verificado → `403 { "code": "EMAIL_NOT_VERIFIED" }`
3. Após verificar e-mail → login funciona normalmente
---
### ✅ Redirect HTTP → Deep Link para links de e-mail
**Arquivos modificados**: `src/adapters/inbound/auth_handler.rs`, `src/router.rs`, `src/config.rs`, `.env`
**Problema**: Clientes de e-mail (Gmail, etc.) bloqueiam links com scheme customizado (`meowspool://`). O link no e-mail não abria o app.
**Solução**: O backend agora gera links `https://` nos e-mails. Ao clicar, o backend redireciona (302) para o deep link do app.
**Fluxo completo**:
```
E-mail → https://meowspool.felipecncloud.com/api/v1/verify-email?token=xxx
↓ GET (browser abre normalmente)
Backend responde 302 Location: meowspool://verify-email?token=xxx
↓ OS reconhece o scheme
App MeowSpool abre → app/verify-email.tsx
POST /auth/verify-email { token } → verifica no banco
```
**Novos handlers** (`auth_handler.rs`):
- `verify_email_redirect_handler` — `GET /api/v1/verify-email?token=xxx`
- `reset_password_redirect_handler` — `GET /api/v1/reset-password?token=xxx`
**Novo campo Config** (`config.rs`):
- `app_scheme: String` — lido de `APP_SCHEME` (padrão: `meowspool`)
**`.env` atualizado**:
- `APP_BASE_URL=https://meowspool.felipecncloud.com/api/v1` (antes: `meowspool:/`)
- `APP_SCHEME=meowspool` (novo)
---
### ✅ Fluxo completo de e-mail: verificação e reset de senha
**Arquivos criados/modificados**: `migrations/20240101000004_create_token_tables.sql`, `src/infrastructure/email_service.rs`, `src/infrastructure/mod.rs`, `src/application/email_token_service.rs`, `src/adapters/inbound/auth_handler.rs`, `src/config.rs`, `src/error.rs`, `src/main.rs`, `src/router.rs`, `Cargo.toml`
#### Migrations
Criadas as tabelas `password_reset_tokens` e `email_verification_tokens` com:
- UUID como PK (gen_random_uuid)
- `token TEXT UNIQUE` — indexado para lookup rápido
- `used_at TIMESTAMPTZ` — `NULL` = não usado; preenchido na validação para invalidar após uso
- `expires_at TIMESTAMPTZ` — TTL: 1h para reset de senha, 24h para verificação de e-mail
#### EmailService (`src/infrastructure/email_service.rs`)
Envia e-mails via SMTP com STARTTLS usando `lettre`. Métodos:
- `send_password_reset(to, reset_link)` — e-mail de redefinição de senha
- `send_email_verification(to, verify_link)` — e-mail de confirmação de conta
#### EmailTokenService (`src/application/email_token_service.rs`)
Orquestra tokens no banco + envio de e-mail. Métodos:
- `forgot_password(email)` — cria token em `password_reset_tokens`, envia e-mail. Sempre retorna `Ok` (não vaza se o e-mail existe)
- `verify_email(token)` — valida token em `email_verification_tokens`, chama `user_repo.verify_email()`, marca token como usado
- `reset_password(token, new_password)` — valida token em `password_reset_tokens`, re-hash da senha com Argon2id, atualiza usuário, marca token como usado
- `send_verification_email(user_id, email)` — cria token em `email_verification_tokens` e envia e-mail. Chamado após o registro
#### Handlers implementados
Anteriormente retornavam `200 OK` sem lógica. Agora delegam ao `EmailTokenService`:
- `forgot_password_handler` — POST `/auth/forgot-password`
- `verify_email_handler` — POST `/auth/verify-email`
- `reset_password_handler` — POST `/auth/reset-password`
#### Registro envia e-mail de verificação
`register_handler` chama `email_token_service.send_verification_email()` via `tokio::spawn` (background) após criar o usuário — o cadastro responde imediatamente sem esperar o SMTP.
#### AppError::BadRequest adicionado
Nova variante para erros previsíveis do usuário (token inválido, expirado, já usado) → HTTP 400.
#### Configuração `APP_BASE_URL`
- Em produção: `APP_BASE_URL=https://meowspool.felipecncloud.com/api/v1`
- Gera links nos e-mails como `https://meowspool.felipecncloud.com/api/v1/verify-email?token=xxx`
- O backend redireciona esse GET para o deep link via `APP_SCHEME`
---
### ✅ Suporte a Impressoras Pequenas — Niimbot e layout adaptativo
**Arquivos alterados**: `filament_handler.rs`, `filament_service.rs`
#### Novo query param `dpi`
Adicionado campo `dpi: Option<u32>` ao `LabelQuery` (handler). Aceito em `/label.svg` e `/label.pdf`. Valores recomendados:
| Impressora | `dpi` |
| ---------------- | ----- |
| Tela / padrão | omitir (96) |
| Niimbot D11/D110 | 203 |
| Brother / Dymo | 300 |
No SVG, `dpi` define `px_per_mm = dpi / 25.4` — o tamanho em pixels do arquivo corresponde à resolução física da impressora. No PDF, o `dpi` é ignorado (PDF já trabalha em mm), mas reservado para uso futuro.
#### Layout adaptativo — modo "mini"
Ativado quando `width_mm < 30` **ou** `height_mm < 20` (ex: Niimbot 22×14mm). Diferenças em relação ao modo padrão:
| Aspecto | Padrão | Mini |
| --- | --- | --- |
| Barra de cor | 3.5mm fixo | 11% da largura |
| QR size | 75% da altura | 80% da altura |
| Margem QR | 2mm | 1mm |
| Y do texto | `h - 8/14/20` (fixo, quebrava para h<20) | `h * 0.72/0.50/0.28` (proporcional) |
| Fontes | 79pt fixo | proporcionais à altura física |
| Rótulo peso | `"320g disponível"` | `"320g"` |
| Separador meta | ` · ` | ` ` (espaço) |
| `interpolate` no QR (PDF) | `true` | `false` (bordas nítidas em térmica) |
**Correção crítica**: no modo anterior, `Mm(h - 20.0)` gerava coordenada negativa para `h < 20mm` (Niimbot), colocando o texto fora da página.
---
### ✅ Exportação de Etiqueta PDF (`GET /filaments/:id/label.pdf`)
- **Dependência**: `printpdf = "0.7"` adicionada ao `Cargo.toml`
- **Service**: `FilamentService::generate_label_pdf` em `filament_service.rs`
- Layout: fundo `#1E1B18`, barra de cor (filament.color_hex), texto Helvetica builtin, QR Code PNG luma embutido
- Página com dimensões exatas em mm via `printpdf::Mm`
- Usa `Polygon` + `PaintMode::Fill` para retângulos (API do printpdf 0.7)
- QR Code embutido via `ImageXObject` com pixels luma brutos — sem depender do feature `image` do printpdf
- **Handler**: `export_label_pdf_handler` em `filament_handler.rs`
- **Rota**: `GET /api/v1/filaments/:id/label.pdf`
### ✅ Controle de Campos na Etiqueta (`fields` query param)
- **Struct**: `LabelFields` em `filament_service.rs` — parseia string CSV de campos ativos
- **Aplicado em**: `generate_label_svg` e `generate_label_pdf`
- **LabelQuery**: adicionado campo `fields: Option<String>` em `filament_handler.rs`
- Permite que o cliente selecione quais elementos aparecem na etiqueta (cor, nome, material, peso, temp, QR)
### ✅ Correção: PUT Spool Preset retornava FORBIDDEN
**Local:** `src/adapters/outbound/postgres_spool_preset_repo.rs` — método `update()`
**Problema:**
O SQL UPDATE não validava o `user_id` na cláusula WHERE. Qualquer usuário poderia tentar editar presets de outros usuários ou presets do sistema.
```rust
// ❌ ANTES (inseguro)
UPDATE spool_presets
SET name = $2, spool_weight_g = $3
WHERE id = $1 AND is_system = false
RETURNING ...
```
**Solução:**
Adicionado `AND user_id = $4` para validar propriedade antes de atualizar:
```rust
// ✅ DEPOIS (seguro)
UPDATE spool_presets
SET name = $2, spool_weight_g = $3
WHERE id = $1 AND is_system = false AND user_id = $4
RETURNING ...
```
Agora o repositório valida que o preset pertence ao usuário autenticado (extraído do JWT). Tentativas de editar presets de outro usuário ou do sistema recebem `404 Not Found` (sem vazar que o preset existe).