- 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.
33 KiB
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 marcasearch— busca em marca, modelo e notasstock_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:
{
"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:
#[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:
{ "error": "not found", "code": "NOT_FOUND" }
Convenções de Código
Nomenclatura
- Structs de domínio:
PascalCase—User,Filament,SpoolPreset - Traits (ports):
PascalCasecom sufixoRepository—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.
// 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):
// 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)
// 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:
# 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)
- Domain — adicione ou modifique a entidade em
src/domain/. - Port — adicione o método necessário no trait em
src/ports/. - Outbound Adapter — implemente o método no repositório PostgreSQL em
src/adapters/outbound/. - Application Service — adicione o caso de uso em
src/application/, chamando o port. - Request/Response DTOs — defina structs com
serdeevalidatorno handler. - Inbound Handler — crie o handler em
src/adapters/inbound/, delegando para o service. - Router — registre a rota em
src/router.rs. - Migration — se necessário, crie um arquivo SQL em
migrations/.
Como Rodar Localmente
# 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_atlocal no body do PUT. - Se o
updated_atdo servidor for mais recente, retorna409 Conflictcom a versão do servidor. - Se o
updated_atdo 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 < 30ouheight_mm < 20 dpiafetapx_per_mme portanto as dimensões em pixels do SVG geradoContent-Type: image/svg+xmlContent-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 < 30ouheight_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 cominterpolate: false(bordas nítidas em impressoras térmicas) Content-Type: application/pdfContent-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.rsque valida o token e populaExtension<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 retorna403 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:
Configganhou campogoogle_client_id_ios: Stringlido deGOOGLE_CLIENT_ID_APPLEverify_google_id_tokenaceita agora dois client IDs e validaaudcontra ambos:
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_handlerpassastate.config.google_client_idestate.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):
// 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_idparaclient_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:
// 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_emailinternamente (gera novo token com TTL 24h)
Novo método (email_token_service.rs):
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::EmailNotVerifiedadicionado → HTTP 403, code"EMAIL_NOT_VERIFIED"AuthService::registerpassa a retornar apenasUser(semTokenPair) — o handler responde201sem body de authAuthService::loginverificauser.email_verifiedantes de emitir tokens:if !user.email_verified { return Err(AppError::EmailNotVerified); }
Fluxo resultante:
- Registro →
201 Created(sem tokens) → backend envia e-mail de verificação em background - Login com e-mail não verificado →
403 { "code": "EMAIL_NOT_VERIFIED" } - 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=xxxreset_password_redirect_handler—GET /api/v1/reset-password?token=xxx
Novo campo Config (config.rs):
app_scheme: String— lido deAPP_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ápidoused_at TIMESTAMPTZ—NULL= não usado; preenchido na validação para invalidar após usoexpires_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 senhasend_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 empassword_reset_tokens, envia e-mail. Sempre retornaOk(não vaza se o e-mail existe)verify_email(token)— valida token ememail_verification_tokens, chamauser_repo.verify_email(), marca token como usadoreset_password(token, new_password)— valida token empassword_reset_tokens, re-hash da senha com Argon2id, atualiza usuário, marca token como usadosend_verification_email(user_id, email)— cria token ememail_verification_tokense 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-passwordverify_email_handler— POST/auth/verify-emailreset_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
- Gera links nos e-mails como
✅ 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 aoCargo.toml - Service:
FilamentService::generate_label_pdfemfilament_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::Fillpara retângulos (API do printpdf 0.7) - QR Code embutido via
ImageXObjectcom pixels luma brutos — sem depender do featureimagedo printpdf
- Layout: fundo
- Handler:
export_label_pdf_handleremfilament_handler.rs - Rota:
GET /api/v1/filaments/:id/label.pdf
✅ Controle de Campos na Etiqueta (fields query param)
- Struct:
LabelFieldsemfilament_service.rs— parseia string CSV de campos ativos - Aplicado em:
generate_label_svgegenerate_label_pdf - LabelQuery: adicionado campo
fields: Option<String>emfilament_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.
// ❌ 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:
// ✅ 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).