import { readFileSync } from 'node:fs'; import express from 'express'; import { criarLink, ErroDeValidacao, FORMATO_CODIGO, listarLinks, registrarClique } from './links.js'; const PORTA = Number(process.env.PORT) || 3000; // Variável de ambiente de exemplo: na Dado, ela se configura no painel do app. const MENSAGEM = process.env.MENSAGEM_BOAS_VINDAS || 'Defina a variável MENSAGEM_BOAS_VINDAS para trocar este texto.'; const INICIO = new Date(); const pacote = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')); const lerPagina = (nome) => readFileSync(new URL(`./paginas/${nome}`, import.meta.url), 'utf8'); const escaparHtml = (texto) => String(texto).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); const fusoBrasilia = { timeZone: 'America/Sao_Paulo' }; const inicioFormatado = `${INICIO.toLocaleDateString('pt-BR', fusoBrasilia)} às ${INICIO.toLocaleTimeString('pt-BR', { ...fusoBrasilia, hour: '2-digit', minute: '2-digit', })} (horário de Brasília)`; // A página é montada uma vez só: nada nela muda enquanto o processo vive. // O valor entra por função (`() => valor`): com uma string, o replace trataria // `$&`, `$'` e `$$` da mensagem como padrões especiais e estragaria a página. const paginaInicial = lerPagina('index.html') .replace('{{RUNTIME}}', () => escaparHtml(`Express ${pacote.dependencies.express} · Node.js ${process.versions.node}`)) .replace('{{INICIO_ISO}}', () => INICIO.toISOString()) .replace('{{INICIO}}', () => escaparHtml(inicioFormatado)) .replace('{{MENSAGEM}}', () => escaparHtml(MENSAGEM)); const paginaNaoEncontrada = lerPagina('nao-encontrado.html'); // 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(); function passouDoLimite(ip) { 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 app = express(); app.disable('x-powered-by'); // O app roda atrás de um proxy HTTPS: confia nos cabeçalhos X-Forwarded-*. app.set('trust proxy', true); const enderecoBase = (req) => `${req.protocol}://${req.get('host')}`; const paraJson = (link, req) => ({ codigo: link.codigo, url: link.url, curto: `${enderecoBase(req)}/${link.codigo}`, cliques: link.cliques, criadoEm: link.criadoEm, }); // CORS aberto só na API: qualquer site (ou outro app seu) pode chamar /api/*. app.use('/api', (req, res, next) => { res.set({ 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type', }); if (req.method === 'OPTIONS') return res.sendStatus(204); next(); }); app.use('/api', express.json({ limit: '10kb' })); app.get('/', (req, res) => { res.type('html').send(paginaInicial); }); app.get('/api/links', (req, res) => { res.set('Cache-Control', 'no-store'); res.json({ links: listarLinks().map((link) => paraJson(link, req)) }); }); app.post('/api/links', (req, res) => { const ip = req.get('cf-connecting-ip') || req.ip; if (passouDoLimite(ip)) { return res.status(429).json({ erro: 'Muitos links em pouco tempo. Espere alguns minutos e tente de novo.' }); } try { const link = criarLink(req.body?.url); res.status(201).json(paraJson(link, req)); } catch (erro) { if (erro instanceof ErroDeValidacao) return res.status(400).json({ erro: erro.message }); throw erro; } }); // O redirecionamento é feito pelo servidor: GET / → 302 para o link. app.get('/:codigo', (req, res, next) => { if (!FORMATO_CODIGO.test(req.params.codigo)) return next(); const link = registrarClique(req.params.codigo); if (!link) return res.status(404).type('html').send(paginaNaoEncontrada); res.set('Cache-Control', 'no-store'); res.redirect(302, link.url); }); app.use((req, res) => { res.status(404).type('html').send(paginaNaoEncontrada); }); // JSON malformado no corpo vira 400 com mensagem, não página de erro. app.use((erro, req, res, next) => { if (erro.type === 'entity.parse.failed' || erro.type === 'entity.too.large') { return res.status(400).json({ erro: 'Corpo inválido. Envie JSON no formato {"url": "https://..."}.' }); } next(erro); }); app.listen(PORTA, () => { console.log(`Encurtador no ar em http://localhost:${PORTA}`); });