import { randomInt } from 'node:crypto'; // Os links ficam num Map na memória do processo: simples e sem banco, mas // somem toda vez que o app reinicia ou é publicado de novo. Para guardar de // vez, troque este arquivo por consultas a um Postgres. export interface Link { codigo: string; url: string; cliques: number; criadoEm: string; } /** O que a API devolve: o link mais o endereço curto completo. */ export interface LinkJson extends Link { curto: string; } const ALFABETO = 'abcdefghijklmnopqrstuvwxyz0123456789'; const TAMANHO_CODIGO = 6; const TAMANHO_MAXIMO_URL = 2048; // Teto de links guardados: passou disso, o mais antigo sai (protege a memória). const MAXIMO_DE_LINKS = 1000; const LINKS_NA_LISTA = 20; // Caminhos do próprio app que nunca podem virar código. const RESERVADOS = new Set(['api']); export const FORMATO_CODIGO = /^[a-z0-9]{6}$/; // No Next.js, cada rota pode ser empacotada separada: guardar o Map no // globalThis garante que a página, a API e o redirecionamento vejam os mesmos links. const guarda = globalThis as typeof globalThis & { __links?: Map }; const links = (guarda.__links ??= new Map()); export class ErroDeValidacao extends Error {} function gerarCodigo(): string { for (;;) { let codigo = ''; for (let i = 0; i < TAMANHO_CODIGO; i += 1) codigo += ALFABETO[randomInt(ALFABETO.length)]; if (!links.has(codigo) && !RESERVADOS.has(codigo)) return codigo; } } function validarUrl(valor: unknown): string { if (typeof valor !== 'string' || valor.trim() === '') { throw new ErroDeValidacao('Envie o campo "url" com o link que você quer encurtar.'); } const texto = valor.trim(); if (texto.length > TAMANHO_MAXIMO_URL) { throw new ErroDeValidacao(`Esse link é comprido demais (máximo de ${TAMANHO_MAXIMO_URL} caracteres).`); } let url: URL | null; try { url = new URL(texto); } catch { url = null; } if (!url || (url.protocol !== 'http:' && url.protocol !== 'https:')) { throw new ErroDeValidacao('Esse não parece um link válido. Use um endereço que comece com http:// ou https://.'); } return url.href; } export function criarLink(valor: unknown): Link { const url = validarUrl(valor); const link: Link = { codigo: gerarCodigo(), url, cliques: 0, criadoEm: new Date().toISOString() }; links.set(link.codigo, link); if (links.size > MAXIMO_DE_LINKS) links.delete(links.keys().next().value!); return link; } /** Os últimos links criados, do mais novo para o mais antigo. */ export function listarLinks(): Link[] { return [...links.values()].slice(-LINKS_NA_LISTA).reverse(); } /** Soma um clique e devolve o link, ou null se o código não existe. */ export function registrarClique(codigo: string): Link | null { const link = links.get(codigo); if (!link) return null; link.cliques += 1; return link; } /** Endereço público do app, a partir dos cabeçalhos que o proxy HTTPS repassa. */ export function enderecoBase(cabecalhos: Headers): string { const protocolo = (cabecalhos.get('x-forwarded-proto') ?? 'http').split(',')[0].trim(); const host = cabecalhos.get('x-forwarded-host') ?? cabecalhos.get('host') ?? 'localhost:3000'; return `${protocolo}://${host}`; } export function paraJson(link: Link, base: string): LinkJson { return { codigo: link.codigo, url: link.url, curto: `${base}/${link.codigo}`, cliques: link.cliques, criadoEm: link.criadoEm, }; }