# 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, Extension(current_user): Extension, Json(req): Json, ) -> Result { 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, preset_repo: Arc, } impl FilamentService { pub async fn create(&self, user_id: Uuid, req: CreateFilamentRequest) -> Result { // 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, AppError>; async fn list(&self, user_id: Uuid, filter: FilamentFilter) -> Result, AppError>; async fn create(&self, filament: &Filament) -> Result; async fn update(&self, filament: &Filament) -> Result; 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 # 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`. - 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::(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` 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` 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).