Nesta aula as Trilhas da Ilha ganham páginas HTML geradas no servidor com Thymeleaf. São templates que abrem no navegador mesmo sem servidor, com layout compartilhado, listagem paginada, página de detalhe e um formulário com validação, erros ao lado de cada campo, o padrão Post/Redirect/Get e proteção contra CSRF.
Uma API serve a programas. Pessoas precisam de páginas. Existem dois grandes caminhos para construí-las. Em um, o navegador baixa um aplicativo JavaScript (React, Angular, Vue) que chama a API e monta a tela. No outro, o servidor gera o HTML pronto e o navegador só o exibe. O segundo é o modelo original da web e voltou a ganhar força, porque é mais simples, rápido no primeiro carregamento, amigável a buscadores e exige uma linguagem a menos. No mundo Java, o motor de templates mais usado para isso é o Thymeleaf.
O que um motor de templates faz, e por que isso é segurança
Um template é um HTML com lacunas, e o motor preenche as lacunas com dados. A forma ingênua de fazer isso é concatenar strings: "<li>" + trilha.nome() + "</li>". Funciona, até o dia em que alguém cadastra uma trilha chamada <script>...</script>. Esse texto vira código na página de todo visitante: é o XSS (cross-site scripting), que permite roubar sessões e agir em nome da vítima.
O Thymeleaf tem uma característica que o protege por construção: ele trabalha sobre a árvore do documento, e não sobre texto. Para ver isso por dentro, vale escrever um motor em miniatura com o parser XML do próprio JDK. Ele entende quatro atributos no estilo do Thymeleaf:
import org.w3c.dom.Attr;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.transform.OutputKeys;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import java.io.ByteArrayInputStream;
import java.io.StringWriter;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/*
* Um motor de templates "natural" em miniatura: o template é HTML válido, que abre no navegador
* sozinho; os atributos th:* dizem como trocar o conteúdo de exemplo pelos dados de verdade.
* O motor trabalha sobre a ÁRVORE do documento, não sobre texto: é isso que o torna seguro.
*/
static final Pattern EXPRESSAO = Pattern.compile("\\$\\{([^}]+)}");
String processar(String template, Map<String, Object> modelo) throws Exception {
var fabrica = DocumentBuilderFactory.newInstance();
fabrica.setNamespaceAware(false);
Document doc = fabrica.newDocumentBuilder().parse(new ByteArrayInputStream(template.getBytes(StandardCharsets.UTF_8)));
processarElemento(doc.getDocumentElement(), new HashMap<>(modelo));
var saida = new StringWriter();
var t = TransformerFactory.newInstance().newTransformer();
t.setOutputProperty(OutputKeys.METHOD, "html");
t.transform(new DOMSource(doc), new StreamResult(saida));
return saida.toString().strip();
}
void processarElemento(Element el, Map<String, Object> vars) {
el.removeAttribute("xmlns:th");
// th:each — repete o elemento para cada item, com uma variável nova no escopo
if (el.hasAttribute("th:each")) {
String[] partes = el.getAttribute("th:each").split(":", 2); // "t : ${trilhas}"
String nomeDaVariavel = partes[0].strip();
Collection<?> itens = (Collection<?>) avaliar(partes[1].strip(), vars);
el.removeAttribute("th:each");
Node pai = el.getParentNode();
for (Object item : itens) {
Element copia = (Element) el.cloneNode(true);
pai.insertBefore(copia, el);
var escopo = new HashMap<>(vars);
escopo.put(nomeDaVariavel, item);
processarElemento(copia, escopo);
}
pai.removeChild(el); // o original era só o molde
return;
}
// th:if — o elemento só fica se a condição for "verdadeira"
if (el.hasAttribute("th:if")) {
boolean fica = verdadeiro(avaliar(el.getAttribute("th:if"), vars));
el.removeAttribute("th:if");
if (!fica) { el.getParentNode().removeChild(el); return; }
}
// th:text — troca o conteúdo de exemplo por um NÓ DE TEXTO (nunca por marcação)
if (el.hasAttribute("th:text")) {
Object valor = avaliar(el.getAttribute("th:text"), vars);
el.removeAttribute("th:text");
el.setTextContent(valor == null ? "" : valor.toString());
}
// th:href, th:src, th:value... — qualquer outro th:x vira o atributo x
for (Attr a : atributos(el)) {
if (a.getName().startsWith("th:")) {
el.removeAttribute(a.getName());
el.setAttribute(a.getName().substring(3), String.valueOf(avaliar(a.getValue(), vars)));
}
}
for (Element filho : filhos(el)) processarElemento(filho, vars);
}
/** "${t.nome}" → valor; "|/trilhas/${t.id}|" → texto com substituições. */
Object avaliar(String expr, Map<String, Object> vars) {
expr = expr.strip();
if (expr.startsWith("|") && expr.endsWith("|")) {
Matcher m = EXPRESSAO.matcher(expr.substring(1, expr.length() - 1));
var sb = new StringBuilder();
while (m.find()) m.appendReplacement(sb, Matcher.quoteReplacement(String.valueOf(caminho(m.group(1), vars))));
m.appendTail(sb);
return sb.toString();
}
Matcher m = EXPRESSAO.matcher(expr);
if (!m.matches()) throw new IllegalArgumentException("expressão inválida: " + expr);
return caminho(m.group(1), vars);
}
/** "t.nome" → vars.get("t").nome(): cada ponto chama um método sem argumentos (acessores de record). */
Object caminho(String caminho, Map<String, Object> vars) {
String[] partes = caminho.strip().split("\\.");
Object atual = vars.get(partes[0]);
for (int i = 1; i < partes.length && atual != null; i++) {
try {
atual = atual.getClass().getMethod(partes[i]).invoke(atual);
} catch (ReflectiveOperationException e) {
throw new IllegalArgumentException("não sei ler '" + partes[i] + "' em " + atual.getClass().getSimpleName(), e);
}
}
return atual;
}
boolean verdadeiro(Object v) {
return switch (v) {
case null -> false;
case Boolean b -> b;
case Collection<?> c -> !c.isEmpty();
case String s -> !s.isEmpty() && !s.equals("false");
default -> true;
};
}
List<Attr> atributos(Element el) {
var lista = new ArrayList<Attr>();
for (int i = 0; i < el.getAttributes().getLength(); i++) lista.add((Attr) el.getAttributes().item(i));
return lista;
}
List<Element> filhos(Element el) {
var lista = new ArrayList<Element>();
for (Node n = el.getFirstChild(); n != null; n = n.getNextSibling()) if (n instanceof Element e) lista.add(e);
return lista;
}
// ---- Uso ----
public record Trilha(long id, String nome, String regiao) {}
void main() throws Exception {
String template = """
<html xmlns:th="http://www.thymeleaf.org">
<body>
<h1 th:text="${titulo}">Título de exemplo</h1>
<p th:if="${vazia}">Nenhuma trilha cadastrada.</p>
<ul>
<li th:each="t : ${trilhas}">
<a href="#" th:href="|/trilhas/${t.id}|" th:text="${t.nome}">Nome de exemplo</a>
(<span th:text="${t.regiao}">Região</span>)
</li>
</ul>
</body>
</html>""";
List<Trilha> trilhas = List.of(
new Trilha(1, "Lagoinha do Leste", "Sul"),
new Trilha(7, "<script>alert('roubei seu cookie')</script>", "Norte & Leste"));
IO.println(processar(template, Map.of("titulo", "Trilhas da Ilha", "trilhas", trilhas, "vazia", trilhas.isEmpty())));
// O que aconteceria montando HTML por concatenação de strings:
IO.println("--- concatenando strings:");
IO.println("<li>" + trilhas.get(1).nome() + "</li>");
}
$ java MiniThymeleaf.java
<html>
<body>
<h1>Trilhas da Ilha</h1>
<ul>
<li>
<a href="/trilhas/1">Lagoinha do Leste</a>
(<span>Sul</span>)
</li><li>
<a href="/trilhas/7"><script>alert('roubei seu cookie')</script></a>
(<span>Norte & Leste</span>)
</li>
</ul>
</body>
</html>
--- concatenando strings:
<li><script>alert('roubei seu cookie')</script></li>
A mesma entrada gerou saídas opostas. O th:text não insere marcação: ele cria um nó de texto na árvore, e quem transforma a árvore em HTML escapa <, > e & na hora de escrever. O nome malicioso aparece na tela como texto, inofensivo. A concatenação, por outro lado, entregou um <script> funcionando.
A miniatura também mostra a ideia de template natural. Abra o template original direto no navegador e você verá uma página com "Título de exemplo" e "Nome de exemplo": o navegador ignora os atributos th:* que não conhece. Um designer pode editar esse HTML com o conteúdo de exemplo, e o motor troca os exemplos pelos dados reais. Os nomes th:text, th:each, th:if e th:href são os mesmos do Thymeleaf de verdade, que acrescenta dezenas de recursos, expressões em SpEL, cache de templates compilados e uma integração profunda com o Spring MVC.
O Thymeleaf também tem o th:utext (unescaped text), que insere o valor como marcação. Ele existe para HTML confiável, gerado pelo próprio sistema. Usá-lo com texto de usuário recria o XSS que o th:text evita. Numa revisão de código, todo th:utext merece uma pergunta: de onde vem esse conteúdo?
Controllers que devolvem páginas
A dependência é uma só:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
Os templates ficam em src/main/resources/templates/, e os arquivos estáticos (CSS, imagens, JavaScript) em src/main/resources/static/. O controller muda em um ponto essencial: em vez de @RestController, que devolve dados, ele é um @Controller, que devolve o nome de uma view. O Spring procura o template com esse nome e o processa com o Model.
package br.com.exemplo.trilhas.site;
import br.com.exemplo.trilhas.ServicoDeTrilhas;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
@Controller
public class PaginasDeTrilhas {
private static final int POR_PAGINA = 20;
private final ServicoDeTrilhas servico;
public PaginasDeTrilhas(ServicoDeTrilhas servico) {
this.servico = servico;
}
@GetMapping("/")
String inicio() {
return "redirect:/trilhas";
}
@GetMapping("/trilhas")
String lista(@RequestParam(defaultValue = "") String q,
@RequestParam(defaultValue = "0") int pagina,
Model model) {
var p = PageRequest.of(Math.max(pagina, 0), POR_PAGINA, Sort.by("nome"));
model.addAttribute("pagina", servico.buscar(q.strip(), p));
model.addAttribute("q", q);
return "trilhas/lista"; // → templates/trilhas/lista.html
}
@GetMapping("/trilhas/{id}")
String detalhe(@PathVariable long id, Model model) {
model.addAttribute("trilha", servico.buscar(id));
model.addAttribute("relatos", servico.relatos(id));
return "trilhas/detalhe";
}
}
O controller reaproveita o ServicoDeTrilhas e os DTOs de "Extensão — A API das Trilhas: REST, Validação, Erros e OpenAPI". A API e o site são duas portas diferentes para o mesmo núcleo, e nenhuma regra de negócio é duplicada. O serviço ganha um método de busca simples:
// No RepositorioDeTrilhas
Page<ResumoDeTrilha> findByNomeContainingIgnoreCase(String trecho, Pageable pagina);
// No ServicoDeTrilhas
@Transactional(readOnly = true)
public Pagina<ResumoDeTrilha> buscar(String texto, Pageable pagina) {
var resultado = texto.isBlank()
? trilhas.findAllBy(pagina)
: trilhas.findByNomeContainingIgnoreCase(texto, pagina);
return Pagina.de(resultado);
}
ContainingIgnoreCase vira WHERE upper(nome) LIKE upper('%lago%'). É bom o bastante para começar, mas não ignora acentos: "agua" não encontra "Água". A busca textual em português de "Extensão — PostgreSQL: o Banco Servidor, JSONB e Arrays", com o tsvector e o ranking, é o passo seguinte natural.
Layout: um molde para todas as páginas
Toda página do site tem o mesmo cabeçalho, menu e rodapé. No Thymeleaf, o jeito idiomático de compartilhar isso é um fragmento com parâmetros: o layout recebe o título e o conteúdo de cada página. Este é o templates/fragmentos.html:
<!DOCTYPE html>
<html lang="pt-BR" xmlns:th="http://www.thymeleaf.org" th:fragment="pagina(titulo, conteudo)">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title th:replace="${titulo}">Trilhas da Ilha</title>
<link rel="stylesheet" th:href="@{/css/site.css}" href="../static/css/site.css">
</head>
<body>
<header>
<a class="marca" th:href="@{/trilhas}">Trilhas da Ilha</a>
<nav>
<a th:href="@{/trilhas}">Trilhas</a>
<a th:href="@{/sugestoes/nova}">Sugerir uma trilha</a>
</nav>
</header>
<p class="aviso" th:if="${mensagem}" th:text="${mensagem}">Mensagem de sucesso aparece aqui.</p>
<main th:replace="${conteudo}">O conteúdo de cada página entra aqui.</main>
<footer>Trilhas da Ilha · caminhe com respeito à natureza</footer>
</body>
</html>
Cada página se "encaixa" no molde, passando o próprio <title> e o próprio <main>. Este é o templates/trilhas/lista.html:
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
th:replace="~{fragmentos :: pagina(~{::title}, ~{::main})}">
<head>
<title>Trilhas — Trilhas da Ilha</title>
</head>
<body>
<main>
<h1>Trilhas</h1>
<form th:action="@{/trilhas}" method="get" role="search">
<label for="q">Buscar pelo nome</label>
<input id="q" type="search" name="q" th:value="${q}" placeholder="ex.: lagoinha">
<button>Buscar</button>
</form>
<p th:if="${pagina.itens.isEmpty()}">Nenhuma trilha encontrada.</p>
<table th:unless="${pagina.itens.isEmpty()}">
<thead>
<tr><th>Nome</th><th>Região</th><th>Distância</th><th>Dificuldade</th></tr>
</thead>
<tbody>
<tr th:each="t : ${pagina.itens}">
<td><a th:href="@{/trilhas/{id}(id=${t.id})}" th:text="${t.nome}">Lagoinha do Leste</a></td>
<td th:text="${t.regiao}">Sul</td>
<td th:text="|${#numbers.formatDecimal(t.distanciaKm, 1, 1, 'COMMA')} km|">4,5 km</td>
<td th:text="#{|dificuldade.${t.dificuldade}|}">Moderada</td>
</tr>
</tbody>
</table>
<nav class="paginas" th:if="${pagina.totalDePaginas > 1}">
<a th:if="${pagina.pagina > 0}"
th:href="@{/trilhas(q=${q}, pagina=${pagina.pagina - 1})}">← anteriores</a>
<span th:text="|Página ${pagina.pagina + 1} de ${pagina.totalDePaginas}|">Página 1 de 3</span>
<a th:if="${pagina.pagina + 1 < pagina.totalDePaginas}"
th:href="@{/trilhas(q=${q}, pagina=${pagina.pagina + 1})}">próximas →</a>
</nav>
</main>
</body>
</html>
Os tipos de expressão que aparecem aí:
| Sintaxe | O que é | Exemplo |
|---|---|---|
${...} |
variável do modelo (SpEL) | ${t.nome} chama t.nome(): records funcionam direto |
@{...} |
URL: acrescenta o context path e codifica parâmetros | @{/trilhas(q=${q}, pagina=2)} → /trilhas?q=lago&pagina=2 |
#{...} |
mensagem de messages.properties (i18n) |
#{dificuldade.FACIL} → "Fácil" |
|...| |
texto com substituições | |Página ${n} de ${total}| |
~{...} |
referência a fragmento | ~{fragmentos :: pagina(...)} |
*{...} |
campo do objeto do formulário | *{nome}, na próxima seção |
A URL com @{...} merece atenção por segurança, não só por conveniência. Os valores dos parâmetros são codificados, então uma busca por lago&admin=true vira q=lago%26admin%3Dtrue, e não um parâmetro novo. É a mesma lição do SQL injection em outra camada: dados nunca viram estrutura.
As mensagens ficam em src/main/resources/messages.properties:
dificuldade.FACIL=Fácil
dificuldade.MODERADA=Moderada
dificuldade.DIFICIL=Difícil
Um messages_en.properties com as traduções bastaria para o site aparecer em inglês a quem pedir Accept-Language: en. A tradução não mexe no template.
Formulários: sugerir uma trilha
Visitantes podem sugerir trilhas que ainda não estão no catálogo. A sugestão vai para uma tabela própria, criada por uma nova migração, V6__sugestoes.sql:
CREATE TABLE sugestao (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
nome TEXT NOT NULL CHECK (length(nome) BETWEEN 1 AND 100),
regiao TEXT NOT NULL,
descricao TEXT NOT NULL CHECK (length(descricao) BETWEEN 20 AND 2000),
email TEXT,
enviada_em TIMESTAMPTZ NOT NULL DEFAULT now()
);
O objeto do formulário é uma classe comum, com getters e setters, e não um record:
package br.com.exemplo.trilhas.site;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
/** O que o formulário "Sugerir uma trilha" envia. Mutável de propósito: ele vai e volta entre página e servidor. */
public class SugestaoForm {
@NotBlank(message = "Informe o nome da trilha.")
@Size(max = 100, message = "No máximo {max} caracteres.")
private String nome = "";
@NotBlank(message = "Escolha a região.")
private String regiao = "";
@NotBlank(message = "Conte um pouco sobre a trilha.")
@Size(min = 20, max = 2000, message = "Entre {min} e {max} caracteres.")
private String descricao = "";
@Email(message = "Esse e-mail não parece válido.")
private String email = "";
public String getNome() { return nome; }
public void setNome(String nome) { this.nome = nome; }
public String getRegiao() { return regiao; }
public void setRegiao(String regiao) { this.regiao = regiao; }
public String getDescricao() { return descricao; }
public void setDescricao(String descricao) { this.descricao = descricao; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
}
Por que uma classe? Um formulário faz uma viagem de ida e volta. Ele é exibido vazio (ou com valores iniciais), enviado, e reexibido com o que o usuário digitou quando algo está errado, para que ninguém perca a descrição de 2.000 caracteres por causa de um e-mail mal escrito. Uma classe mutável com valores padrão e acessores no estilo JavaBean é o caminho mais bem suportado pelo th:field e pelo data binding do Spring nesse ciclo. Para ler dados no template, como no ${t.nome} da listagem, records funcionam perfeitamente.
O controller do formulário:
package br.com.exemplo.trilhas.site;
import jakarta.validation.Valid;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import java.util.List;
@Controller
@RequestMapping("/sugestoes")
public class PaginasDeSugestao {
static final List<String> REGIOES = List.of("Norte", "Sul", "Leste", "Oeste", "Centro");
private final JdbcClient jdbc;
public PaginasDeSugestao(JdbcClient jdbc) {
this.jdbc = jdbc;
}
/** Disponível em TODOS os métodos deste controller: a lista do <select>. */
@ModelAttribute("regioes")
List<String> regioes() {
return REGIOES;
}
@GetMapping("/nova")
String formulario(Model model) {
model.addAttribute("sugestao", new SugestaoForm());
return "sugestoes/form";
}
@PostMapping
String enviar(@Valid @ModelAttribute("sugestao") SugestaoForm form,
BindingResult erros, // TEM de vir logo depois do objeto validado
RedirectAttributes redirecionar) {
if (!REGIOES.contains(form.getRegiao())) {
erros.rejectValue("regiao", "regiao.invalida", "Escolha uma região da lista.");
}
if (erros.hasErrors()) {
return "sugestoes/form"; // reexibe COM o que foi digitado e as mensagens
}
jdbc.sql("INSERT INTO sugestao (nome, regiao, descricao, email) VALUES (?, ?, ?, NULLIF(?, ''))")
.params(form.getNome().strip(), form.getRegiao(), form.getDescricao().strip(), form.getEmail().strip())
.update();
redirecionar.addFlashAttribute("mensagem", "Obrigado! Sua sugestão será avaliada pela equipe.");
return "redirect:/trilhas"; // Post/Redirect/Get
}
}
Há três detalhes para guardar.
BindingResultlogo depois do objeto validado. Sem ele, a validação que falha lança exceção, como na API. Com ele, o Spring entrega os erros ao método, e você decide o que fazer: aqui, reexibir o formulário. A ordem dos parâmetros é obrigatória, porque o Spring associa cadaBindingResultao parâmetro imediatamente anterior.- Validação que não cabe em anotação. A região precisa estar na lista, e
rejectValueacrescenta esse erro ao mesmoBindingResult. O<select>na página não é uma garantia: qualquer pessoa pode enviar um POST comregiao=Marteusandocurl. Toda validação do navegador é cortesia; a do servidor é a regra. @ModelAttributeem método. A lista de regiões entra no modelo de todas as requisições deste controller, inclusive na reexibição depois de um erro, que é justamente o caso fácil de esquecer.
O template do formulário, templates/sugestoes/form.html:
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
th:replace="~{fragmentos :: pagina(~{::title}, ~{::main})}">
<head><title>Sugerir uma trilha — Trilhas da Ilha</title></head>
<body>
<main>
<h1>Sugerir uma trilha</h1>
<form th:action="@{/sugestoes}" th:object="${sugestao}" method="post" novalidate>
<div class="campo" th:classappend="${#fields.hasErrors('nome')} ? 'com-erro'">
<label for="nome">Nome da trilha</label>
<input type="text" th:field="*{nome}" maxlength="100" required>
<p class="erro" th:errors="*{nome}">Mensagem de erro do nome</p>
</div>
<div class="campo" th:classappend="${#fields.hasErrors('regiao')} ? 'com-erro'">
<label for="regiao">Região</label>
<select th:field="*{regiao}" required>
<option value="">— escolha —</option>
<option th:each="r : ${regioes}" th:value="${r}" th:text="${r}">Sul</option>
</select>
<p class="erro" th:errors="*{regiao}">Mensagem de erro da região</p>
</div>
<div class="campo" th:classappend="${#fields.hasErrors('descricao')} ? 'com-erro'">
<label for="descricao">Como é a trilha?</label>
<textarea th:field="*{descricao}" rows="6" maxlength="2000" required></textarea>
<p class="erro" th:errors="*{descricao}">Mensagem de erro da descrição</p>
</div>
<div class="campo" th:classappend="${#fields.hasErrors('email')} ? 'com-erro'">
<label for="email">Seu e-mail (opcional, para avisarmos quando entrar no catálogo)</label>
<input type="email" th:field="*{email}">
<p class="erro" th:errors="*{email}">Mensagem de erro do e-mail</p>
</div>
<button type="submit">Enviar sugestão</button>
</form>
</main>
</body>
</html>
O th:object diz qual objeto o formulário edita, e cada th:field="*{nome}" faz três coisas de uma vez: gera id="nome" e name="nome" (o nome que o Spring usa no binding) e preenche value com o valor atual, que é o que torna a reexibição com os dados digitados automática. O th:errors mostra as mensagens daquele campo, e some quando não há erro. O novalidate desliga a validação do navegador nesta demonstração, para você ver as mensagens do servidor. Em produção, deixe as duas: a do navegador responde mais rápido, e a do servidor é a que vale.
Post/Redirect/Get: o F5 que não duplica
Imagine responder ao POST com a página "Obrigado!" diretamente. O usuário aperta F5, o navegador pergunta "reenviar o formulário?", o usuário confirma sem ler, e a sugestão é gravada duas vezes. Esse bug existe desde os primeiros formulários da web, e a solução também tem décadas: o padrão Post/Redirect/Get.
- O navegador envia o
POST /sugestoes. - O servidor grava e responde com um redirecionamento (
302 Found, comLocation: /trilhas), e não com uma página. - O navegador faz um
GET /trilhas, e é essa página que fica no histórico. Um F5 repete o GET, que é inofensivo.
O return "redirect:/trilhas" produz o passo 2. Como a mensagem "Obrigado!" sobrevive ao redirecionamento, se a requisição que a criou já terminou? Esse é o papel do atributo flash: addFlashAttribute guarda o valor na sessão por uma requisição. O GET seguinte o recebe no modelo (e o layout o mostra no <p class="aviso">), e logo depois ele é apagado. Um segundo F5 já não mostra a mensagem.
Repare que o POST com erros não redireciona: ele devolve a página do formulário diretamente, porque os dados digitados e as mensagens precisam estar ali. Não há risco de duplicar, porque nada foi gravado.
CSRF: agora com cookies
Em "Extensão — Quem É Você? Spring Security e JWT" desligamos a proteção CSRF da API, e explicamos por quê: sem cookie, não há CSRF. O site é diferente. Na próxima aula ele terá login com cookie de sessão, e mesmo antes disso o formulário é um alvo, porque um site malicioso poderia fazer navegadores alheios enviarem sugestões falsas aos milhares.
A proteção funciona assim. O servidor gera um token imprevisível, guardado na sessão do usuário, e exige que todo POST, PUT e DELETE o devolva. Um site de outra origem não tem como ler esse token, e por isso não consegue forjar a requisição. A boa notícia é que você quase não precisa escrever nada. Com o Spring Security presente, todo formulário com th:action e method="post" ganha sozinho um campo escondido (os de GET, como a busca, ficam sem ele, e não precisam):
<input type="hidden" name="_csrf" value="Zu-vRp4JR-Sp-OkOQIFEpSADaAGzgP_28l5cQSjljmEzRbdYVdqedq06cIKEzN1td6xwkEY0RWOAtcbbkGZuJx-A71FQJ4Y9"/>
O valor muda a cada página. É o token da sessão misturado (XOR) a bytes aleatórios e codificado em Base64, para que ele não possa ser deduzido pelo tamanho de respostas comprimidas (o ataque BREACH). Quem confere desfaz a mistura e compara com o token guardado.
Esse é mais um motivo para usar th:action="@{...}" em vez de action="...": sem o th:, o token não é inserido, e o POST recebe 403.
A cadeia de segurança do site substitui a cadeia "resto", que negava tudo, da aula de segurança. A cadeia da API, @Order(1), continua intacta:
// Em ConfiguracaoDeSeguranca, no lugar da cadeia "resto"
@Bean
@Order(2)
SecurityFilterChain site(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(regras -> regras
.requestMatchers("/", "/trilhas", "/trilhas/*", "/sugestoes/**").permitAll()
.requestMatchers("/css/**", "/js/**", "/img/**", "/favicon.ico").permitAll()
.requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll()
.requestMatchers("/error").permitAll()
.anyRequest().denyAll());
// CSRF fica LIGADO (é o padrão): não há nenhuma linha desligando
return http.build();
}
A linha do /error é fácil de esquecer. Quando algo dá errado, o Spring Boot encaminha internamente para /error para montar a página de erro. Se esse endereço estiver negado, o usuário recebe um 403 no lugar do 404 ou do 500, e a depuração vira um mistério.
Erros do site são páginas, não JSON
O TratadorDeErros da API é um @RestControllerAdvice e, sem restrição, atenderia todos os controllers, inclusive os do site: um visitante que abrisse /trilhas/999 receberia um JSON de Problem Details. A solução é delimitar o alcance de cada tratador:
@RestControllerAdvice(basePackages = "br.com.exemplo.trilhas.api")
public class TratadorDeErros extends ResponseEntityExceptionHandler { /* ...como antes... */ }
package br.com.exemplo.trilhas.site;
import br.com.exemplo.trilhas.TrilhaNaoEncontrada;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
@ControllerAdvice(basePackages = "br.com.exemplo.trilhas.site")
public class ErrosDoSite {
@ExceptionHandler(TrilhaNaoEncontrada.class)
@ResponseStatus(HttpStatus.NOT_FOUND) // o status continua sendo 404: buscadores e caches dependem disso
String trilhaNaoEncontrada() {
return "error/404"; // → templates/error/404.html, no mesmo layout
}
}
Para os outros erros, o Spring Boot procura automaticamente templates/error/404.html, templates/error/500.html e assim por diante. Por isso o tratador acima devolve error/404: a trilha que não existe e o endereço que não existe mostram a mesma página. Crie essas páginas com o layout do site, e nenhuma página de erro genérica e sem estilo chegará ao visitante.
Testando páginas
O @WebMvcTest serve às páginas como serviu à API. Com páginas, dá para conferir o nome da view, o modelo e o HTML gerado:
package br.com.exemplo.trilhas.site;
import br.com.exemplo.trilhas.seguranca.ConfiguracaoDeSeguranca;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.security.test.context.support.WithAnonymousUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
@WebMvcTest(controllers = PaginasDeSugestao.class,
properties = "trilhas.jwt.segredo=AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=")
@Import(ConfiguracaoDeSeguranca.class) // as cadeias de verdade, como em RegrasDeAcessoTest
@WithAnonymousUser
class PaginasDeSugestaoTest {
@Autowired MockMvcTester mvc;
@MockitoBean(answers = org.mockito.Answers.RETURNS_DEEP_STUBS) JdbcClient jdbc;
@Test
void formularioInvalidoVoltaComOsDadosEAsMensagens() {
var resposta = mvc.post().uri("/sugestoes").with(csrf())
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.formField("nome", "Trilha do Farol")
.formField("regiao", "Marte")
.formField("descricao", "curta")
.exchange();
assertThat(resposta).hasStatusOk().hasViewName("sugestoes/form");
assertThat(resposta).model().extractingBindingResult("sugestao")
.hasFieldErrors("regiao", "descricao");
assertThat(resposta).bodyText()
.contains("value=\"Trilha do Farol\"") // o que foi digitado voltou
.contains("Escolha uma região da lista.");
verifyNoInteractions(jdbc); // nada foi gravado
}
@Test
void semTokenCsrfOPostERecusado() {
assertThat(mvc.post().uri("/sugestoes")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.formField("nome", "Qualquer"))
.hasStatus4xxClientError();
}
@Test
void envioValidoRedirecionaComMensagem() {
assertThat(mvc.post().uri("/sugestoes").with(csrf())
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.formField("nome", "Trilha do Farol")
.formField("regiao", "Sul")
.formField("descricao", "Sobe pelo costão até o farol, com vista para a baía."))
.hasStatus3xxRedirection()
.hasRedirectedUrl("/trilhas")
.flash().containsKey("mensagem");
}
}
O @Import não é opcional. O @WebMvcTest não carrega classes @Configuration, e sem a ConfiguracaoDeSeguranca valeria a segurança padrão do Spring Boot, que exige login em tudo: o visitante anônimo seria recusado, e o primeiro e o terceiro testes falhariam sem chegar ao controller. O csrf() do Spring Security Test injeta um token válido no POST. O segundo teste, sem ele, prova que a proteção está ativa: se alguém a desligar por engano, o teste fica vermelho. O RETURNS_DEEP_STUBS deixa o dublê do JdbcClient responder à cadeia sql(...).params(...).update() sem configurar cada elo. É um atalho aceitável em teste de controller, porque o SQL em si foi testado contra o PostgreSQL de verdade.
O site já tem cara: páginas montadas no servidor com escape automático, um formulário que volta com o que foi digitado, um POST que não se repete no F5 e um token que impede outro site de enviar em nome do visitante. Cada clique ainda recarrega a página inteira, e a área de administração, que exige login, ainda não existe.
Fontes e leituras recomendadas
- Tutorial: Thymeleaf + Spring: formulários,
th:field,#fieldse a integração com o Spring MVC. - Thymeleaf — documentação: referência completa das expressões e dos atributos, incluindo layouts com fragmentos parametrizados.
- OWASP — Cross Site Scripting Prevention Cheat Sheet: por que o escape depende do contexto (texto, atributo, URL, script).
- OWASP — Cross-Site Request Forgery Prevention Cheat Sheet: o token sincronizador, os cookies
SameSitee o que não funciona. - Spring Security — Cross Site Request Forgery: como o Spring gera, guarda e confere o token.
- Post/Redirect/Get — Wikipedia: a história e o diagrama do padrão.
Exercícios
Exercício 1
Um colega trocou th:text="${t.nome}" por th:utext="${t.nome}" na listagem, "porque alguns nomes têm & e estavam aparecendo como &". O diagnóstico dele está certo? O que você responderia na revisão?
Ver resposta
✓ Resposta: O diagnóstico está errado. & no código-fonte do HTML é a forma correta de escrever &, e o navegador mostra & na tela. Se a tela mostra literalmente &, o nome foi escapado duas vezes, provavelmente porque alguém gravou o nome já escapado no banco. A correção é gravar o texto puro e escapar só na saída, uma vez. Trocar por th:utext "resolve" o sintoma e abre um XSS, porque qualquer trilha cadastrada com <script> passaria a executar na página de todos os visitantes.
Exercício 2
No PaginasDeSugestao, o que acontece se você trocar a ordem dos parâmetros para enviar(@Valid @ModelAttribute("sugestao") SugestaoForm form, RedirectAttributes redirecionar, BindingResult erros)?
Ver resposta
✓ Resposta: Ao montar o formulário, o Spring olha o parâmetro seguinte para decidir se pode entregar os erros ao método. Agora ele é um RedirectAttributes, e não um BindingResult. Com o formulário inválido, a validação lança MethodArgumentNotValidException antes de o método rodar, e o usuário recebe um 400 genérico em vez do formulário reexibido com as mensagens. Com o formulário válido, tudo parece funcionar: o BindingResult é procurado como o último atributo do modelo, e o RedirectAttributes não entra nesse modelo. É o pior tipo de defeito, que só aparece quando o usuário erra, e é o caso que o teste do formulário inválido pega. A regra é: o BindingResult vem sempre colado ao objeto que ele descreve.
Exercício 3
Explique, passo a passo, o que o navegador faz depois do envio válido de uma sugestão, e o que acontece se o usuário apertar F5 duas vezes na página seguinte.
Ver resposta
✓ Resposta: O navegador envia POST /sugestoes. O servidor grava, guarda a mensagem como atributo flash na sessão e responde 302 com Location: /trilhas. O navegador segue o redirecionamento com um GET /trilhas. O servidor tira a mensagem do flash, coloca no modelo, a apaga da sessão e devolve a lista com o aviso "Obrigado!". No primeiro F5, o navegador repete o GET (o POST não está mais no topo do histórico): a lista reaparece, sem o aviso, porque o flash já foi consumido. O segundo F5 faz o mesmo. Nenhuma sugestão duplicada.
Exercício 4
Acrescente ao formulário um campo "dificuldade estimada" (um <select> com Fácil, Moderada e Difícil), reaproveitando o enum Dificuldade e as mensagens de messages.properties.
Ver resposta
✓ Resposta: No SugestaoForm, acrescente @NotNull(message = "Escolha a dificuldade.") private Dificuldade dificuldade; com getter e setter (o Spring converte "MODERADA" no enum sozinho). No controller, @ModelAttribute("dificuldades") Dificuldade[] dificuldades() { return Dificuldade.values(); }. No template: <select th:field="*{dificuldade}"><option value="">— escolha —</option><option th:each="d : ${dificuldades}" th:value="${d}" th:text="#{|dificuldade.${d}|}">Fácil</option></select>, mais o <p th:errors="*{dificuldade}">. Na migração, uma nova coluna dificuldade TEXT CHECK (dificuldade IN ('FACIL','MODERADA','DIFICIL')), em uma nova migração V7, nunca editando a V6 já aplicada.
Exercício 5
Um visitante mal-intencionado envia, via curl, mil sugestões por minuto, todas válidas. CSRF não ajuda aqui. Por quê? Cite duas defesas.
Ver resposta
✓ Resposta: CSRF protege o usuário contra requisições forjadas em nome dele por outro site. Aqui o atacante age em nome próprio e pode simplesmente buscar o formulário, ler o token da página e enviar o POST com ele, porque o token não é segredo para quem o recebeu legitimamente. As defesas são contra abuso: limitar a taxa por IP no nginx ou em um filtro (por exemplo, cinco sugestões por hora); um campo "armadilha" (honeypot) invisível que humanos deixam vazio e robôs preenchem; um CAPTCHA a partir de certo volume; e moderação antes da publicação, que já existe aqui, porque sugestões não aparecem no site sem aprovação.