From 239d0dee0a6577c8aaf556e6232a386f1ebe8c53 Mon Sep 17 00:00:00 2001 From: "dado.cloud" Date: Wed, 21 Oct 2026 12:27:03 +0000 Subject: [PATCH] Modelo do encurtador em FastAPI Gerado a partir de deploy-templates/fastapi (dado.cloud 4c2a882). --- .gitignore | 5 + .python-version | 1 + README.md | 86 +++++++++++ links.py | 78 ++++++++++ main.py | 151 ++++++++++++++++++++ paginas/index.html | 278 ++++++++++++++++++++++++++++++++++++ paginas/nao-encontrado.html | 32 +++++ requirements.txt | 2 + 8 files changed, 633 insertions(+) create mode 100644 .gitignore create mode 100644 .python-version create mode 100644 README.md create mode 100644 links.py create mode 100644 main.py create mode 100644 paginas/index.html create mode 100644 paginas/nao-encontrado.html create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..957abf3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.venv/ +__pycache__/ +*.pyc +.env +.DS_Store diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/README.md b/README.md new file mode 100644 index 0000000..ea3978f --- /dev/null +++ b/README.md @@ -0,0 +1,86 @@ +# Encurtador de links em FastAPI + +Um encurtador de links pronto para publicar na [Dado](https://dado.cloud): cole um link +comprido, receba um endereço curto e acompanhe quantas vezes ele foi aberto. + +Este repositório nasceu de um modelo da Dado. Cada `git push` na branch `main` publica +uma versão nova do seu app, sozinho. + +## O que tem aqui + +- `main.py`: o app FastAPI — página, API (`POST/GET /api/links`) e o redirecionamento `GET /` (302). +- `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/nao-encontrado.html`: a página de link que não existe. +- `requirements.txt`: as dependências, com versão fixa. + +Não tem banco de dados: os links ficam na memória do app e **somem quando ele é publicado +de novo**. Veja no fim como guardar de vez. + +## Rodar no seu computador + +Precisa de Python 3.11 ou mais novo. + +```bash +git clone https://git.dado.cloud//encurtador-fastapi.git +cd encurtador-fastapi +python3 -m venv .venv +source .venv/bin/activate # no Windows: .venv\Scripts\activate +pip install -r requirements.txt +uvicorn main:app --reload +``` + +Abra http://localhost:8000. + +Quando o `git clone` pedir usuário e senha, use o usuário da sua conta de Repositórios e, +como senha, o **token de Repositórios** (no painel da Dado, em Repositórios). O comando +certinho do seu app aparece no painel, em Deploys, na visão geral do app. + +## Mudar e publicar + +Edite o que quiser e mande para a Dado: + +```bash +git add . +git commit -m "Minha primeira mudança" +git push +``` + +O push na branch `main` publica uma versão nova. Dá para acompanhar o andamento em +Deploys, no painel. + +## Variável de ambiente de exemplo + +A mensagem em "Sobre este deploy" vem da variável `MENSAGEM_BOAS_VINDAS`. Para trocar, +abra Deploys no painel, clique em "Variáveis de ambiente" no seu app, salve e publique de +novo. No seu computador: + +```bash +MENSAGEM_BOAS_VINDAS="Oi, mundo" uvicorn main:app --reload +``` + +## API + +```bash +# Encurtar +curl -X POST https:///api/links \ + -H 'Content-Type: application/json' \ + -d '{"url": "https://exemplo.com.br/uma-pagina"}' +# 201 {"codigo":"a1b2c3","url":"https://exemplo.com.br/uma-pagina","curto":"https:///a1b2c3","cliques":0,"criadoEm":"..."} + +# Últimos 20 links +curl https:///api/links +# 200 {"links":[...]} +``` + +`GET /` redireciona (302) para o link original e soma um clique. 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. + +A documentação interativa da API (do próprio FastAPI) fica em `/docs`. + +## Guardar os links de vez + +Para os links sobreviverem a uma nova publicação, ligue um Postgres no app (painel, em +Deploys, aba "Banco de dados" do app). A conexão chega ao app na variável `DATABASE_URL`; +aí é trocar o que está em `links.py` por consultas ao banco. diff --git a/links.py b/links.py new file mode 100644 index 0000000..6508431 --- /dev/null +++ b/links.py @@ -0,0 +1,78 @@ +"""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 re +import secrets +from datetime import datetime, timezone +from urllib.parse import urlsplit + +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}$") + +# 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).") + try: + partes = urlsplit(texto) + 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://.") + return texto + + +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 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 diff --git a/main.py b/main.py new file mode 100644 index 0000000..8142a94 --- /dev/null +++ b/main.py @@ -0,0 +1,151 @@ +"""Encurtador de links em FastAPI, pronto para publicar na Dado. + +Rodar local: uvicorn main:app --reload (ou: python main.py) +""" + +import html +import os +import platform +import time +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import fastapi +from fastapi import FastAPI, Request +from fastapi.exceptions import RequestValidationError +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response +from pydantic import BaseModel + +from links import FORMATO_CODIGO, ErroDeValidacao, criar_link, listar_links, registrar_clique + +# 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." +INICIO = datetime.now(timezone.utc) +# Brasília não tem horário de verão desde 2019: UTC-3 o ano todo. +_inicio_brasilia = INICIO.astimezone(timezone(timedelta(hours=-3))) +INICIO_FORMATADO = _inicio_brasilia.strftime("%d/%m/%Y às %H:%M") + " (horário de Brasília)" + +PASTA_PAGINAS = Path(__file__).parent / "paginas" + +# A página é montada uma vez só: nada nela muda enquanto o processo vive. +PAGINA_INICIAL = ( + (PASTA_PAGINAS / "index.html") + .read_text(encoding="utf-8") + .replace("{{RUNTIME}}", html.escape(f"FastAPI {fastapi.__version__} · Python {platform.python_version()}")) + .replace("{{INICIO_ISO}}", INICIO.isoformat(timespec="seconds").replace("+00:00", "Z")) + .replace("{{INICIO}}", html.escape(INICIO_FORMATADO)) + .replace("{{MENSAGEM}}", html.escape(MENSAGEM)) +) +PAGINA_NAO_ENCONTRADA = (PASTA_PAGINAS / "nao-encontrado.html").read_text(encoding="utf-8") + +# Freio simples contra abuso: no máximo 30 links novos a cada 10 minutos por IP. +LIMITE_POR_IP = 30 +JANELA_SEGUNDOS = 10 * 60 +_tentativas: dict[str, tuple[int, float]] = {} + + +def passou_do_limite(ip: str) -> bool: + agora = time.monotonic() + if len(_tentativas) > 10_000: + _tentativas.clear() + total, expira_em = _tentativas.get(ip, (0, 0.0)) + if expira_em <= agora: + _tentativas[ip] = (1, agora + JANELA_SEGUNDOS) + return False + _tentativas[ip] = (total + 1, expira_em) + return total + 1 > LIMITE_POR_IP + + +app = FastAPI(title="Encurtador de links", description="Encurta links e conta os cliques.") + +CORS = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", +} + + +@app.middleware("http") +async def cors_so_na_api(request: Request, call_next): + """CORS aberto só na API: qualquer site (ou outro app seu) pode chamar /api/*.""" + if not request.url.path.startswith("/api/"): + return await call_next(request) + if request.method == "OPTIONS": + resposta = Response(status_code=204) + else: + resposta = await call_next(request) + resposta.headers.update(CORS) + return resposta + + +def endereco_base(request: Request) -> str: + # O app roda atrás de um proxy HTTPS, que avisa o protocolo e o host originais. + protocolo = request.headers.get("x-forwarded-proto", request.url.scheme).split(",")[0].strip() + host = request.headers.get("x-forwarded-host") or request.headers.get("host") or request.url.netloc + return f"{protocolo}://{host}" + + +def para_json(link: dict, request: Request) -> dict: + return { + "codigo": link["codigo"], + "url": link["url"], + "curto": f"{endereco_base(request)}/{link['codigo']}", + "cliques": link["cliques"], + "criadoEm": link["criadoEm"], + } + + +class NovoLink(BaseModel): + url: str | None = None + + +@app.exception_handler(RequestValidationError) +async def corpo_invalido(request: Request, erro: RequestValidationError): + return JSONResponse( + status_code=400, + content={"erro": 'Corpo inválido. Envie JSON no formato {"url": "https://..."}.'}, + ) + + +@app.get("/", response_class=HTMLResponse, include_in_schema=False) +def pagina_inicial(): + return PAGINA_INICIAL + + +@app.get("/api/links", summary="Lista os últimos 20 links") +def listar(request: Request): + return JSONResponse( + {"links": [para_json(link, request) for link in listar_links()]}, + headers={"Cache-Control": "no-store"}, + ) + + +@app.post("/api/links", status_code=201, summary="Encurta um link") +def criar(novo: NovoLink, request: Request): + ip = request.headers.get("cf-connecting-ip") or (request.client.host if request.client else "?") + if passou_do_limite(ip): + return JSONResponse( + status_code=429, + content={"erro": "Muitos links em pouco tempo. Espere alguns minutos e tente de novo."}, + ) + try: + link = criar_link(novo.url) + except ErroDeValidacao as erro: + return JSONResponse(status_code=400, content={"erro": str(erro)}) + return JSONResponse(status_code=201, content=para_json(link, request)) + + +# O redirecionamento é feito pelo servidor: GET / → 302 para o link. +# Fica por último: as rotas fixas acima têm prioridade. +@app.get("/{codigo}", include_in_schema=False) +def redirecionar(codigo: str): + link = registrar_clique(codigo) if FORMATO_CODIGO.match(codigo) else None + if link is None: + return HTMLResponse(PAGINA_NAO_ENCONTRADA, status_code=404) + return RedirectResponse(link["url"], status_code=302, headers={"Cache-Control": "no-store"}) + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", "8000"))) diff --git a/paginas/index.html b/paginas/index.html new file mode 100644 index 0000000..289b9c5 --- /dev/null +++ b/paginas/index.html @@ -0,0 +1,278 @@ + + + + + + Encurtador de links + + + + + +
+
+ +

Encurtador de links

+
+

Cole um link comprido e receba um endereço curto, pronto para compartilhar.

+ +
+
+ + + +
+ + +
+ +

Últimos links

+
+
    +

    Nenhum link ainda. Encurte o primeiro aí em cima.

    +
    + +

    + Os links ficam na memória do app e somem quando ele é publicado de novo. + Para guardar os links de vez, ligue um Postgres. +

    + +
    +

    Sobre este deploy

    +
    +
    Runtime
    +
    {{RUNTIME}}
    +
    Região
    +
    Rodando em São Paulo
    +
    No ar desde
    +
    +
    Mensagem
    +
    {{MENSAGEM}}
    +
    +

    + A mensagem vem da variável MENSAGEM_BOAS_VINDAS. Troque o valor nas + configurações do app, no painel, e publique de novo. +

    +
    + + +
    + + + + diff --git a/paginas/nao-encontrado.html b/paginas/nao-encontrado.html new file mode 100644 index 0000000..3d8d054 --- /dev/null +++ b/paginas/nao-encontrado.html @@ -0,0 +1,32 @@ + + + + + + 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/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9ad2ce5 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +fastapi==0.156.8 +uvicorn==0.54.0