diff --git a/app/api/donations/create/route.ts b/app/api/donations/create/route.ts index 7037ad0..3f74fad 100644 --- a/app/api/donations/create/route.ts +++ b/app/api/donations/create/route.ts @@ -1,56 +1,58 @@ import { NextRequest, NextResponse } from "next/server"; import { abacatePay } from "@/lib/abacatepay"; import { DONATION_PRODUCTS, DonorData } from "@/types/donation"; +import { createDonationSchema } from "@/lib/validation-schemas"; +import { + checkRateLimit, + getClientIP, + createRateLimitResponse, +} from "@/lib/rate-limiter"; +import { secureLogger } from "@/lib/secure-logger"; export async function POST(request: NextRequest) { try { + // Rate limiting + const ip = getClientIP(request); + const rateLimit = checkRateLimit(ip, "/api/donations/create"); + + if (!rateLimit.allowed) { + return createRateLimitResponse(rateLimit.resetAt); + } + const body = await request.json(); - // Validar tier - const validTiers: Array = [ - "coffee", - "snack", - "lunch", - "custom", - ]; - if (!body.tier || !validTiers.includes(body.tier)) { + // Validação com Zod + const validationResult = createDonationSchema.safeParse(body); + + if (!validationResult.success) { + const errors = validationResult.error.issues.map((err: any) => ({ + field: err.path.join("."), + message: err.message, + })); + return NextResponse.json( - { success: false, error: "Tier de doação inválido" }, + { + success: false, + error: "Dados de entrada inválidos", + details: errors, + }, { status: 400 } ); } - const product = - DONATION_PRODUCTS[body.tier as keyof typeof DONATION_PRODUCTS]; + const validatedData = validationResult.data; - // Se for custom, validar valor enviado + const product = + DONATION_PRODUCTS[validatedData.tier as keyof typeof DONATION_PRODUCTS]; + + // Calcular valor final let finalAmount: number; let finalDescription: string; - if (body.tier === "custom") { - if (!body.customAmount || typeof body.customAmount !== "number") { - return NextResponse.json( - { success: false, error: "Valor customizado inválido" }, - { status: 400 } - ); - } - - const minAmount = DONATION_PRODUCTS.custom.minAmount; - const maxAmount = DONATION_PRODUCTS.custom.maxAmount; - - if (body.customAmount < minAmount || body.customAmount > maxAmount) { - return NextResponse.json( - { - success: false, - error: `Valor deve estar entre R$ ${minAmount} e R$ ${maxAmount}`, - }, - { status: 400 } - ); - } - + if (validatedData.tier === "custom") { // Converter para centavos - finalAmount = Math.round(body.customAmount * 100); - finalDescription = `Doação Personalizada - R$ ${body.customAmount.toFixed( + finalAmount = Math.round(validatedData.customAmount! * 100); + finalDescription = `Doação Personalizada - R$ ${validatedData.customAmount!.toFixed( 2 )}`; } else { @@ -61,7 +63,7 @@ export async function POST(request: NextRequest) { let pixData; // Fluxo: Anônimo ou Identificado - if (body.anonymous === true) { + if (validatedData.anonymous === true) { // Doação anônima - sem customer pixData = await abacatePay.createPixQrCode({ amount: finalAmount, @@ -69,89 +71,35 @@ export async function POST(request: NextRequest) { expiresIn: 600, }); - console.log("🔍 PIX criado (anônimo):", pixData); - - console.log("📝 Registro de doação anônima:", { - type: "donation_created", + secureLogger.info("PIX criado (anônimo)", { pixId: pixData.id, amount: finalAmount, - tier: body.tier, - customAmount: body.tier === "custom" ? body.customAmount : undefined, - anonymous: true, - createdAt: new Date().toISOString(), + tier: validatedData.tier, }); } else { - // Doação identificada - validar campos obrigatórios - if (!body.donorName || body.donorName.trim().length < 3) { - return NextResponse.json( - { success: false, error: "Nome é obrigatório (mínimo 3 caracteres)" }, - { status: 400 } - ); - } - - if (!body.donorPhone || body.donorPhone.trim().length === 0) { - return NextResponse.json( - { success: false, error: "Telefone é obrigatório" }, - { status: 400 } - ); - } - - if (!body.donorEmail) { - return NextResponse.json( - { - success: false, - error: "Email é obrigatório para doação identificada", - }, - { status: 400 } - ); - } - - if (!body.donorTaxId || body.donorTaxId.trim().length === 0) { - return NextResponse.json( - { success: false, error: "CPF/CNPJ é obrigatório" }, - { status: 400 } - ); - } - - if (body.donorMessage && body.donorMessage.length > 280) { - return NextResponse.json( - { - success: false, - error: "Mensagem deve ter no máximo 280 caracteres", - }, - { status: 400 } - ); - } - + // Doação identificada - dados já validados pelo Zod pixData = await abacatePay.createPixQrCode({ amount: finalAmount, description: finalDescription, expiresIn: 600, customer: { - name: body.donorName.trim(), - email: body.donorEmail.trim(), - cellphone: body.donorPhone.trim(), - taxId: body.donorTaxId.trim(), + name: validatedData.donorName, + email: validatedData.donorEmail, + cellphone: validatedData.donorPhone, + taxId: validatedData.donorTaxId, }, }); - console.log("🔍 PIX criado (identificado):", pixData); - - console.log("📝 Registro de doação identificada:", { - type: "donation_created", + secureLogger.info("PIX criado (identificado)", { pixId: pixData.id, amount: finalAmount, - tier: body.tier, - customAmount: body.tier === "custom" ? body.customAmount : undefined, - anonymous: false, + tier: validatedData.tier, donor: { - name: body.donorName, - email: body.donorEmail, - phone: body.donorPhone, - taxId: body.donorTaxId, - message: body.donorMessage, + name: validatedData.donorName, + email: validatedData.donorEmail, + phone: validatedData.donorPhone, + taxId: validatedData.donorTaxId, }, - createdAt: new Date().toISOString(), }); } @@ -163,7 +111,7 @@ export async function POST(request: NextRequest) { expiresAt: pixData.expiresAt, }); } catch (error) { - console.error("Donation creation error:", error); + secureLogger.error("Donation creation error", error); return NextResponse.json( { success: false, diff --git a/app/api/donations/status/route.ts b/app/api/donations/status/route.ts index aa18600..fdf9e8a 100644 --- a/app/api/donations/status/route.ts +++ b/app/api/donations/status/route.ts @@ -1,8 +1,20 @@ import { NextRequest, NextResponse } from "next/server"; import { getPaymentStatus } from "@/lib/payment-cache"; +import { + checkRateLimit, + getClientIP, + createRateLimitResponse, +} from "@/lib/rate-limiter"; export async function GET(request: NextRequest) { try { + // Rate limiting + const ip = getClientIP(request); + const rateLimit = checkRateLimit(ip, "/api/donations/status"); + + if (!rateLimit.allowed) { + return createRateLimitResponse(rateLimit.resetAt); + } const { searchParams } = new URL(request.url); const pixId = searchParams.get("pixId"); diff --git a/app/api/donations/verify/route.ts b/app/api/donations/verify/route.ts index 0d5b5cf..8be92e3 100644 --- a/app/api/donations/verify/route.ts +++ b/app/api/donations/verify/route.ts @@ -1,8 +1,20 @@ import { NextRequest, NextResponse } from "next/server"; import { abacatePay } from "@/lib/abacatepay"; +import { + checkRateLimit, + getClientIP, + createRateLimitResponse, +} from "@/lib/rate-limiter"; export async function GET(request: NextRequest) { try { + // Rate limiting + const ip = getClientIP(request); + const rateLimit = checkRateLimit(ip, "/api/donations/verify"); + + if (!rateLimit.allowed) { + return createRateLimitResponse(rateLimit.resetAt); + } const { searchParams } = new URL(request.url); const billingId = searchParams.get("billingId"); diff --git a/app/api/webhooks/abacatepay/route.ts b/app/api/webhooks/abacatepay/route.ts index 730b7fa..b718266 100644 --- a/app/api/webhooks/abacatepay/route.ts +++ b/app/api/webhooks/abacatepay/route.ts @@ -1,9 +1,32 @@ import { NextRequest, NextResponse } from "next/server"; import { abacatePay } from "@/lib/abacatepay"; import { markAsPaid } from "@/lib/payment-cache"; +import { + checkRateLimit, + getClientIP, + createRateLimitResponse, +} from "@/lib/rate-limiter"; +import { secureLogger } from "@/lib/secure-logger"; + +// Whitelist de IPs do AbacatePay (configurável via env) +const ABACATEPAY_IPS = process.env.ABACATEPAY_WHITELIST_IPS?.split(",") || []; export async function POST(request: NextRequest) { try { + // Rate limiting + const ip = getClientIP(request); + const rateLimit = checkRateLimit(ip, "/api/webhooks/abacatepay"); + + if (!rateLimit.allowed) { + return createRateLimitResponse(rateLimit.resetAt); + } + + // Validar IP de origem (se configurado) + if (ABACATEPAY_IPS.length > 0 && !ABACATEPAY_IPS.includes(ip)) { + secureLogger.warn("Webhook rejeitado: IP não autorizado", { ip }); + return NextResponse.json({ error: "IP não autorizado" }, { status: 403 }); + } + const payload = await request.text(); // AbacatePay envia webhookSecret como query param @@ -13,15 +36,34 @@ export async function POST(request: NextRequest) { // Validar secret if (!webhookSecret || !expectedSecret || webhookSecret !== expectedSecret) { - console.error("🔒 Webhook rejeitado: secret inválido ou ausente"); + secureLogger.warn("Webhook rejeitado: secret inválido ou ausente"); return NextResponse.json({ error: "Secret inválido" }, { status: 401 }); } - console.log("✅ Webhook autenticado com sucesso"); + secureLogger.info("Webhook autenticado com sucesso"); const event = JSON.parse(payload); - console.log("🔔 Webhook recebido:", JSON.stringify(event, null, 2)); + secureLogger.debug("Webhook recebido", event); + + // Validar timestamp do evento (prevenir replay attacks) + const eventTimestamp = event.timestamp || event.data?.pixQrCode?.createdAt; + if (eventTimestamp) { + const eventTime = new Date(eventTimestamp).getTime(); + const now = Date.now(); + const timeDiff = Math.abs(now - eventTime); + + // Rejeitar eventos com mais de 5 minutos de diferença + if (timeDiff > 5 * 60 * 1000) { + secureLogger.warn("Webhook rejeitado: timestamp inválido", { + timeDiff: timeDiff / 1000 + "s", + }); + return NextResponse.json( + { error: "Timestamp inválido" }, + { status: 400 } + ); + } + } // Verificar se é evento de pagamento confirmado const isPaidEvent = @@ -35,11 +77,14 @@ export async function POST(request: NextRequest) { const payment = event.data.payment; if (!pixQrCode || pixQrCode.status !== "PAID") { - console.log("⚠️ Webhook recebido mas status não é PAID"); + secureLogger.info("Webhook recebido mas status não é PAID"); return NextResponse.json({ success: true }); } - console.log("✅ Doação confirmada via webhook:", pixQrCode.id); + secureLogger.info("Doação confirmada via webhook", { + pixId: pixQrCode.id, + amount: pixQrCode.amount, + }); // Detectar se é doação anônima ou identificada const isAnonymous = !pixQrCode.customer; @@ -67,13 +112,15 @@ export async function POST(request: NextRequest) { taxId: pixQrCode.customer.metadata.taxId, }; - console.log( - "📧 Email disponível para agradecimento:", - pixQrCode.customer.metadata.email - ); + secureLogger.info("Email disponível para agradecimento", { + maskedEmail: pixQrCode.customer.metadata.email, + }); } - console.log("📝 Registro de pagamento:", donationRecord); + secureLogger.info( + "Registro de pagamento criado", + secureLogger.mask(donationRecord) + ); // Salvar no cache para frontend detectar via polling markAsPaid(pixQrCode.id, donationRecord); @@ -93,12 +140,14 @@ export async function POST(request: NextRequest) { // TODO: Atualizar estatísticas // await updateDonationStats(donationRecord.amount); } else { - console.log("ℹ️ Evento de webhook (não é pagamento):", event.event); + secureLogger.info("Evento de webhook (não é pagamento)", { + event: event.event, + }); } return NextResponse.json({ success: true }); } catch (error) { - console.error("Webhook error:", error); + secureLogger.error("Webhook error", error); return NextResponse.json( { error: "Erro ao processar webhook" }, { status: 500 } diff --git a/lib/abacatepay.ts b/lib/abacatepay.ts index 4e6d7ec..73ee53f 100644 --- a/lib/abacatepay.ts +++ b/lib/abacatepay.ts @@ -57,7 +57,8 @@ class AbacatePayClient { requestBody.customer = params.customer; } - console.log("🔍 PIX Request:", JSON.stringify(requestBody, null, 2)); + // Request body logging removido para segurança LGPD + // Dados sensíveis não devem aparecer em logs const response = await fetch(`${this.config.baseUrl}/pixQrCode/create`, { method: "POST", @@ -68,16 +69,13 @@ class AbacatePayClient { body: JSON.stringify(requestBody), }); - console.log("🔍 PIX Response Status:", response.status); - if (!response.ok) { const errorData = await response.json().catch(() => ({})); - console.error("🔍 PIX Error:", errorData); + console.error("PIX Error:", errorData); throw new Error(`Failed to create PIX: ${JSON.stringify(errorData)}`); } const result = await response.json(); - console.log("🔍 PIX Success:", result); if (result.error) { throw new Error(result.error); @@ -177,20 +175,10 @@ const apiKey = isProduction ? process.env.ABACATE_APIKEY || "demo_key" : process.env.ABACATE_TEST_APIKEY || "demo_key"; -// Log completo das variáveis de ambiente configuradas -console.log("\n🔧 =========================================="); -console.log("🔧 VARIÁVEIS DE AMBIENTE DO ABACATEPAY"); -console.log("🔧 =========================================="); -console.log(`📊 NODE_ENV: ${process.env.NODE_ENV || "não definido"}`); +// Log das variáveis de ambiente (sem expor valores sensíveis) +console.log("\n🔧 Configuração do AbacatePay:"); console.log(`📊 Environment: ${isProduction ? "PRODUCTION" : "TEST"}`); -console.log( - `📊 API Key: ${apiKey ? "✅ Configurada (masqueada)" : "❌ NÃO CONFIGURADA"}` -); -console.log( - `📊 API Key preview: ${ - apiKey ? apiKey.substring(0, 10) + "..." : "não definida" - }` -); +console.log(`📊 API Key: ${apiKey ? "✅ Configurada" : "❌ NÃO CONFIGURADA"}`); console.log( `📊 Webhook Secret: ${ process.env.ABACATEPAY_WEBHOOK_SECRET @@ -198,9 +186,6 @@ console.log( : "❌ NÃO CONFIGURADA" }` ); -console.log( - `📊 Base URL: ${process.env.NEXT_PUBLIC_BASE_URL || "não definido"}` -); console.log("🔧 ==========================================\n"); export const abacatePay = new AbacatePayClient({ diff --git a/lib/rate-limiter.ts b/lib/rate-limiter.ts new file mode 100644 index 0000000..edf1163 --- /dev/null +++ b/lib/rate-limiter.ts @@ -0,0 +1,169 @@ +/** + * Rate Limiter para ambiente serverless + * Utiliza Map em memória com TTL (Time To Live) + * Adequado para serverless de curta duração + */ + +interface RateLimitConfig { + maxRequests: number; + windowMs: number; +} + +interface RateLimitEntry { + count: number; + resetAt: number; +} + +// Cache em memória de requisições por IP +const requestCache = new Map(); + +// Configuração de rate limits por endpoint +const RATE_LIMITS: Record = { + "/api/donations/create": { + maxRequests: 5, + windowMs: 10 * 60 * 1000, // 10 minutos + }, + "/api/donations/status": { + maxRequests: 30, + windowMs: 60 * 1000, // 1 minuto + }, + "/api/donations/verify": { + maxRequests: 20, + windowMs: 60 * 1000, // 1 minuto + }, + "/api/webhooks/abacatepay": { + maxRequests: 100, + windowMs: 60 * 1000, // 1 minuto + }, +}; + +// Limpar cache expirado a cada 5 minutos +setInterval(() => { + const now = Date.now(); + let cleaned = 0; + + for (const [key, entry] of requestCache.entries()) { + if (now > entry.resetAt) { + requestCache.delete(key); + cleaned++; + } + } + + if (cleaned > 0) { + console.log(`🧹 Rate limiter: ${cleaned} entradas expiradas removidas`); + } +}, 5 * 60 * 1000); + +/** + * Verifica se uma requisição excede o rate limit + * @param ip - Endereço IP do cliente + * @param path - Caminho da requisição (ex: /api/donations/create) + * @returns Objeto com informações sobre o rate limit + */ +export function checkRateLimit( + ip: string, + path: string +): { + allowed: boolean; + remaining: number; + resetAt: number; + limit: number; +} { + const config = RATE_LIMITS[path]; + + // Se não há config para este path, permite tudo + if (!config) { + return { + allowed: true, + remaining: Infinity, + resetAt: Date.now() + 60000, + limit: Infinity, + }; + } + + const key = `${ip}:${path}`; + const now = Date.now(); + const entry = requestCache.get(key); + + // Se não existe entrada ou expirou, criar nova + if (!entry || now > entry.resetAt) { + const newEntry: RateLimitEntry = { + count: 1, + resetAt: now + config.windowMs, + }; + requestCache.set(key, newEntry); + + return { + allowed: true, + remaining: config.maxRequests - 1, + resetAt: newEntry.resetAt, + limit: config.maxRequests, + }; + } + + // Se existe e não expirou, incrementar contador + entry.count++; + + if (entry.count > config.maxRequests) { + return { + allowed: false, + remaining: 0, + resetAt: entry.resetAt, + limit: config.maxRequests, + }; + } + + return { + allowed: true, + remaining: config.maxRequests - entry.count, + resetAt: entry.resetAt, + limit: config.maxRequests, + }; +} + +/** + * Extrai o IP real de uma requisição + * Considera headers de proxies/CDN (x-forwarded-for, x-real-ip) + */ +export function getClientIP(request: Request): string { + // Tentar x-forwarded-for primeiro (pode ter múltiplos IPs) + const forwardedFor = request.headers.get("x-forwarded-for"); + if (forwardedFor) { + // Pegar apenas o primeiro IP (cliente original) + return forwardedFor.split(",")[0].trim(); + } + + // Tentar x-real-ip + const realIP = request.headers.get("x-real-ip"); + if (realIP) { + return realIP.trim(); + } + + // Fallback: usar conexão remota + return request.headers.get("cf-connecting-ip") || "unknown"; +} + +/** + * Middleware helper para criar resposta de rate limit + */ +export function createRateLimitResponse(resetAt: number): Response { + const retryAfter = Math.ceil((resetAt - Date.now()) / 1000); + + return new Response( + JSON.stringify({ + error: "Rate limit excedido", + message: "Muitas requisições. Tente novamente mais tarde.", + retryAfter, + }), + { + status: 429, + headers: { + "Content-Type": "application/json", + "Retry-After": retryAfter.toString(), + "X-RateLimit-Limit": "1", + "X-RateLimit-Remaining": "0", + "X-RateLimit-Reset": new Date(resetAt).toISOString(), + }, + } + ); +} diff --git a/lib/secure-logger.ts b/lib/secure-logger.ts new file mode 100644 index 0000000..0ed0757 --- /dev/null +++ b/lib/secure-logger.ts @@ -0,0 +1,208 @@ +/** + * Logger Seguro - Conformidade LGPD + * Mascara automaticamente dados pessoais sensíveis em logs + * Previne vazamento de informações em ambientes de produção + */ + +interface MaskOptions { + /** + * Mascara parcial de email (mantém parte visível) + * Ex: user@domain.com -> u***@domain.com + */ + maskEmail?: boolean; + + /** + * Mascara parcial de CPF/CNPJ + * Ex: 123.456.789-00 -> ***.***.***-00 + */ + maskDocument?: boolean; + + /** + * Mascara telefone completo + * Ex: (11) 98765-4321 -> (11) *****-4321 + */ + maskPhone?: boolean; + + /** + * Remove completamente dados sensíveis em produção + */ + removeSensitive?: boolean; +} + +const isProduction = process.env.NODE_ENV === "production"; + +/** + * Mascara email mantendo primeiro caractere e domínio + */ +function maskEmail(email: string): string { + if (!email || !email.includes("@")) return email; + + const [localPart, domain] = email.split("@"); + const maskedLocal = localPart.substring(0, 1) + "***"; + + return `${maskedLocal}@${domain}`; +} + +/** + * Mascara CPF mantendo apenas os dois últimos dígitos + */ +function maskCPF(cpf: string): string { + if (!cpf) return cpf; + + // Remove formatação + const cleaned = cpf.replace(/[^\d]/g, ""); + + if (cleaned.length === 11) { + return `***.***.***-${cleaned.slice(-2)}`; + } + + if (cleaned.length === 14) { + // CNPJ + return `**.***.***/****-${cleaned.slice(-2)}`; + } + + return "***.***.***-**"; +} + +/** + * Mascara telefone mantendo DDD e últimos 4 dígitos + */ +function maskPhone(phone: string): string { + if (!phone) return phone; + + // Remove formatação + const cleaned = phone.replace(/[^\d]/g, ""); + + if (cleaned.length >= 10) { + const ddd = cleaned.substring(0, 2); + const lastFour = cleaned.slice(-4); + return `(${ddd}) *****-${lastFour}`; + } + + return "**-****-****"; +} + +/** + * Mascara objeto recursivamente + */ +function maskSensitiveData(obj: any, options: MaskOptions = {}): any { + if (!obj || typeof obj !== "object") { + return obj; + } + + // Se for string, verifica se é dado sensível + if (typeof obj === "string") { + // Email + if (obj.includes("@") && options.maskEmail) { + return maskEmail(obj); + } + + // CPF/CNPJ + const digitsOnly = obj.replace(/[^\d]/g, ""); + if ( + (digitsOnly.length === 11 || digitsOnly.length === 14) && + options.maskDocument + ) { + return maskCPF(obj); + } + + // Telefone + if (digitsOnly.length >= 10 && options.maskPhone) { + return maskPhone(obj); + } + + return obj; + } + + // Se for array + if (Array.isArray(obj)) { + return obj.map((item) => maskSensitiveData(item, options)); + } + + // Se for objeto + const masked: any = {}; + for (const [key, value] of Object.entries(obj)) { + const lowerKey = key.toLowerCase(); + + // Identificar campos sensíveis + const isEmail = lowerKey.includes("email") || lowerKey === "mail"; + const isDocument = + lowerKey.includes("cpf") || + lowerKey.includes("cnpj") || + lowerKey.includes("taxid"); + const isPhone = + lowerKey.includes("phone") || + lowerKey.includes("cellphone") || + lowerKey.includes("telefone"); + + if (isEmail && options.maskEmail) { + masked[key] = maskEmail(String(value)); + } else if (isDocument && options.maskDocument) { + masked[key] = maskCPF(String(value)); + } else if (isPhone && options.maskPhone) { + masked[key] = maskPhone(String(value)); + } else { + masked[key] = maskSensitiveData(value, options); + } + } + + return masked; +} + +/** + * Logger seguro que mascara dados sensíveis + */ +export const secureLogger = { + /** + * Log info com mascaramento automático + */ + info(message: string, data?: any) { + if (isProduction && data) { + const masked = maskSensitiveData(data, { + maskEmail: true, + maskDocument: true, + maskPhone: true, + }); + console.log(`ℹ️ ${message}`, JSON.stringify(masked, null, 2)); + } else { + console.log(`ℹ️ ${message}`, data ? JSON.stringify(data, null, 2) : ""); + } + }, + + /** + * Log debug com mascaramento automático + */ + debug(message: string, data?: any) { + if (isProduction && data) { + const masked = maskSensitiveData(data, { + maskEmail: true, + maskDocument: true, + maskPhone: true, + }); + console.debug(`🔍 ${message}`, JSON.stringify(masked, null, 2)); + } else { + console.debug(`🔍 ${message}`, data ? JSON.stringify(data, null, 2) : ""); + } + }, + + /** + * Log warning + */ + warn(message: string, data?: any) { + console.warn(`⚠️ ${message}`, data || ""); + }, + + /** + * Log error + */ + error(message: string, error?: any) { + console.error(`❌ ${message}`, error || ""); + }, + + /** + * Mascara dados manualmente + */ + mask(data: any, options?: MaskOptions) { + return maskSensitiveData(data, options); + }, +}; diff --git a/lib/validation-schemas.ts b/lib/validation-schemas.ts new file mode 100644 index 0000000..a9eac6d --- /dev/null +++ b/lib/validation-schemas.ts @@ -0,0 +1,145 @@ +import { z } from "zod"; +import validator from "validator"; +import { isValidDocument } from "./validators/document-validator"; +import sanitizeHtml from "sanitize-html"; + +/** + * Schemas de validação usando Zod + * Garante type-safety e validação robusta de dados de entrada + */ + +// Schema para email - validação RFC 5322 +export const emailSchema = z + .string() + .min(1, "Email é obrigatório") + .email("Formato de email inválido") + .refine((email) => validator.isEmail(email), { + message: "Email inválido", + }) + .transform((email) => email.trim().toLowerCase()); + +// Schema para telefone brasileiro +export const phoneSchema = z + .string() + .min(1, "Telefone é obrigatório") + .refine((phone) => { + // Remove todos os caracteres não numéricos + const cleaned = phone.replace(/[^\d]/g, ""); + // Deve ter 10 ou 11 dígitos (telefone fixo ou celular com DDD) + return cleaned.length >= 10 && cleaned.length <= 11; + }, "Telefone deve ter 10 ou 11 dígitos (incluindo DDD)") + .transform((phone) => phone.replace(/[^\d]/g, "")); + +// Schema para CPF/CNPJ +export const documentSchema = z + .string() + .min(1, "CPF/CNPJ é obrigatório") + .refine((doc) => isValidDocument(doc), { + message: "CPF ou CNPJ inválido", + }) + .transform((doc) => doc.replace(/[^\d]/g, "")); // Normaliza removendo formatação + +// Schema para nome (alfabético, sem números) +export const nameSchema = z + .string() + .min(3, "Nome deve ter no mínimo 3 caracteres") + .max(100, "Nome deve ter no máximo 100 caracteres") + .refine( + (name) => /^[A-Za-zÀ-ÿ\s'-]+$/.test(name.trim()), + "Nome deve conter apenas letras, espaços e acentos" + ) + .transform((name) => name.trim()); + +// Schema para mensagem (sanitiza HTML e previne XSS) +export const messageSchema = z + .string() + .max(280, "Mensagem deve ter no máximo 280 caracteres") + .transform((message) => { + // Sanitiza HTML para prevenir XSS + const sanitized = sanitizeHtml(message, { + allowedTags: [], // Remove todas as tags HTML + allowedAttributes: {}, + }); + return sanitized.trim(); + }) + .optional(); + +// Schema para tier de doação +export const donationTierSchema = z.enum( + ["coffee", "snack", "lunch", "custom"], + { + message: "Tier de doação inválido", + } +); + +// Schema para valor customizado +export const customAmountSchema = z + .number() + .min(5, "Valor mínimo é R$ 5,00") + .max(500, "Valor máximo é R$ 500,00"); + +// Schema completo para criação de doação +export const createDonationSchema = z + .object({ + tier: donationTierSchema, + anonymous: z.boolean().optional().default(false), + customAmount: z.number().optional(), + }) + .and( + z.union([ + // Doação anônima - não precisa de dados pessoais + z.object({ + anonymous: z.literal(true), + }), + // Doação identificada - precisa de todos os dados + z.object({ + anonymous: z.literal(false), + donorName: nameSchema, + donorEmail: emailSchema, + donorPhone: phoneSchema, + donorTaxId: documentSchema, + donorMessage: messageSchema, + }), + ]) + ) + .refine( + (data) => { + // Se tier for custom, customAmount é obrigatório + if (data.tier === "custom") { + return data.customAmount !== undefined; + } + return true; + }, + { + message: "customAmount é obrigatório quando tier é 'custom'", + path: ["customAmount"], + } + ) + .refine( + (data) => { + // Se tier for custom, customAmount deve estar no range válido + if (data.tier === "custom" && data.customAmount) { + return data.customAmount >= 5 && data.customAmount <= 500; + } + return true; + }, + { + message: "Valor deve estar entre R$ 5,00 e R$ 500,00", + path: ["customAmount"], + } + ); + +// Schema para verificação de status +export const verifyStatusSchema = z.object({ + pixId: z.string().min(1, "pixId é obrigatório"), +}); + +// Schema para verificação de billing +export const verifyBillingSchema = z.object({ + billingId: z.string().min(1, "ID da cobrança é obrigatório"), +}); + +// Type exports para uso no código +export type CreateDonationInput = z.infer; +export type VerifyStatusInput = z.infer; +export type VerifyBillingInput = z.infer; diff --git a/lib/validators/document-validator.ts b/lib/validators/document-validator.ts new file mode 100644 index 0000000..2486e52 --- /dev/null +++ b/lib/validators/document-validator.ts @@ -0,0 +1,85 @@ +/** + * Validador de documentos brasileiros (CPF e CNPJ) + * Implementa algoritmo completo de validação de dígitos verificadores + */ + +export function isValidCPF(cpf: string): boolean { + // Remove caracteres não numéricos + const cleaned = cpf.replace(/[^\d]/g, ""); + + // Verifica se tem 11 dígitos + if (cleaned.length !== 11) return false; + + // Verifica se todos os dígitos são iguais (ex: 111.111.111-11) + if (/^(\d)\1+$/.test(cleaned)) return false; + + // Valida primeiro dígito verificador + let sum = 0; + for (let i = 0; i < 9; i++) { + sum += parseInt(cleaned.charAt(i)) * (10 - i); + } + let remainder = (sum * 10) % 11; + if (remainder === 10 || remainder === 11) remainder = 0; + if (remainder !== parseInt(cleaned.charAt(9))) return false; + + // Valida segundo dígito verificador + sum = 0; + for (let i = 0; i < 10; i++) { + sum += parseInt(cleaned.charAt(i)) * (11 - i); + } + remainder = (sum * 10) % 11; + if (remainder === 10 || remainder === 11) remainder = 0; + if (remainder !== parseInt(cleaned.charAt(10))) return false; + + return true; +} + +export function isValidCNPJ(cnpj: string): boolean { + // Remove caracteres não numéricos + const cleaned = cnpj.replace(/[^\d]/g, ""); + + // Verifica se tem 14 dígitos + if (cleaned.length !== 14) return false; + + // Verifica se todos os dígitos são iguais + if (/^(\d)\1+$/.test(cleaned)) return false; + + // Valida primeiro dígito verificador + const weights1 = [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]; + let sum = 0; + for (let i = 0; i < 12; i++) { + sum += parseInt(cleaned.charAt(i)) * weights1[i]; + } + let remainder = sum % 11; + const firstDigit = remainder < 2 ? 0 : 11 - remainder; + if (firstDigit !== parseInt(cleaned.charAt(12))) return false; + + // Valida segundo dígito verificador + const weights2 = [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]; + sum = 0; + for (let i = 0; i < 13; i++) { + sum += parseInt(cleaned.charAt(i)) * weights2[i]; + } + remainder = sum % 11; + const secondDigit = remainder < 2 ? 0 : 11 - remainder; + if (secondDigit !== parseInt(cleaned.charAt(13))) return false; + + return true; +} + +export function isValidDocument(document: string): boolean { + const cleaned = document.replace(/[^\d]/g, ""); + + if (cleaned.length === 11) { + return isValidCPF(document); + } else if (cleaned.length === 14) { + return isValidCNPJ(document); + } + + return false; +} + +export function normalizeDocument(document: string): string { + // Remove todos os caracteres não numéricos + return document.replace(/[^\d]/g, ""); +} diff --git a/package.json b/package.json index 0fa2d08..e60c71b 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,10 @@ "react-dom": "19.1.0", "react-qr-code": "^2.0.18", "recharts": "^3.3.0", - "tailwind-merge": "^3.3.1" + "sanitize-html": "^2.17.0", + "tailwind-merge": "^3.3.1", + "validator": "^13.15.20", + "zod": "^4.1.12" }, "devDependencies": { "@biomejs/biome": "2.2.0", @@ -41,6 +44,8 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@types/sanitize-html": "^2.16.0", + "@types/validator": "^13.15.3", "tailwindcss": "^4", "tw-animate-css": "^1.3.7", "typescript": "^5" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1236753..2b5c0e1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -77,9 +77,18 @@ importers: recharts: specifier: ^3.3.0 version: 3.3.0(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react-is@19.2.0)(react@19.1.0)(redux@5.0.1) + sanitize-html: + specifier: ^2.17.0 + version: 2.17.0 tailwind-merge: specifier: ^3.3.1 version: 3.3.1 + validator: + specifier: ^13.15.20 + version: 13.15.20 + zod: + specifier: ^4.1.12 + version: 4.1.12 devDependencies: '@biomejs/biome': specifier: 2.2.0 @@ -96,6 +105,12 @@ importers: '@types/react-dom': specifier: ^19 version: 19.2.2(@types/react@19.2.2) + '@types/sanitize-html': + specifier: ^2.16.0 + version: 2.16.0 + '@types/validator': + specifier: ^13.15.3 + version: 13.15.3 tailwindcss: specifier: ^4 version: 4.1.15 @@ -939,9 +954,15 @@ packages: '@types/react@19.2.2': resolution: {integrity: sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==} + '@types/sanitize-html@2.16.0': + resolution: {integrity: sha512-l6rX1MUXje5ztPT0cAFtUayXF06DqPhRyfVXareEN5gGCFaP/iwsxIyKODr9XDhfxPpN6vXUFNfo5kZMXCxBtw==} + '@types/use-sync-external-store@0.0.6': resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + '@types/validator@13.15.3': + resolution: {integrity: sha512-7bcUmDyS6PN3EuD9SlGGOxM77F8WLVsrwkxyWxKnxzmXoequ6c7741QBrANq6htVRGOITJ7z72mTP6Z4XyuG+Q==} + aria-hidden@1.2.6: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} @@ -1018,6 +1039,10 @@ packages: decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -1025,13 +1050,34 @@ packages: detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + enhanced-resolve@5.18.3: resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} engines: {node: '>=10.13.0'} + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + es-toolkit@1.40.0: resolution: {integrity: sha512-8o6w0KFmU0CiIl0/Q/BCEOabF2IJaELM1T2PWj6e8KqzHv1gdx+7JtFnDwOx1kJH/isJ5NwlDG1nCr1HrRF94Q==} + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} @@ -1056,6 +1102,9 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + htmlparser2@8.0.2: + resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} + immer@10.1.3: resolution: {integrity: sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw==} @@ -1066,6 +1115,10 @@ packages: intl-messageformat@10.7.18: resolution: {integrity: sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==} + is-plain-object@5.0.0: + resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} + engines: {node: '>=0.10.0'} + jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true @@ -1225,6 +1278,9 @@ packages: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + parse-srcset@1.0.2: + resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==} + phenomenon@1.6.0: resolution: {integrity: sha512-7h9/fjPD3qNlgggzm88cY58l9sudZ6Ey+UmZsizfhtawO6E3srZQXywaNm2lBwT72TbpHYRPy7ytIHeBUD/G0A==} @@ -1326,6 +1382,9 @@ packages: reselect@5.1.1: resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + sanitize-html@2.17.0: + resolution: {integrity: sha512-dLAADUSS8rBwhaevT12yCezvioCA+bmUTPH/u57xKPT8d++voeYE6HeluA/bPbQ15TwDBG2ii+QZIEmYx8VdxA==} + scheduler@0.26.0: resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==} @@ -1412,9 +1471,16 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + validator@13.15.20: + resolution: {integrity: sha512-KxPOq3V2LmfQPP4eqf3Mq/zrT0Dqp2Vmx2Bn285LwVahLc+CsxOM0crBHczm8ijlcjZ0Q5Xd6LW3z3odTPnlrw==} + engines: {node: '>= 0.10'} + victory-vendor@37.3.6: resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} + zod@4.1.12: + resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==} + snapshots: '@alloc/quick-lru@5.2.0': {} @@ -2150,8 +2216,14 @@ snapshots: dependencies: csstype: 3.1.3 + '@types/sanitize-html@2.16.0': + dependencies: + htmlparser2: 8.0.2 + '@types/use-sync-external-store@0.0.6': {} + '@types/validator@13.15.3': {} + aria-hidden@1.2.6: dependencies: tslib: 2.8.1 @@ -2216,17 +2288,41 @@ snapshots: decimal.js@10.6.0: {} + deepmerge@4.3.1: {} + detect-libc@2.1.2: {} detect-node-es@1.1.0: {} + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + enhanced-resolve@5.18.3: dependencies: graceful-fs: 4.2.11 tapable: 2.3.0 + entities@4.5.0: {} + es-toolkit@1.40.0: {} + escape-string-regexp@4.0.0: {} + eventemitter3@5.0.1: {} framer-motion@12.23.24(react-dom@19.1.0(react@19.1.0))(react@19.1.0): @@ -2242,6 +2338,13 @@ snapshots: graceful-fs@4.2.11: {} + htmlparser2@8.0.2: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 4.5.0 + immer@10.1.3: {} internmap@2.0.3: {} @@ -2253,6 +2356,8 @@ snapshots: '@formatjs/icu-messageformat-parser': 2.11.4 tslib: 2.8.1 + is-plain-object@5.0.0: {} + jiti@2.6.1: {} js-tokens@4.0.0: {} @@ -2376,6 +2481,8 @@ snapshots: object-assign@4.1.1: {} + parse-srcset@1.0.2: {} + phenomenon@1.6.0: {} picocolors@1.1.1: {} @@ -2481,6 +2588,15 @@ snapshots: reselect@5.1.1: {} + sanitize-html@2.17.0: + dependencies: + deepmerge: 4.3.1 + escape-string-regexp: 4.0.0 + htmlparser2: 8.0.2 + is-plain-object: 5.0.0 + parse-srcset: 1.0.2 + postcss: 8.5.6 + scheduler@0.26.0: {} semver@7.7.3: @@ -2565,6 +2681,8 @@ snapshots: dependencies: react: 19.1.0 + validator@13.15.20: {} + victory-vendor@37.3.6: dependencies: '@types/d3-array': 3.2.2 @@ -2581,3 +2699,5 @@ snapshots: d3-shape: 3.2.0 d3-time: 3.1.0 d3-timer: 3.0.1 + + zod@4.1.12: {}