Ações SSR: confirmação, flash e redirect-back¶
Todo painel SSR repete o mesmo fluxo em cada ação:
- a pessoa clica em Remover e o navegador pergunta "Remover o bucket fotos?";
- o
POSTroda; deu certo →303para a tela com "Bucket removido."; - deu errado por regra de negócio →
303de volta para a tela de onde veio, com "O bucket ainda tem objetos."; - a mesma exceção, chamada pela API, continua respondendo o JSON de sempre.
Cada passo tem uma armadilha de segurança: script inline para a pergunta
(e um nome com aspas vira código), texto do aviso na URL (e um link
forjado põe palavras na tela), Referer seguido às cegas (e o painel vira
open redirect). Esta receita mostra as peças do SDK que já resolvem as
três, para você não reescrever nenhuma.
Quando usar esta receita
- Seu serviço serve HTML com a camada UI e tem formulários que mudam estado.
- Você quer confirmar ação destrutiva sem escrever JavaScript.
- Você quer "ação falhou → volta para a tela com aviso" sem montar
cookie,
Referere exception handler na mão.
O exemplo completo¶
Um painel de buckets: listar, e remover só o bucket vazio.
from collections.abc import Sequence
from typing import ClassVar
from fastapi import FastAPI, Request
from fastapi.responses import RedirectResponse, Response
from pydantic import BaseModel, Field
from tempest_core import Text, Widget
from tempest_fastapi_sdk import ConflictException, register_exception_handlers
from tempest_fastapi_sdk.ssr import (
FlashMiddleware,
flash,
get_flashes,
html_response,
make_htmx_router,
redirect_back,
register_html_error_handlers,
)
from tempest_fastapi_sdk.ui import app_stylesheet
from tempest_fastapi_sdk.ui.components import Card, FlashMessage, FlashMessages
from tempest_fastapi_sdk.ui.css import make_css_router
from tempest_fastapi_sdk.ui.forms import form_for
from tempest_fastapi_sdk.ui.layout import Shell
from tempest_fastapi_sdk.ui.pages import ErrorPage, Page
BUCKETS: dict[str, int] = {"fotos": 3, "rascunhos": 0}
class DeleteBucketSchema(BaseModel):
"""Remover não tem campo: a ação inteira está na URL."""
class BasePage(Page):
"""Chrome do painel: folha de estilo, título e avisos."""
stylesheets: ClassVar[Sequence[str]] = ("/static/app.css",)
title_suffix: ClassVar[str] = " · Painel"
flashes: list[FlashMessage] = Field(default_factory=list)
def shell(self, body: Widget) -> Widget:
"""Mostra os avisos pendentes acima do conteúdo."""
return Shell(children=[FlashMessages(messages=self.flashes), body])
class AdminErrorPage(BasePage, ErrorPage):
"""A página de erro, com o chrome do painel."""
class BucketsPage(BasePage):
"""Lista de buckets, cada um com o seu botão de remover."""
buckets: dict[str, int]
def body(self) -> Widget:
"""Um card por bucket."""
return Card(
title="Buckets",
children=[
Card(
title=name,
children=[
Text(content=f"{objects} objetos", tag="p"),
form_for(
DeleteBucketSchema,
action=f"/admin/buckets/{name}/delete",
submit_label="Remover",
confirm=f"Remover o bucket {name}?",
id_prefix=f"delete-{name}",
),
],
)
for name, objects in self.buckets.items()
],
)
app: FastAPI = FastAPI()
app.add_middleware(FlashMiddleware, secret="troque-por-um-segredo-das-settings")
app.include_router(make_htmx_router())
app.include_router(make_css_router(app_stylesheet()))
register_exception_handlers(app)
register_html_error_handlers(app, prefixes=["/admin"], error_page=AdminErrorPage)
@app.get("/admin/buckets")
async def list_buckets(request: Request) -> Response:
"""A tela: lê os avisos uma vez e os entrega à página."""
return html_response(
BucketsPage(title="Buckets", buckets=BUCKETS, flashes=get_flashes(request)),
)
@app.post("/admin/buckets/{name}/delete")
async def delete_bucket(request: Request, name: str) -> RedirectResponse:
"""A ação: falha de regra vira exceção; sucesso vira aviso."""
if BUCKETS.get(name):
raise ConflictException(f"O bucket {name} ainda tem objetos.")
BUCKETS.pop(name, None)
flash(request, f"Bucket {name} removido.", "success")
return redirect_back(request, fallback="/admin/buckets", allowed_prefix="/admin")
Rodando esse app com o TestClient (em https://testserver), o que sai:
GET /admin/buckets 200 <title>Buckets · Painel</title>
<link rel="stylesheet" href="/static/app.css">
<script src="/_ssr/confirm.js" defer></script>
<form method="post" action="/admin/buckets/fotos/delete"
class="tui-form" data-confirm="Remover o bucket fotos?">
POST /admin/buckets/fotos/delete 303 location: /admin/buckets (set-cookie: tempest_flash=...)
GET /admin/buckets 200 <div class="tui-alert tui-alert--error" role="alert">
<p>O bucket fotos ainda tem objetos.</p>
(set-cookie: tempest_flash=""; Max-Age=0 — lido, apagado)
POST /admin/buckets/rascunhos/delete 303 location: /admin/buckets
GET /admin/buckets 200 <div class="tui-alert tui-alert--success" role="status">
<p>Bucket rascunhos removido.</p>
GET /admin/buckets/fotos/delete 405 <title>Erro 405 · Painel</title>
GET /api/nada 404 application/json {"detail":"Not Found"}
Nenhuma rota passou title=, stylesheets= nem escreveu uma linha de
JavaScript.
Peça por peça¶
Confirmar antes de remover¶
from pydantic import BaseModel
from tempest_fastapi_sdk.ui.forms import form_for
class DeleteBucketSchema(BaseModel):
"""Sem campos."""
form = form_for(
DeleteBucketSchema,
action="/admin/buckets/fotos/delete",
submit_label="Remover",
confirm="Remover o bucket fotos?",
)
confirm= põe data-confirm="..." no <form>. O html_response vê o
atributo no documento e inclui /_ssr/confirm.js, servido pelo mesmo
make_htmx_router() que já serve o HTMX. O script escuta submit no
documento inteiro, lê o texto com getAttribute e chama
window.confirm; na resposta "Cancelar", o submit não acontece.
- O texto é dado, nunca código. Ele é valor de atributo, escapado pelo
renderer, e o script o lê de volta — nada é interpolado em JavaScript.
confirm=f"Remover {name}?"com um nome cheio de aspas continua sendo só uma pergunta. - Sem JavaScript, o form funciona. O atributo é inerte: o
POSTacontece, só sem a pergunta. - CSP amigável. O script vem da própria aplicação;
script-src 'self'basta, sem'unsafe-inline'.
Fora de um form_for, use confirm() — ele devolve o mesmo atributo
para espalhar em attrs= de um botão ou de um link:
from tempest_core import Button, Text
from tempest_fastapi_sdk.ssr import confirm
button = Button(label="Remover tudo", attrs=confirm("Remover todos os buckets?"))
link = Text(
content="Sair",
tag="a",
attrs={"href": "/logout", **confirm("Encerrar a sessão?")},
)
Num botão de submit, a pergunta do botão vale no lugar da do form — dá para ter "Salvar" sem pergunta e "Remover" com pergunta no mesmo form.
Quando o script entra na página
html_response(confirm=None) (o padrão) inclui o script quando o
documento tem data-confirm ou quando htmx=True: uma troca do
HTMX pode trazer um form com pergunta para uma página que não tinha
nenhum, e o listener delegado no documento já cobre o fragmento que
chegou. confirm=True força, confirm=False tira.
Elemento que dispara request HTMX
Para hx-post/hx-delete, prefira htmx(confirm="..."), que vira
hx-confirm e é lido pelo próprio HTMX.
Avisos de uma leitura só (flash)¶
from fastapi import FastAPI, Request
from fastapi.responses import RedirectResponse
from tempest_fastapi_sdk.ssr import FlashMiddleware, flash
app: FastAPI = FastAPI()
app.add_middleware(FlashMiddleware, secret="troque-por-um-segredo-das-settings")
@app.post("/admin/buckets")
async def create_bucket(request: Request) -> RedirectResponse:
flash(request, "Bucket criado.", "success")
return RedirectResponse("/admin/buckets", status_code=303)
flash(request, texto, variante)enfileira o aviso. A variante é a mesma doAlert:"info","success","warning"ou"error".FlashMiddlewaregrava a fila num cookie na resposta e, na próxima requisição, decodifica — só se a assinatura HMAC-SHA256 confere e o cookie tem menos demax_agesegundos (padrão 300).get_flashes(request)devolve os avisos pendentes e os marca como lidos; o middleware apaga o cookie nessa resposta, então recarregar a página não repete o aviso. Uma tela que não chamaget_flashesdeixa o cookie como está, e o aviso espera a próxima que chamar.FlashMessages(messages=...)renderiza umAlertpor aviso. Sem avisos, o wrapper sai vazio e a folha do SDK o esconde.
O texto nunca passa pela URL, então um link forjado não põe palavras na tela; e um cookie forjado falha a assinatura e é apagado.
Assinado, não criptografado
Quem tem o cookie consegue ler o texto (é base64). Isso é uma escolha: o aviso é para a própria pessoa ler. Nunca ponha num flash o que ela não pode ver.
Detalhes técnicos do cookie
HttpOnly,SameSite=Lax(sobrevive ao303de nível superior depois do form),Securepor padrão. Emhttp://de desenvolvimento passesecure=False: cookieSecurenão volta por HTTP puro.- O segredo precisa de 16 caracteres ou mais; a assinatura usa um contexto fixo, então o mesmo segredo usado em outro lugar não produz um cookie de flash válido.
- Cada aviso é cortado em
MAX_FLASH_MESSAGE_LENGTH(500) caracteres, e a fila, emMAX_FLASH_COOKIE_BYTES(3800) bytes: o que não cabe perde os mais antigos, para o aviso mais recente sempre chegar. flasheget_flashessem o middleware levantamRuntimeError: um aviso enfileirado sem ele se perderia em silêncio.
Voltar para a origem sem open redirect¶
from fastapi import FastAPI, Request
from fastapi.responses import RedirectResponse
from tempest_fastapi_sdk.ssr import redirect_back
app: FastAPI = FastAPI()
@app.post("/admin/buckets/{name}/delete")
async def delete_bucket(request: Request, name: str) -> RedirectResponse:
return redirect_back(request, fallback="/admin/buckets", allowed_prefix="/admin")
redirect_back segue o Referer só quando ele aponta para este host e
para um path sob allowed_prefix; o Location enviado é sempre o path
relativo, nunca o header como chegou. O resto vai para fallback:
Referer |
Resultado |
|---|---|
https://testserver/admin/buckets?page=2 |
/admin/buckets?page=2 |
| ausente | fallback |
https://evil.example/admin (outro host) |
fallback |
https://testserver@evil.example/admin (user info) |
fallback |
/admin/buckets (sem host) |
fallback |
//evil.example/admin (protocol-relative) |
fallback |
javascript:..., data:..., ftp://... |
fallback |
https://testserver/public (fora do prefixo) |
fallback |
https://testserver/administrator (prefixo sem fronteira) |
fallback |
https://testserver//evil.example/x, .../%2Fevil.example |
fallback |
https://testserver/admin/../public, .../%2e%2e/..., \ |
fallback |
Cada linha é um caso de tests/ssr/test_redirects.py. Precisa só do URL,
sem a resposta? back_url(request, allowed_prefix=...) devolve o path
validado ou None.
A exceção vira página ou aviso¶
from fastapi import FastAPI
from tempest_fastapi_sdk import register_exception_handlers
from tempest_fastapi_sdk.ssr import FlashMiddleware, register_html_error_handlers
app: FastAPI = FastAPI()
app.add_middleware(FlashMiddleware, secret="troque-por-um-segredo-das-settings")
register_exception_handlers(app)
register_html_error_handlers(app, prefixes=["/admin"])
register_html_error_handlers embrulha os handlers de AppException e de
HTTPException que já estão registrados — por isso vem depois do
register_exception_handlers. O handler JSON roda sempre (log, tradução
pelo catálogo, on_server_error), e só a forma da resposta muda:
| Requisição | Resposta |
|---|---|
rota HTML, GET/HEAD |
error_page com o mesmo status e a mensagem |
| rota HTML, outro método | flash "error" + 303 via redirect_back |
| qualquer outra | o JSON de sempre, intacto |
- Rota HTML é a que casa um dos
prefixes("/admin"casa/admine/admin/..., não/administrator) ou tem uma dastags=do router. Path desconhecido não tem rota, então só o prefixo o reconhece. error_page=recebe a classe da página. CombineErrorPagecom a sua página-base —class AdminErrorPage(BasePage, ErrorPage)— e ela herda chrome, stylesheet e sufixo do título. O título vem do atributo de classetitle_template,"Erro {status_code}"por padrão.fallback=é o destino quando oRefereré recusado; o padrão é o próprio prefixo casado.- Sem
FlashMiddlewareo aviso se perderia, então oPOSTrecebe a página de erro no lugar do redirect.
Recap¶
form_for(..., confirm="...")ouconfirm("...")pergunta antes da ação; o script é local, o texto é dado, e sem JavaScript tudo funciona.FlashMiddleware+flash+get_flashes+FlashMessageslevam um aviso através do redirect num cookie assinado, lido uma vez.redirect_backvolta para a origem só quando ela é deste host e deste prefixo, e sempre comLocationrelativo.register_html_error_handlersfaz a mesma exceção virar página de erro, aviso com redirect ou JSON, conforme quem pediu.stylesheets,headetitle_suffixna página-base tiram esses argumentos de toda rota — veja a camada UI.