Template
107 lines
3.6 KiB
TypeScript
107 lines
3.6 KiB
TypeScript
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<string, Link> };
|
|
const links = (guarda.__links ??= new Map<string, Link>());
|
|
|
|
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();
|
|
}
|
|
|
|
/** O link do código, ou null se ele não existe (não soma clique). */
|
|
export function buscarLink(codigo: string): Link | null {
|
|
return links.get(codigo) ?? null;
|
|
}
|
|
|
|
/** 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,
|
|
};
|
|
}
|