Modelo do encurtador em FastAPI

Gerado a partir de deploy-templates/fastapi (dado.cloud ab2f4c0).
This commit is contained in:
dado.cloud
2026-10-21 14:40:18 +00:00
parent 7006615b91
commit e0c5d12f3f
5 changed files with 251 additions and 14 deletions
+6 -2
View File
@@ -8,9 +8,10 @@ uma versão nova do seu app, sozinho.
## O que tem aqui ## O que tem aqui
- `main.py`: o app FastAPI — página, API (`POST/GET /api/links`) e o redirecionamento `GET /<código>` (302). - `main.py`: o app FastAPI — página, API (`POST/GET /api/links`), o aviso `GET /<código>` e o redirecionamento `GET /<código>/ir` (302).
- `links.py`: onde os links ficam guardados (na memória) e a validação dos endereços. - `links.py`: 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/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. - `paginas/nao-encontrado.html`: a página de link que não existe.
- `requirements.txt`: as dependências, com versão fixa. - `requirements.txt`: as dependências, com versão fixa.
@@ -73,7 +74,10 @@ curl https://<endereço-do-seu-app>/api/links
# 200 {"links":[...]} # 200 {"links":[...]}
``` ```
`GET /<código>` redireciona (302) para o link original e soma um clique. A API aceita `GET /<código>` abre uma página de aviso com o endereço de destino e o botão "Continuar",
que leva a `GET /<código>/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. 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. Para evitar abuso, cada IP cria no máximo 30 links a cada 10 minutos.
+93 -8
View File
@@ -5,11 +5,15 @@ somem toda vez que o app reinicia ou é publicado de novo. Para guardar de vez,
troque este arquivo por consultas a um Postgres. troque este arquivo por consultas a um Postgres.
""" """
import ipaddress
import re import re
import secrets import secrets
import unicodedata
from datetime import datetime, timezone from datetime import datetime, timezone
from urllib.parse import urlsplit, urlunsplit from urllib.parse import urlsplit, urlunsplit
import idna
ALFABETO = "abcdefghijklmnopqrstuvwxyz0123456789" ALFABETO = "abcdefghijklmnopqrstuvwxyz0123456789"
TAMANHO_CODIGO = 6 TAMANHO_CODIGO = 6
TAMANHO_MAXIMO_URL = 2048 TAMANHO_MAXIMO_URL = 2048
@@ -21,6 +25,20 @@ RESERVADOS = {"api", "docs"}
FORMATO_CODIGO = re.compile(r"^[a-z0-9]{6}$") FORMATO_CODIGO = re.compile(r"^[a-z0-9]{6}$")
LINK_INVALIDO = "Esse não parece um link válido. Use um endereço que comece com http:// ou https://."
# Caracteres que o navegador troca por %XX em cada parte do link (conjuntos do
# padrão WHATWG URL, os mesmos do `new URL()` dos modelos Node). Além destes,
# todo caractere fora do ASCII visível (acentos, controle, bidi) vira %XX.
_CODIFICAR_NO_CAMINHO = frozenset(' "#<>?`{}')
_CODIFICAR_NA_CONSULTA = frozenset(" \"#<>'")
_CODIFICAR_NO_FRAGMENTO = frozenset(' "<>`')
_CODIFICAR_NO_USUARIO = _CODIFICAR_NO_CAMINHO | frozenset("/:;=@[\\]^|")
# Último pedaço do host que o navegador lê como número (ex.: "1", "0x7f").
_PARECE_NUMERO = re.compile(r"^(?:[0-9]+|0x[0-9a-f]*)$")
_OCTETO = re.compile(r"^(?:0|[1-9][0-9]{0,2})$")
# Dicionário do Python mantém a ordem de inserção: o primeiro é o mais antigo. # Dicionário do Python mantém a ordem de inserção: o primeiro é o mais antigo.
_links: dict[str, dict] = {} _links: dict[str, dict] = {}
@@ -42,29 +60,79 @@ def _validar_url(valor) -> str:
texto = valor.strip() texto = valor.strip()
if len(texto) > TAMANHO_MAXIMO_URL: if len(texto) > TAMANHO_MAXIMO_URL:
raise ErroDeValidacao(f"Esse link é comprido demais (máximo de {TAMANHO_MAXIMO_URL} caracteres).") raise ErroDeValidacao(f"Esse link é comprido demais (máximo de {TAMANHO_MAXIMO_URL} caracteres).")
# Barra invertida o navegador lê como "/" (e "a.com\@b.com" abre a.com, não
# b.com); espaço e meio caractere UTF-16 também não entram.
if "\\" in texto or any(c.isspace() or 0xD800 <= ord(c) <= 0xDFFF for c in texto):
raise ErroDeValidacao(LINK_INVALIDO)
try: try:
partes = urlsplit(texto) partes = urlsplit(texto)
porta = partes.port # porta inválida (ex.: ":abc") levanta ValueError porta = partes.port # porta inválida (ex.: ":abc") levanta ValueError
except ValueError: except ValueError:
partes = None partes = None
if not partes or partes.scheme not in ("http", "https") or not partes.hostname or any(c.isspace() for c in texto): if not partes or partes.scheme.lower() not in ("http", "https") or not partes.hostname:
raise ErroDeValidacao("Esse não parece um link válido. Use um endereço que comece com http:// ou https://.") raise ErroDeValidacao(LINK_INVALIDO)
return _normalizar(partes, porta) return _normalizar(partes, porta)
def _normalizar_host(host: str) -> str:
"""O host exatamente como o navegador vai abrir — é ele que a página de aviso
mostra, então nada aqui pode ser ambíguo. Na dúvida, o link é recusado."""
if ":" in host: # IPv6, que veio entre colchetes
try:
if "%" in host:
raise ValueError
return f"[{ipaddress.IPv6Address(host).compressed}]"
except ValueError:
raise ErroDeValidacao(LINK_INVALIDO) from None
host = host.lower()
# Só letras, números, "-" e ".": fica de fora %XX, controle, bidi e símbolos.
if any(unicodedata.category(c)[0] not in "LMN" and c not in "-." for c in host):
raise ErroDeValidacao(LINK_INVALIDO)
if not host.isascii(): # nome com acento → punycode (xn--...), como o navegador
try:
host = idna.encode(host, uts46=True, transitional=False).decode("ascii")
except (idna.IDNAError, UnicodeError):
raise ErroDeValidacao(LINK_INVALIDO) from None
# Host que termina em número o navegador lê como IPv4 ("0x7f.1" abre
# 127.0.0.1). Aceita só a forma de sempre: 4 números de 0 a 255.
rotulos = host.split(".")
ultimo = rotulos[-2] if len(rotulos) > 1 and rotulos[-1] == "" else rotulos[-1]
if _PARECE_NUMERO.match(ultimo):
if len(rotulos) != 4 or not all(_OCTETO.match(r) and int(r) <= 255 for r in rotulos):
raise ErroDeValidacao(LINK_INVALIDO)
return host
def _codificar(texto: str, conjunto: frozenset) -> str:
"""Troca por %XX (UTF-8) o que o navegador trocaria; %XX que já vem fica."""
return "".join(
c if 0x20 < ord(c) < 0x7F and c not in conjunto else "".join(f"%{b:02X}" for b in c.encode("utf-8"))
for c in texto
)
def _normalizar(partes, porta) -> str: def _normalizar(partes, porta) -> str:
"""Mesma forma que o `new URL(...).href` dos modelos Node: esquema e host em """Mesma forma que o `new URL(...).href` dos modelos Node: esquema e host em
minúsculas, sem a porta padrão e com "/" quando o caminho vem vazio.""" minúsculas (com acento vira punycode), sem a porta padrão, "/" quando o
caminho vem vazio e %XX onde o navegador também codifica."""
esquema = partes.scheme.lower() esquema = partes.scheme.lower()
host = partes.hostname.lower() host = _normalizar_host(partes.hostname)
if ":" in host: # IPv6 volta entre colchetes
host = f"[{host}]"
if porta is not None and porta != {"http": 80, "https": 443}[esquema]: if porta is not None and porta != {"http": 80, "https": 443}[esquema]:
host = f"{host}:{porta}" host = f"{host}:{porta}"
if partes.username is not None: if partes.username is not None:
credenciais = partes.username + (f":{partes.password}" if partes.password is not None else "") credenciais = _codificar(partes.username, _CODIFICAR_NO_USUARIO)
if partes.password is not None:
credenciais += ":" + _codificar(partes.password, _CODIFICAR_NO_USUARIO)
host = f"{credenciais}@{host}" host = f"{credenciais}@{host}"
return urlunsplit((esquema, host, partes.path or "/", partes.query, partes.fragment)) return urlunsplit(
(
esquema,
host,
_codificar(partes.path or "/", _CODIFICAR_NO_CAMINHO),
_codificar(partes.query, _CODIFICAR_NA_CONSULTA),
_codificar(partes.fragment, _CODIFICAR_NO_FRAGMENTO),
)
)
def criar_link(valor) -> dict: def criar_link(valor) -> dict:
@@ -85,6 +153,23 @@ def listar_links() -> list[dict]:
return list(reversed(list(_links.values())[-LINKS_NA_LISTA:])) return list(reversed(list(_links.values())[-LINKS_NA_LISTA:]))
def buscar_link(codigo: str) -> dict | None:
"""O link do código, ou None se ele não existe (não soma clique)."""
return _links.get(codigo)
def host_do_link(url: str) -> str:
"""O host de um link já guardado (normalizado na criação): sem usuário/senha
e com a porta só quando não é a padrão — igual ao `new URL(url).host` do Node."""
partes = urlsplit(url)
host = partes.hostname or ""
if ":" in host: # IPv6 volta entre colchetes
host = f"[{host}]"
if partes.port is not None:
host = f"{host}:{partes.port}"
return host
def registrar_clique(codigo: str) -> dict | None: def registrar_clique(codigo: str) -> dict | None:
"""Soma um clique e devolve o link, ou None se o código não existe.""" """Soma um clique e devolve o link, ou None se o código não existe."""
link = _links.get(codigo) link = _links.get(codigo)
+40 -4
View File
@@ -7,6 +7,7 @@ import html
import json import json
import os import os
import platform import platform
import re
import time import time
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
@@ -15,7 +16,7 @@ import fastapi
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
from links import FORMATO_CODIGO, ErroDeValidacao, criar_link, listar_links, registrar_clique from links import FORMATO_CODIGO, ErroDeValidacao, buscar_link, criar_link, host_do_link, listar_links, registrar_clique
# Variável de ambiente de exemplo: na Dado, ela se configura no painel do app. # Variável de ambiente de exemplo: na Dado, ela se configura no painel do app.
MENSAGEM = os.environ.get("MENSAGEM_BOAS_VINDAS") or "Defina a variável MENSAGEM_BOAS_VINDAS para trocar este texto." MENSAGEM = os.environ.get("MENSAGEM_BOAS_VINDAS") or "Defina a variável MENSAGEM_BOAS_VINDAS para trocar este texto."
@@ -36,6 +37,19 @@ PAGINA_INICIAL = (
.replace("{{MENSAGEM}}", html.escape(MENSAGEM)) .replace("{{MENSAGEM}}", html.escape(MENSAGEM))
) )
PAGINA_NAO_ENCONTRADA = (PASTA_PAGINAS / "nao-encontrado.html").read_text(encoding="utf-8") PAGINA_NAO_ENCONTRADA = (PASTA_PAGINAS / "nao-encontrado.html").read_text(encoding="utf-8")
MODELO_AVISO = (PASTA_PAGINAS / "aviso.html").read_text(encoding="utf-8")
# A página de aviso não roda script nenhum: o navegador só aceita o CSS dela.
CSP_AVISO = "default-src 'none'; style-src 'unsafe-inline'; img-src data:; frame-ancestors 'none'; base-uri 'none'; form-action 'none'"
MARCADORES_AVISO = re.compile(r"\{\{(HOST|URL|CODIGO)\}\}")
def pagina_aviso(link: dict) -> str:
"""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."""
valores = {"HOST": host_do_link(link["url"]), "URL": link["url"], "CODIGO": link["codigo"]}
return MARCADORES_AVISO.sub(lambda m: html.escape(valores[m.group(1)]), MODELO_AVISO)
# Freio simples contra abuso: no máximo 30 links novos a cada 10 minutos por IP. # Freio simples contra abuso: no máximo 30 links novos a cada 10 minutos por IP.
LIMITE_POR_IP = 30 LIMITE_POR_IP = 30
@@ -171,14 +185,36 @@ async def criar(request: Request):
return JSONResponse(status_code=201, content=para_json(link, request)) return JSONResponse(status_code=201, content=para_json(link, request))
# O redirecionamento é feito pelo servidor: GET /<código> → 302 para o link. # GET /<código> não redireciona direto: mostra antes uma página de aviso com o
# Fica por último: as rotas fixas acima têm prioridade. # 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.
# Estas rotas ficam por último: as rotas fixas acima têm prioridade.
@app.get("/{codigo}", include_in_schema=False) @app.get("/{codigo}", include_in_schema=False)
def aviso(codigo: str):
link = buscar_link(codigo) if FORMATO_CODIGO.match(codigo) else None
if link is None:
return HTMLResponse(PAGINA_NAO_ENCONTRADA, status_code=404)
return HTMLResponse(
pagina_aviso(link),
headers={
"Cache-Control": "no-store",
"Referrer-Policy": "no-referrer",
"X-Robots-Tag": "noindex, nofollow",
"Content-Security-Policy": CSP_AVISO,
},
)
# O botão "Continuar" leva para GET /<código>/ir: aqui o servidor soma o
# clique e redireciona (302) para o link.
@app.get("/{codigo}/ir", include_in_schema=False)
def redirecionar(codigo: str): def redirecionar(codigo: str):
link = registrar_clique(codigo) if FORMATO_CODIGO.match(codigo) else None link = registrar_clique(codigo) if FORMATO_CODIGO.match(codigo) else None
if link is None: if link is None:
return HTMLResponse(PAGINA_NAO_ENCONTRADA, status_code=404) return HTMLResponse(PAGINA_NAO_ENCONTRADA, status_code=404)
return RedirectResponse(link["url"], status_code=302, headers={"Cache-Control": "no-store"}) return RedirectResponse(
link["url"], status_code=302, headers={"Cache-Control": "no-store", "Referrer-Policy": "no-referrer"}
)
if __name__ == "__main__": if __name__ == "__main__":
+111
View File
@@ -0,0 +1,111 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex, nofollow" />
<meta name="referrer" content="no-referrer" />
<title>Você está saindo deste site</title>
<link
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%233b5bdb'/%3E%3Cpath d='M13.5 18.5l5-5M12 15l-2 2a3.5 3.5 0 005 5l2-2M20 17l2-2a3.5 3.5 0 00-5-5l-2 2' stroke='white' stroke-width='2.2' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"
/>
<style>
/* Página de aviso antes de sair: mesmo visual da página inicial. */
:root {
--fundo: #f6f7f9;
--cartao: #ffffff;
--texto: #14161a;
--suave: #5b6270;
--borda: #e3e6eb;
--destaque: #3b5bdb;
--destaque-texto: #ffffff;
--destaque-suave: #edf1ff;
--raio: 14px;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
color-scheme: light dark;
}
@media (prefers-color-scheme: dark) {
:root {
--fundo: #0f1115;
--cartao: #171a21;
--texto: #eceef2;
--suave: #9aa1ad;
--borda: #2a2f3a;
--destaque: #8299ff;
--destaque-texto: #0f1115;
--destaque-suave: #1e2540;
}
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: var(--fundo);
color: var(--texto);
line-height: 1.5;
-webkit-text-size-adjust: 100%;
}
.pagina { max-width: 640px; margin: 0 auto; padding: 56px 16px 32px; }
.topo { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
.marca {
flex: none; width: 40px; height: 40px; border-radius: 10px;
background: var(--destaque); color: var(--destaque-texto);
display: grid; place-items: center;
}
h1 { font-size: 28px; line-height: 1.2; margin: 0; letter-spacing: -0.02em; }
.lead { color: var(--suave); margin: 0 0 24px; }
.cartao { background: var(--cartao); border: 1px solid var(--borda); border-radius: var(--raio); padding: 20px; }
.rotulo { margin: 0; font-size: 13px; color: var(--suave); }
.host { margin: 2px 0 0; font-family: var(--mono); font-size: 22px; font-weight: 700; overflow-wrap: anywhere; }
.url-completa {
margin: 12px 0 0; padding: 12px 14px; border-radius: 10px; background: var(--destaque-suave);
font-family: var(--mono); font-size: 14px; overflow-wrap: anywhere;
}
.cuidado { margin: 16px 0 0; font-size: 14px; color: var(--suave); }
.acoes { display: flex; align-items: center; gap: 16px; margin-top: 20px; }
.botao {
font-weight: 600; padding: 12px 20px; border-radius: 10px; text-decoration: none;
background: var(--destaque); color: var(--destaque-texto); overflow-wrap: anywhere;
}
.voltar { color: var(--destaque); font-weight: 600; }
a:focus-visible { outline: 2px solid var(--destaque); outline-offset: 2px; }
.rodape { margin-top: 32px; text-align: center; font-size: 13px; color: var(--suave); }
.rodape a { color: inherit; }
@media (max-width: 480px) {
.pagina { padding-top: 32px; }
h1 { font-size: 24px; }
.host { font-size: 20px; }
.acoes { flex-direction: column; align-items: stretch; text-align: center; }
}
</style>
</head>
<body>
<main class="pagina">
<header class="topo">
<span class="marca" aria-hidden="true">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round">
<path d="M10 14l4-4M8.5 11.5l-1.8 1.8a3.2 3.2 0 004.5 4.5l1.8-1.8M15.5 12.5l1.8-1.8a3.2 3.2 0 00-4.5-4.5l-1.8 1.8" />
</svg>
</span>
<h1>Você está saindo deste site</h1>
</header>
<p class="lead">Este link curto leva para outro endereço.</p>
<section class="cartao" aria-label="Destino do link">
<p class="rotulo">Você está indo para</p>
<p class="host">{{HOST}}</p>
<p class="url-completa">{{URL}}</p>
<p class="cuidado">
Confira se o endereço é o que você esperava. Este encurtador é público: qualquer pessoa pode criar links nele.
</p>
<div class="acoes">
<a class="botao" href="/{{CODIGO}}/ir" rel="noreferrer">Continuar para {{HOST}}</a>
<a class="voltar" href="/">Voltar</a>
</div>
</section>
<footer class="rodape">Publicado na <a href="https://dado.cloud" target="_blank" rel="noopener">Dado</a></footer>
</main>
</body>
</html>
+1
View File
@@ -1,2 +1,3 @@
fastapi==0.156.8 fastapi==0.156.8
idna==3.20
uvicorn==0.54.0 uvicorn==0.54.0