"""Guarda dos links encurtados. Os links ficam num dicionário na memória do processo: simples e sem banco, mas 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 # Teto de links guardados: passou disso, o mais antigo sai (protege a memória). MAXIMO_DE_LINKS = 1000 LINKS_NA_LISTA = 20 # Caminhos do próprio app que nunca podem virar código. 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] = {} class ErroDeValidacao(ValueError): pass def _gerar_codigo() -> str: while True: codigo = "".join(secrets.choice(ALFABETO) for _ in range(TAMANHO_CODIGO)) if codigo not in _links and codigo not in RESERVADOS: return codigo def _validar_url(valor) -> str: if not isinstance(valor, str) or not valor.strip(): raise ErroDeValidacao('Envie o campo "url" com o link que você quer encurtar.') 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.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 (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 = _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 = _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, _codificar(partes.path or "/", _CODIFICAR_NO_CAMINHO), _codificar(partes.query, _CODIFICAR_NA_CONSULTA), _codificar(partes.fragment, _CODIFICAR_NO_FRAGMENTO), ) ) def criar_link(valor) -> dict: link = { "codigo": _gerar_codigo(), "url": _validar_url(valor), "cliques": 0, "criadoEm": datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z"), } _links[link["codigo"]] = link if len(_links) > MAXIMO_DE_LINKS: del _links[next(iter(_links))] return link def listar_links() -> list[dict]: """Os últimos links criados, do mais novo para o mais antigo.""" 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) if link is None: return None link["cliques"] += 1 return link