refactor(api): enhance donation creation with rate limiting, Zod validation, and secure logging
- Add rate limiting to prevent abuse on /api/donations/create endpoint - Replace manual input validation with Zod schema for better error handling and type safety - Switch from console.log to secureLogger to comply with LGPD and avoid exposing sensitive data - Remove sensitive request body logging in AbacatePay client for improved security
This commit is contained in:
1 parent
bb6b320d1f
commit
a1800227a7
11 files changed
+876
-138
No files matched your search
@@ -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<keyof typeof DONATION_PRODUCTS> = [
|
||||
"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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
+6
-21
@@ -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({
|
||||
|
||||
@@ -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<string, RateLimitEntry>();
|
||||
|
||||
// Configuração de rate limits por endpoint
|
||||
const RATE_LIMITS: Record<string, RateLimitConfig> = {
|
||||
"/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(),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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: [email protected] -> 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);
|
||||
},
|
||||
};
|
||||
@@ -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<typeof createDonationSchema>;
|
||||
export type VerifyStatusInput = z.infer<typeof verifyStatusSchema>;
|
||||
export type VerifyBillingInput = z.infer<typeof verifyBillingSchema>;
|
||||
@@ -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, "");
|
||||
}
|
||||
+6
-1
@@ -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"
|
||||
|
||||
Generated
+120
@@ -77,9 +77,18 @@ importers:
|
||||
recharts:
|
||||
specifier: ^3.3.0
|
||||
version: 3.3.0(@types/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected])
|
||||
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/[email protected])
|
||||
'@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/[email protected]':
|
||||
resolution: {integrity: sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==}
|
||||
|
||||
'@types/[email protected]':
|
||||
resolution: {integrity: sha512-l6rX1MUXje5ztPT0cAFtUayXF06DqPhRyfVXareEN5gGCFaP/iwsxIyKODr9XDhfxPpN6vXUFNfo5kZMXCxBtw==}
|
||||
|
||||
'@types/[email protected]':
|
||||
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
|
||||
|
||||
'@types/[email protected]':
|
||||
resolution: {integrity: sha512-7bcUmDyS6PN3EuD9SlGGOxM77F8WLVsrwkxyWxKnxzmXoequ6c7741QBrANq6htVRGOITJ7z72mTP6Z4XyuG+Q==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -1018,6 +1039,10 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -1025,13 +1050,34 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-8o6w0KFmU0CiIl0/Q/BCEOabF2IJaELM1T2PWj6e8KqzHv1gdx+7JtFnDwOx1kJH/isJ5NwlDG1nCr1HrRF94Q==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
|
||||
|
||||
@@ -1056,6 +1102,9 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw==}
|
||||
|
||||
@@ -1066,6 +1115,10 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
[email protected]:
|
||||
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'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-7h9/fjPD3qNlgggzm88cY58l9sudZ6Ey+UmZsizfhtawO6E3srZQXywaNm2lBwT72TbpHYRPy7ytIHeBUD/G0A==}
|
||||
|
||||
@@ -1326,6 +1382,9 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-dLAADUSS8rBwhaevT12yCezvioCA+bmUTPH/u57xKPT8d++voeYE6HeluA/bPbQ15TwDBG2ii+QZIEmYx8VdxA==}
|
||||
|
||||
[email protected]:
|
||||
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
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-KxPOq3V2LmfQPP4eqf3Mq/zrT0Dqp2Vmx2Bn285LwVahLc+CsxOM0crBHczm8ijlcjZ0Q5Xd6LW3z3odTPnlrw==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@alloc/[email protected]': {}
|
||||
@@ -2150,8 +2216,14 @@ snapshots:
|
||||
dependencies:
|
||||
csstype: 3.1.3
|
||||
|
||||
'@types/[email protected]':
|
||||
dependencies:
|
||||
htmlparser2: 8.0.2
|
||||
|
||||
'@types/[email protected]': {}
|
||||
|
||||
'@types/[email protected]': {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
@@ -2216,17 +2288,41 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
domhandler: 5.0.3
|
||||
entities: 4.5.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
dom-serializer: 2.0.0
|
||||
domelementtype: 2.3.0
|
||||
domhandler: 5.0.3
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
tapable: 2.3.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]([email protected]([email protected]))([email protected]):
|
||||
@@ -2242,6 +2338,13 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
domhandler: 5.0.3
|
||||
domutils: 3.2.2
|
||||
entities: 4.5.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -2253,6 +2356,8 @@ snapshots:
|
||||
'@formatjs/icu-messageformat-parser': 2.11.4
|
||||
tslib: 2.8.1
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -2376,6 +2481,8 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -2481,6 +2588,15 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
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
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
@@ -2565,6 +2681,8 @@ snapshots:
|
||||
dependencies:
|
||||
react: 19.1.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
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
|
||||
|
||||
[email protected]: {}
|
||||
Reference in new issue
Block a user