@chrono-os/links
v0.1.1
Published
Encurtador de links com rastreio LGPD: slug único, destino só http/https, 302 por padrão (301 opcional), clique com IP em HMAC diário e estatísticas por dia e por origem. Persistência por interface; adaptador Prisma e schema de referência inclusos. Zero d
Downloads
275
Maintainers
Readme
@chrono-os/links
Encurtador de links com rastreio que respeita a LGPD. Cria, edita e lista links com slug único; recusa destino que não seja http/https; resolve o slug com 302 por padrão (301 opcional); registra o clique com o IP em HMAC diário, nunca em texto; e agrega estatísticas por dia e por origem.
Não depende de framework nem de banco. A persistência é uma interface (RepositorioLinks) que o app implementa sobre o próprio schema. O pacote traz um adaptador Prisma pronto para o schema de referência e um repositório em memória para teste. Zero dependências de runtime.
yarn add @chrono-os/linksUso
import { criarServicoLinks, criarRepositorioPrisma } from '@chrono-os/links'
export const links = criarServicoLinks({
repositorio: criarRepositorioPrisma({ redirect: prisma.redirect, clickLog: prisma.clickLog }),
segredoIp: env.LINKS_IP_SECRET, // 16+ caracteres: openssl rand -hex 32
slugsReservados: ['admin', 'api', 'health', 'propostas'],
validacaoUrl: { hostsProibidos: ['lnk.exemplo.com.br'] }, // o próprio encurtador
fusoHorario: 'America/Sao_Paulo', // define "dia" nas estatísticas
aoMudar: (e) => auditoria.registrar(e), // opcional
})
await links.criar({ slug: 'setembro', targetUrl: 'https://exemplo.com.br/lp', title: 'Campanha de setembro' })
await links.atualizar(id, { targetUrl: 'https://exemplo.com.br/lp-v2' })
await links.listar() // mais novo primeiro, com totalCliques
await links.arquivar(id) // soft delete; o slug continua ocupado
await links.desarquivar(id) // volta INATIVO
await links.excluirDefinitivamente(id) // só depois de arquivar
await links.estatisticas(id, { desde })Erros saem como subclasses de ErroLinks, com codigo e statusHttp sugerido: ErroSlugInvalido e ErroSlugReservado (422), ErroSlugEmUso (409), ErroUrlDestinoInvalida (422, com motivo), ErroEntradaInvalida (422), ErroLinkNaoEncontrado (404), ErroLinkNaoArquivado (409). ehErroLinks(e) reconhece qualquer um deles mesmo com as builds ESM e CJS carregadas ao mesmo tempo.
Rota pública
O núcleo devolve status, Location e headers; aplicar na resposta leva três linhas em qualquer framework.
Express ou Nest (@Res()):
const acesso = await links.acessar(req.params.slug, dadosDaRequisicao({ headers: req.headers, ip: req.ip, url: req.originalUrl }))
if (!acesso) return res.status(404).send(paginaNaoEncontrada)
for (const [k, v] of Object.entries(acesso.headers)) res.setHeader(k, v)
res.status(acesso.status).setHeader('Location', acesso.location)
res.setHeader('Content-Length', '0').end() // res.redirect() reescreve a URL com encodeUrl()Route handler do Next (Vercel):
import { waitUntil } from '@vercel/functions'
export async function GET(req: Request, { params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const acesso = await links.acessar(slug, dadosDaRequisicao({ headers: req.headers, url: req.url, confiarXForwardedFor: true }))
if (!acesso) return new Response('Link não encontrado', { status: 404 })
waitUntil(acesso.clique) // sem isso a função congela e o clique se perde
return new Response(null, { status: acesso.status, headers: { ...acesso.headers, Location: acesso.location } })
}acessar resolve o slug e dispara a gravação do clique sem esperar por ela. A gravação nunca rejeita: se o banco falhar, o erro vai para aoFalhar e a pessoa chega ao destino do mesmo jeito. A query do acesso é repassada ao destino (as UTMs de quem clicou chegam na página); se o destino já tem a mesma chave, vale a dele. Desligue com propagarQuery: false.
302 ou 301
O padrão é 302 (temporário). O navegador pergunta ao encurtador a cada clique, então todo clique é contado e trocar o destino vale na hora para todo mundo. A resposta sai com Cache-Control: no-store e X-Robots-Tag: noindex, nofollow.
301 (permanente) é guardado pelo navegador. A partir do segundo clique da mesma pessoa, o navegador vai direto ao destino sem passar pelo encurtador: o clique repetido some da contagem, e quem já clicou continua indo ao destino antigo depois de uma troca. Serve para migrar URL antiga preservando SEO; para link de campanha, não. Escolha por link (redirectType: 301) ou mude o padrão do serviço (tipoPadrao).
Destino
validarUrlDestino aceita só http: e https:. Recusa, com o motivo no erro:
| Entrada | Motivo |
|---|---|
| javascript:…, data:…, vbscript:…, file:…, ftp:… | protocolo |
| //golpe.com, /\golpe.com | protocolo-relativo (o navegador lê como outro host) |
| https://[email protected] | credenciais |
| caractere de controle (CR, LF, TAB) | caractere-de-controle |
| host em hostsProibidos ou subdomínio dele | host-proibido |
| /caminho sem permitirCaminhoRelativo | relativa |
| mais de 2048 caracteres | longa-demais |
Um javascript: gravado não executa no redirect (navegador ignora esse Location), mas vira XSS no painel que mostra o destino como link. As implementações anteriores validavam com z.string().url() do Zod 3, que aceita javascript:alert(1) e data:.
O valor gravado é o que a pessoa digitou, só aparado; o pacote não normaliza a URL.
Clique e LGPD
Cada clique grava:
| Campo | O que vai | Por quê |
|---|---|---|
| ipHash | HMAC-SHA256 com chave segredo:AAAA-MM-DD (UTC), base64url | Mesmo algoritmo de hashIpHmacDiario do @chrono-os/consent-lgpd 0.7 e do admin-audit. Sal fixo deixa reverter o hash varrendo o IPv4 (2³² tentativas); com a chave do dia, a correlação do mesmo IP fica limitada a 24 h |
| userAgent | resumido: Chrome/128 · Android | a string inteira é superfície de fingerprint |
| deviceType | mobile, tablet, desktop, bot, unknown | prévia de link do WhatsApp e do Telegram conta como bot |
| referrer | só o host (l.instagram.com) | a URL de quem indicou pode trazer e-mail ou token na query |
| country | ISO-2 do header da CDN | |
| utmSource … utmContent | as cinco UTMs, até 200 caracteres | |
segredoIp é obrigatório (16+ caracteres). Sem ele o serviço não sobe: hash de IP sem segredo é reversível.
Estatísticas
estatisticas(id, { desde, ate, limiteTop, excluirBots }) devolve totalCliques, cliquesHoje, cliques7d, visitantesUnicosDiarios, cliquesSemOrigem, a série porDia (sem buraco: dia sem clique vale 0) e os rankings porOrigem, porUtmSource, porUtmMedium, porUtmCampaign, porDispositivo e porPais.
visitantesUnicosDiarios é a soma, dia a dia, dos IPs distintos. Não é "pessoas distintas no período": como a chave do HMAC troca todo dia, a mesma pessoa em dois dias conta duas vezes. As implementações anteriores usavam sal fixo e contavam pessoas distintas no período inteiro; o número novo é maior para link com retorno. Dado antigo com sal fixo é contado do mesmo jeito (par dia + hash), então a métrica não muda de natureza no meio da série.
A agregação é feita em memória sobre listarCliques (como no links-manager). Vai bem até a casa de 10⁵ cliques por consulta. Acima disso, agregue no banco e monte o mesmo formato; agregarEstatisticas é pura e exportada.
Repositório próprio
Schema diferente do de referência implementa a interface direto. São nove métodos:
interface RepositorioLinks {
buscarPorId(id): Promise<Link | null>
buscarPorSlug(slug): Promise<Link | null> // inclui arquivados
listar({ incluirArquivados }): Promise<Link[]>
criar(dados): Promise<Link> // lança ErroSlugEmUso no conflito do banco
atualizar(id, alteracoes): Promise<Link> // idem para troca de slug
excluir(id): Promise<void> // cliques vão junto
registrarClique(clique): Promise<void>
listarCliques(linkId, { desde, ate }): Promise<Clique[]>
contarCliques(ids): Promise<Record<string, number>>
}O serviço confere o slug antes de gravar, mas só a constraint do banco fecha a corrida entre dois criar simultâneos. Por isso o repositório deve traduzir o erro de unicidade (P2002 no Prisma) em ErroSlugEmUso, como o adaptador pronto faz.
App com várias marcas no mesmo banco (coluna property) usa camposFixos: criarRepositorioPrisma({ …, camposFixos: { property: 'lealferraz' } }), um serviço por marca. O pacote não sabe o que a coluna significa.
Schema de referência
prisma/schema.prisma, também exportado como @chrono-os/links/schema.prisma. Tabelas e colunas são as do links-manager do Naírio. Quem já tem esse formato só precisa de uma coluna:
ALTER TABLE "redirects" ADD COLUMN "redirect_type" INTEGER NOT NULL DEFAULT 302;Validado com Prisma 7.10 (prisma validate, db push) e com o adaptador rodando contra Postgres 16.
Origem
Unifica quatro implementações: links-manager-backend do Naírio (a fonte), redirects do nairio-institucional, src/lib/links do lealferraz-dashboard e o módulo links do sobrevivendo-backend. O CHANGELOG registra o que veio de cada uma e o que mudou.
