diff --git a/README.md b/README.md index 8eb05cb..e4235dd 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,10 @@ uma versão nova do seu app, sozinho. ## O que tem aqui -- `server.js`: o servidor — página, API (`POST/GET /api/links`) e o redirecionamento `GET /` (302). +- `server.js`: o servidor — página, API (`POST/GET /api/links`), o aviso `GET /` e o redirecionamento `GET //ir` (302). - `links.js`: onde os links ficam guardados (na memória) e a validação dos endereços. - `paginas/index.html`: a página (HTML, CSS e o JavaScript do formulário). Mude à vontade. +- `paginas/aviso.html`: a página de aviso que mostra para onde o link leva, antes de sair. - `paginas/nao-encontrado.html`: a página de link que não existe. Não tem banco de dados: os links ficam na memória do app e **somem quando ele é publicado @@ -70,7 +71,10 @@ curl https:///api/links # 200 {"links":[...]} ``` -`GET /` redireciona (302) para o link original e soma um clique. A API aceita +`GET /` abre uma página de aviso com o endereço de destino e o botão "Continuar", +que leva a `GET //ir`: ali o app soma um clique e redireciona (302) para o link +original. O aviso existe porque o encurtador é público — sem ele, qualquer pessoa poderia +usar o endereço do seu app para disfarçar um link de golpe. A API aceita chamadas de qualquer site (CORS aberto só em `/api/*`), então outro app seu pode usá-la. Para evitar abuso, cada IP cria no máximo 30 links a cada 10 minutos. diff --git a/links.js b/links.js index 9bcdfca..5c75150 100644 --- a/links.js +++ b/links.js @@ -60,6 +60,11 @@ export function listarLinks() { 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) { + 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) { const link = links.get(codigo); diff --git a/paginas/aviso.html b/paginas/aviso.html new file mode 100644 index 0000000..2f21fc1 --- /dev/null +++ b/paginas/aviso.html @@ -0,0 +1,111 @@ + + + + + + + + Você está saindo deste site + + + + +
+
+ +

Você está saindo deste site

+
+

Este link curto leva para outro endereço.

+ +
+

Você está indo para

+

{{HOST}}

+

{{URL}}

+

+ Confira se o endereço é o que você esperava. Este encurtador é público: qualquer pessoa pode criar links nele. +

+ +
+ + +
+ + diff --git a/server.js b/server.js index f1a9e9d..2560d81 100644 --- a/server.js +++ b/server.js @@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs'; import express from 'express'; -import { criarLink, ErroDeValidacao, FORMATO_CODIGO, listarLinks, registrarClique } from './links.js'; +import { buscarLink, 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. @@ -31,6 +31,17 @@ const paginaInicial = lerPagina('index.html') .replace('{{INICIO}}', () => escaparHtml(inicioFormatado)) .replace('{{MENSAGEM}}', () => escaparHtml(MENSAGEM)); const paginaNaoEncontrada = lerPagina('nao-encontrado.html'); +// A página de aviso não roda script nenhum: o navegador só aceita o CSS dela. +const CSP_AVISO = "default-src 'none'; style-src 'unsafe-inline'; img-src data:; frame-ancestors 'none'; base-uri 'none'; form-action 'none'"; +const modeloAviso = lerPagina('aviso.html'); + +// Página de aviso antes de sair: mostra para onde o link leva. Todo valor é +// escapado (o link foi digitado por alguém) e entra numa passada só, para um +// valor nunca ser lido como outro marcador. +function paginaAviso(link) { + const valores = { HOST: new URL(link.url).host, URL: link.url, CODIGO: link.codigo }; + return modeloAviso.replace(/\{\{(HOST|URL|CODIGO)\}\}/g, (_, chave) => escaparHtml(valores[chave])); +} // Freio simples contra abuso: no máximo 30 links novos a cada 10 minutos por IP. const LIMITE_POR_IP = 30; @@ -98,12 +109,30 @@ app.post('/api/links', (req, res) => { } }); -// O redirecionamento é feito pelo servidor: GET / → 302 para o link. +// GET / não redireciona direto: mostra antes uma página de aviso com o +// destino. Sem ela, qualquer pessoa usaria o seu endereço para disfarçar um +// link de golpe. A página não conta clique — quem só olha não foi ao destino. app.get('/:codigo', (req, res, next) => { if (!FORMATO_CODIGO.test(req.params.codigo)) return next(); - const link = registrarClique(req.params.codigo); + const link = buscarLink(req.params.codigo); if (!link) return res.status(404).type('html').send(paginaNaoEncontrada); - res.set('Cache-Control', 'no-store'); + res.set({ + 'Cache-Control': 'no-store', + 'Referrer-Policy': 'no-referrer', + 'X-Robots-Tag': 'noindex, nofollow', + 'Content-Security-Policy': CSP_AVISO, + }); + res.type('html').send(paginaAviso(link)); +}); + +// O botão "Continuar" leva para GET //ir: aqui o servidor soma o +// clique e redireciona (302) para o link. HEAD (robôs que só conferem o +// endereço) responde igual, mas não conta clique. +app.get('/:codigo/ir', (req, res, next) => { + if (!FORMATO_CODIGO.test(req.params.codigo)) return next(); + const link = req.method === 'HEAD' ? buscarLink(req.params.codigo) : registrarClique(req.params.codigo); + if (!link) return res.status(404).type('html').send(paginaNaoEncontrada); + res.set({ 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' }); res.redirect(302, link.url); });