Template
Modelo do encurtador em FastAPI
Gerado a partir de deploy-templates/fastapi (dado.cloud ab2f4c0).
This commit is contained in:
@@ -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.
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
import re
|
||||
import secrets
|
||||
import unicodedata
|
||||
from datetime import datetime, timezone
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
import idna
|
||||
|
||||
ALFABETO = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
TAMANHO_CODIGO = 6
|
||||
TAMANHO_MAXIMO_URL = 2048
|
||||
@@ -21,6 +25,20 @@ RESERVADOS = {"api", "docs"}
|
||||
|
||||
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.
|
||||
_links: dict[str, dict] = {}
|
||||
|
||||
@@ -42,29 +60,79 @@ def _validar_url(valor) -> str:
|
||||
texto = valor.strip()
|
||||
if len(texto) > TAMANHO_MAXIMO_URL:
|
||||
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:
|
||||
partes = urlsplit(texto)
|
||||
porta = partes.port # porta inválida (ex.: ":abc") levanta ValueError
|
||||
except ValueError:
|
||||
partes = None
|
||||
if not partes or partes.scheme not in ("http", "https") or not partes.hostname or any(c.isspace() for c in texto):
|
||||
raise ErroDeValidacao("Esse não parece um link válido. Use um endereço que comece com http:// ou https://.")
|
||||
if not partes or partes.scheme.lower() not in ("http", "https") or not partes.hostname:
|
||||
raise ErroDeValidacao(LINK_INVALIDO)
|
||||
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:
|
||||
"""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()
|
||||
host = partes.hostname.lower()
|
||||
if ":" in host: # IPv6 volta entre colchetes
|
||||
host = f"[{host}]"
|
||||
host = _normalizar_host(partes.hostname)
|
||||
if porta is not None and porta != {"http": 80, "https": 443}[esquema]:
|
||||
host = f"{host}:{porta}"
|
||||
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}"
|
||||
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:
|
||||
@@ -85,6 +153,23 @@ def listar_links() -> list[dict]:
|
||||
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:
|
||||
"""Soma um clique e devolve o link, ou None se o código não existe."""
|
||||
link = _links.get(codigo)
|
||||
|
||||
Reference in New Issue
Block a user