diff --git a/README.md b/README.md index c64b207..f94e529 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,10 @@ uma versão nova do seu app, sozinho. - `app/page.tsx`: a página (formulário, últimos links e "Sobre este deploy"). - `app/Encurtador.tsx`: a parte interativa da página (encurtar, copiar, lista). - `app/api/links/route.ts`: a API — `POST /api/links` cria, `GET /api/links` lista. -- `app/[codigo]/route.ts`: o redirecionamento `GET /` (302) e a contagem de cliques. +- `app/[codigo]/route.ts`: a página de aviso `GET /`, que mostra para onde o link leva. +- `app/[codigo]/ir/route.ts`: o redirecionamento `GET //ir` (302) e a contagem de cliques. - `lib/links.ts`: onde os links ficam guardados (na memória). +- `lib/paginas.ts`: o HTML da página de aviso e da página de link que não existe. - `app/globals.css`: o visual. Mude à vontade. Não tem banco de dados: os links ficam na memória do app e **somem quando ele é publicado @@ -72,7 +74,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/app/[codigo]/ir/route.ts b/app/[codigo]/ir/route.ts new file mode 100644 index 0000000..fe711e3 --- /dev/null +++ b/app/[codigo]/ir/route.ts @@ -0,0 +1,25 @@ +import { buscarLink, FORMATO_CODIGO, registrarClique, type Link } from '@/lib/links'; +import { respostaNaoEncontrada } from '@/lib/paginas'; + +type Contexto = { params: Promise<{ codigo: string }> }; + +function redirecionar(link: Link | null): Response { + if (!link) return respostaNaoEncontrada(); + return new Response(null, { + status: 302, + headers: { Location: link.url, 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' }, + }); +} + +// O botão "Continuar" leva para GET //ir: aqui o servidor soma o +// clique e redireciona (302) para o link. +export async function GET(_request: Request, { params }: Contexto) { + const { codigo } = await params; + return redirecionar(FORMATO_CODIGO.test(codigo) ? registrarClique(codigo) : null); +} + +// HEAD (robôs que só conferem o endereço) responde igual, mas não conta clique. +export async function HEAD(_request: Request, { params }: Contexto) { + const { codigo } = await params; + return redirecionar(FORMATO_CODIGO.test(codigo) ? buscarLink(codigo) : null); +} diff --git a/app/[codigo]/route.ts b/app/[codigo]/route.ts index ed7cfc0..bf424d1 100644 --- a/app/[codigo]/route.ts +++ b/app/[codigo]/route.ts @@ -1,48 +1,20 @@ -import { FORMATO_CODIGO, registrarClique } from '@/lib/links'; +import { buscarLink, FORMATO_CODIGO } from '@/lib/links'; +import { CSP_AVISO, paginaAviso, respostaNaoEncontrada } from '@/lib/paginas'; -// 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. export async function GET(_request: Request, { params }: { params: Promise<{ codigo: string }> }) { const { codigo } = await params; - const link = FORMATO_CODIGO.test(codigo) ? registrarClique(codigo) : null; - if (!link) { - return new Response(PAGINA_NAO_ENCONTRADA, { - status: 404, - headers: { 'Content-Type': 'text/html; charset=utf-8' }, - }); - } - return new Response(null, { status: 302, headers: { Location: link.url, 'Cache-Control': 'no-store' } }); + const link = FORMATO_CODIGO.test(codigo) ? buscarLink(codigo) : null; + if (!link) return respostaNaoEncontrada(); + return new Response(paginaAviso(link), { + headers: { + 'Content-Type': 'text/html; charset=utf-8', + 'Cache-Control': 'no-store', + 'Referrer-Policy': 'no-referrer', + 'X-Robots-Tag': 'noindex, nofollow', + 'Content-Security-Policy': CSP_AVISO, + }, + }); } - -const PAGINA_NAO_ENCONTRADA = ` - - - - - Link não encontrado - - - -
-

Link não encontrado

-

Esse link curto não existe ou sumiu quando o app foi publicado de novo.

- Encurtar um link -
- - -`; diff --git a/lib/links.ts b/lib/links.ts index 2e05032..fabfa65 100644 --- a/lib/links.ts +++ b/lib/links.ts @@ -75,6 +75,11 @@ 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); diff --git a/lib/paginas.ts b/lib/paginas.ts new file mode 100644 index 0000000..965aa08 --- /dev/null +++ b/lib/paginas.ts @@ -0,0 +1,175 @@ +import type { Link } from './links'; + +// As páginas que o servidor devolve fora do React (aviso antes de sair e link +// não encontrado). São as mesmas dos modelos Express e FastAPI. + +const escaparHtml = (texto: string) => + texto.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!); + +/** + * 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. + */ +export function paginaAviso(link: Link): string { + const valores: Record = { HOST: new URL(link.url).host, URL: link.url, CODIGO: link.codigo }; + return MODELO_AVISO.replace(/\{\{(HOST|URL|CODIGO)\}\}/g, (_, chave: string) => escaparHtml(valores[chave])); +} + +// A página de aviso não roda script nenhum: o navegador só aceita o CSS dela. +export const CSP_AVISO = + "default-src 'none'; style-src 'unsafe-inline'; img-src data:; frame-ancestors 'none'; base-uri 'none'; form-action 'none'"; + +export function respostaNaoEncontrada(): Response { + return new Response(PAGINA_NAO_ENCONTRADA, { + status: 404, + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + }); +} + +const MODELO_AVISO = ` + + + + + + + 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. +

+ +
+ + +
+ + +`; + +const PAGINA_NAO_ENCONTRADA = ` + + + + + Link não encontrado + + + +
+

Link não encontrado

+

Esse link curto não existe ou sumiu quando o app foi publicado de novo.

+ Encurtar um link +
+ + +`;