Files
dado.cloud 46bf3d94fd Modelo do encurtador em Next.js
Gerado a partir de deploy-templates/nextjs (dado.cloud 57aa2a2).
2026-10-21 12:43:20 +00:00

86 lines
3.2 KiB
TypeScript

import { criarLink, enderecoBase, ErroDeValidacao, listarLinks, paraJson } from '@/lib/links';
// CORS aberto só na API: qualquer site (ou outro app seu) pode chamar /api/*.
const CORS = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
};
// Freio simples contra abuso: no máximo 30 links novos a cada 10 minutos por IP.
const LIMITE_POR_IP = 30;
const JANELA_MS = 10 * 60 * 1000;
const tentativas = new Map<string, { total: number; expiraEm: number }>();
function passouDoLimite(ip: string): boolean {
const agora = Date.now();
if (tentativas.size > 10_000) tentativas.clear();
const atual = tentativas.get(ip);
if (!atual || atual.expiraEm <= agora) {
tentativas.set(ip, { total: 1, expiraEm: agora + JANELA_MS });
return false;
}
atual.total += 1;
return atual.total > LIMITE_POR_IP;
}
const erro = (status: number, mensagem: string) => Response.json({ erro: mensagem }, { status, headers: CORS });
const CORPO_INVALIDO = 'Corpo inválido. Envie JSON no formato {"url": "https://..."}.';
// Um pedido de encurtar link cabe com folga em 10 KB; mais que isso é recusado
// sem ler o resto (o app é público: ninguém deve conseguir mandar megabytes).
const TAMANHO_MAXIMO_CORPO = 10 * 1024;
/** Lê o corpo em pedaços e para assim que passar do limite (não confia só no Content-Length). */
async function lerJsonLimitado(request: Request): Promise<{ url?: unknown } | null> {
const declarado = request.headers.get('content-length');
if (declarado !== null && !(/^\d+$/.test(declarado) && Number(declarado) <= TAMANHO_MAXIMO_CORPO)) return null;
if (!request.body) return null;
const leitor = request.body.getReader();
const pedacos: Uint8Array[] = [];
let total = 0;
for (;;) {
const { done, value } = await leitor.read();
if (done) break;
total += value.byteLength;
if (total > TAMANHO_MAXIMO_CORPO) {
await leitor.cancel();
return null;
}
pedacos.push(value);
}
try {
const dados: unknown = JSON.parse(Buffer.concat(pedacos).toString('utf8'));
return dados && typeof dados === 'object' && !Array.isArray(dados) ? (dados as { url?: unknown }) : null;
} catch {
return null;
}
}
export function OPTIONS() {
return new Response(null, { status: 204, headers: CORS });
}
export function GET(request: Request) {
const base = enderecoBase(request.headers);
return Response.json(
{ links: listarLinks().map((link) => paraJson(link, base)) },
{ headers: { ...CORS, 'Cache-Control': 'no-store' } },
);
}
export async function POST(request: Request) {
const ip = request.headers.get('cf-connecting-ip') ?? request.headers.get('x-forwarded-for')?.split(',')[0].trim() ?? '?';
if (passouDoLimite(ip)) return erro(429, 'Muitos links em pouco tempo. Espere alguns minutos e tente de novo.');
const corpo = await lerJsonLimitado(request);
if (!corpo) return erro(400, CORPO_INVALIDO);
try {
const link = criarLink(corpo.url);
return Response.json(paraJson(link, enderecoBase(request.headers)), { status: 201, headers: CORS });
} catch (e) {
if (e instanceof ErroDeValidacao) return erro(400, e.message);
throw e;
}
}