- 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.
775 lines
33 KiB
Markdown
775 lines
33 KiB
Markdown
# 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 | 7–9pt 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).
|