Até aqui a aplicação só lê dados. Nesta atividade você implementa o C do CRUD pela web: um formulário protegido por login que cadastra novos produtos no banco, com validação no servidor e proteção contra requisições forjadas.
Pré-requisito:Nota 5 funcionando (login e rota protegida)
Resultado esperado: página /produtos/novo que grava um produto, redireciona para a lista em /produtos e recusa dados inválidos com mensagens claras
Fecha o ciclo:Create pela web (Update e Delete ficam como desafio)
Conceitos antes do código
O caminho de uma requisição
Etapa
Rota
O que acontece
1
GET /produtos/novo
O servidor exibe o formulário e emite um token CSRF
2
POST /produtos
O navegador envia os dados e o token
3
(servidor)
Confere o token, valida os campos e executa o INSERT
4
redirecionamento
Se deu certo, envia o navegador para GET /produtos
5
GET /produtos
Exibe a lista com o aviso de sucesso
O passo 4 é o padrão Post/Redirect/Get. Sem ele, apertar F5 depois do cadastro reenviaria o formulário e criaria um produto duplicado.
Validação: navegador ajuda, servidor decide
Atributos como required e min="0" melhoram a experiência, mas qualquer pessoa pode ignorá-los (pelas ferramentas do desenvolvedor ou com curl). Por isso toda regra é verificada de novo no servidor.
CSRF em uma frase
Um ataque Cross-Site Request Forgery faz o navegador de uma pessoa logada enviar uma requisição que ela não pretendia fazer. A defesa usada aqui é o token duplo: um valor aleatório vai em um cookie assinado e também em um campo oculto do formulário. Um site malicioso não consegue ler o campo oculto, então só o formulário legítimo envia os dois valores iguais.
Passo 1: regras de validação (validacao.py)
Mantenha a validação em uma função simples, sem depender do Bottle. Assim ela pode ser testada isoladamente.
import mathdef validar_produto(campos: dict, ids_categoria_validos: set[int]): """ Valida os campos recebidos do formulário (todos como texto). Devolve (dados, erros): se houver erros, 'dados' é None. """ erros = {} nome = campos["nome"].strip() if not nome: erros["nome"] = "Informe o nome do produto." elif len(nome) > 100: erros["nome"] = "O nome deve ter no máximo 100 caracteres." preco = None try: preco = float(campos["preco"].strip().replace(",", ".")) if not math.isfinite(preco) or preco < 0: raise ValueError except ValueError: erros["preco"] = "Informe um preço válido, igual ou maior que zero (ex.: 49,90)." estoque = None try: estoque = int(campos["estoque"].strip()) if estoque < 0: raise ValueError except ValueError: erros["estoque"] = "Informe o estoque como um número inteiro, igual ou maior que zero." id_categoria = None try: id_categoria = int(campos["id_categoria"]) if id_categoria not in ids_categoria_validos: raise ValueError except ValueError: erros["id_categoria"] = "Selecione uma categoria da lista." if erros: return None, erros return { "nome": nome, "preco": round(preco, 2), "estoque": estoque, "id_categoria": id_categoria, }, {}
Por que math.isfinite?
O Python aceita float("nan") e float("inf") como números válidos. Sem essa verificação, alguém poderia cadastrar um preço “infinito”.
Teste rápido, sem servidor
Crie um arquivo testar_validacao.py e execute com python testar_validacao.py:
from validacao import validar_produtoCATEGORIAS = {1, 2, 3}# Caso válido (vírgula decimal e espaços são aceitos)dados, erros = validar_produto( {"nome": " Mouse sem fio ", "preco": "89,90", "estoque": "20", "id_categoria": "1"}, CATEGORIAS,)assert erros == {}assert dados == {"nome": "Mouse sem fio", "preco": 89.9, "estoque": 20, "id_categoria": 1}# Preço negativo, estoque decimal e categoria inexistentedados, erros = validar_produto( {"nome": "X", "preco": "-5", "estoque": "2.5", "id_categoria": "99"}, CATEGORIAS,)assert dados is Noneassert set(erros) == {"preco", "estoque", "id_categoria"}# Preço infinito_, erros = validar_produto( {"nome": "X", "preco": "inf", "estoque": "1", "id_categoria": "1"}, CATEGORIAS)assert "preco" in errosprint("Todos os testes passaram.")
Passo 2: funções de banco (dados.py)
Acrescente ao final do arquivo dados.py:
def listar_categorias() -> list[dict]: """Categorias em ordem alfabética, para preencher a lista do formulário.""" with closing(abrir_conexao()) as conn: return [ dict(linha) for linha in conn.execute( "SELECT id_categoria, nome_categoria FROM Categoria ORDER BY nome_categoria;" ) ]def listar_produtos() -> list[dict]: """Produtos com o nome da categoria, do mais recente para o mais antigo.""" with closing(abrir_conexao()) as conn: return [ dict(linha) for linha in conn.execute(""" SELECT p.id_produto, p.nome, p.preco, p.estoque, c.nome_categoria FROM Produto p LEFT JOIN Categoria c ON p.id_categoria = c.id_categoria ORDER BY p.id_produto DESC; """) ]def inserir_produto(nome: str, preco: float, estoque: int, id_categoria: int) -> int: """Insere um produto e devolve o id gerado.""" with closing(abrir_conexao()) as conn: with conn: # commit automático; rollback em caso de erro cursor = conn.execute( "INSERT INTO Produto (nome, preco, estoque, id_categoria) VALUES (?, ?, ?, ?);", (nome, preco, estoque, id_categoria), ) return cursor.lastrowid
Parâmetros ?, sempre
Os valores digitados no formulário nunca são concatenados ao texto do SQL. Os marcadores ? fazem o banco tratar tudo como dado, o que elimina o SQL Injection (visto na Nota 1).
Os valores digitados voltam preenchidos quando há erro, e cada mensagem aparece junto do campo correspondente.
<!DOCTYPE html><html lang="pt-br"><head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>Novo produto</title> <link rel="stylesheet" href="/static/estilo.css"></head><body> <nav> <a href="/produtos">Produtos</a> · <a href="/relatorio/ranking">Relatório</a> · <a href="/logout">Sair</a> </nav> <h1>Novo produto</h1> <form action="/produtos" method="post"> % if 'geral' in erros: <p class="erro">{{ erros['geral'] }}</p> % end <input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <label for="nome">Nome</label> <input id="nome" type="text" name="nome" maxlength="100" required value="{{ valores.get('nome', '') }}"> % if 'nome' in erros: <p class="erro">{{ erros['nome'] }}</p> % end <label for="preco">Preço (R$)</label> <input id="preco" type="text" name="preco" inputmode="decimal" required placeholder="49,90" value="{{ valores.get('preco', '') }}"> % if 'preco' in erros: <p class="erro">{{ erros['preco'] }}</p> % end <label for="estoque">Estoque (unidades)</label> <input id="estoque" type="number" name="estoque" min="0" step="1" required value="{{ valores.get('estoque', '') }}"> % if 'estoque' in erros: <p class="erro">{{ erros['estoque'] }}</p> % end <label for="id_categoria">Categoria</label> <select id="id_categoria" name="id_categoria" required> <option value="">Selecione...</option> % for c in categorias: % marcada = ' selected' if str(c['id_categoria']) == valores.get('id_categoria', '') else '' <option value="{{ c['id_categoria'] }}"{{! marcada }}>{{ c['nome_categoria'] }}</option> % end </select> % if 'id_categoria' in erros: <p class="erro">{{ erros['id_categoria'] }}</p> % end <input type="submit" value="Cadastrar produto"> </form></body></html>
Escape automático
Os trechos {{ ... }} escapam o HTML. Se alguém cadastrar um produto chamado <script>alert(1)</script>, o navegador exibirá o texto, sem executá-lo. A forma {{! ... }} desativa o escape e foi usada apenas para o texto fixo selected, definido por nós.
Acrescente as funções e rotas abaixo antes do bloco if __name__ == "__main__"::
CAMPOS_PRODUTO = ("nome", "preco", "estoque", "id_categoria")AVISOS = {"cadastrado": "Produto cadastrado com sucesso."}def emitir_token_csrf() -> str: """Gera um token aleatório, guarda em cookie assinado e devolve para o formulário.""" token = secrets.token_hex(16) response.set_cookie( "csrf", token, secret=CHAVE_SECRETA, httponly=True, samesite="Lax", max_age=3600, path="/", ) return tokendef token_csrf_valido() -> bool: """Confere se o token do campo oculto é igual ao do cookie.""" esperado = request.get_cookie("csrf", secret=CHAVE_SECRETA) recebido = request.forms.getunicode("csrf_token") or "" if not esperado: return False return hmac.compare_digest(esperado.encode("utf-8"), recebido.encode("utf-8"))def pagina_formulario(valores: dict, erros: dict, categorias: list): return template( "produto_form", valores=valores, erros=erros, categorias=categorias, csrf_token=emitir_token_csrf(), )@app.get("/produtos")@requer_autenticacaodef listar(): aviso = AVISOS.get(request.query.get("ok", ""), "") return template("produtos", produtos=listar_produtos(), aviso=aviso)@app.get("/produtos/novo")@requer_autenticacaodef exibir_formulario(): return pagina_formulario({}, {}, listar_categorias())@app.post("/produtos")@requer_autenticacaodef cadastrar_produto(): if not token_csrf_valido(): abort(403, "Requisição inválida. Recarregue o formulário e tente novamente.") campos = {c: request.forms.getunicode(c) or "" for c in CAMPOS_PRODUTO} categorias = listar_categorias() ids_validos = {c["id_categoria"] for c in categorias} dados, erros = validar_produto(campos, ids_validos) if not erros: try: inserir_produto(**dados) except sqlite3.IntegrityError: erros["geral"] = "O banco de dados recusou os dados informados." if erros: response.status = 400 return pagina_formulario(campos, erros, categorias) redirect("/produtos?ok=cadastrado")
Ordem dos decoradores
Em cada rota, @app.get(...) ou @app.post(...) vem primeiro (acima) e @requer_autenticacao logo abaixo. Invertendo a ordem, a rota é registrada sem a proteção.
Inicie o servidor (com APP_SECRET definido, como na Nota 5) e acesse http://localhost:8080/produtos/novo depois de fazer login.
Resultado esperado
Cadastre Mouse sem fio, preço 89,90, estoque 20, categoria Eletrônicos. Você deve ser levado a /produtos com a mensagem de sucesso e o novo produto no topo da lista.
O ID será 7 se você seguiu a Nota 1 sem usar o script de restauração da Nota 2: o AUTOINCREMENT nunca reaproveita o id 6 da Calça Jeans excluída.
Passo 5: testar
Teste 1: pressionar F5 após o cadastro
Na página /produtos exibida depois do cadastro, aperte F5. Algum produto foi duplicado? Por quê?
Teste 2: ignorar a validação do navegador
Abra as ferramentas do desenvolvedor (F12), remova o atributo required dos campos e envie o formulário com o preço -10 e o nome vazio. O que o servidor responde?
Teste 3: remover o token CSRF
Nas ferramentas do desenvolvedor, apague o campo oculto csrf_token (ou altere o valor dele) e envie o formulário. O que acontece?
Teste 4: injeção de HTML
Cadastre um produto chamado <b>teste</b>. Como ele aparece na lista?
Teste 5: formulário sem login
Em uma janela anônima, acesse /produtos/novo. Depois, tente enviar um POST direto com curl:
curl -i -X POST http://localhost:8080/produtos -d "nome=X&preco=1&estoque=1&id_categoria=1"
Resultados esperados
Nenhum duplicado. Após o POST, o servidor redireciona, e o F5 repete apenas o GET /produtos, que só lê dados.
Resposta com status 400 e o formulário reexibido com as mensagens de erro e os valores preenchidos. A regra vale no servidor, não só no navegador.
Erro 403 (“Requisição inválida”). Sem o token que corresponde ao cookie, o servidor recusa o envio.
O texto <b>teste</b> aparece literalmente, sem negrito, porque o {{ }} escapa o HTML.
A janela anônima é redirecionada ao login. O curl também recebe um redirecionamento para /login, pois não há cookie de sessão.
Desafios
Desafio 1: nome duplicado
Impeça o cadastro de dois produtos com o mesmo nome, ignorando diferença entre maiúsculas e minúsculas. Onde a regra deve ficar: na validação, no banco ou nos dois?
Dica do desafio 1
No banco, uma restrição UNIQUE em coluna com COLLATE NOCASE garante a regra mesmo que o código falhe. Na validação, uma consulta prévia permite mostrar uma mensagem amigável. Usar as duas camadas é o mais robusto. Lembre-se de tratar a corrida entre duas requisições simultâneas capturando o IntegrityError.
Desafio 2: editar produto (Update)
Crie GET /produtos/<id:int>/editar e POST /produtos/<id:int>, reaproveitando validar_produto e o mesmo template do formulário. Como o template deve saber se está criando ou editando?
Desafio 3: excluir produto (Delete)
Acrescente um botão de exclusão em cada linha da lista, usando POST (nunca um link GET) e token CSRF. Trate o caso de um produto que já tem vendas, lembrando do experimento da Nota 2. Que mensagem o usuário deve receber?
Dica do desafio 3
O DELETE de um produto com vendas gera sqlite3.IntegrityError: FOREIGN KEY constraint failed. Capture o erro e explique: “Este produto tem vendas registradas e não pode ser excluído”. Uma alternativa de projeto é uma coluna ativo para desativar o produto sem apagar o histórico.
Desafio 4: reaproveitar o layout
Os arquivos login.tpl e ranking.tpl ainda repetem o <head> e o CSS. Crie views/base.tpl e use % rebase('base', titulo='...') para eliminar a repetição.
Desafio 5: busca e filtro
Na lista, acrescente um campo de busca por nome e um filtro por categoria, usando GET com parâmetros ? na consulta.
Erros comuns
Sintoma
Causa provável
Como corrigir
Sempre aparece o erro 403 ao enviar
Cookie csrf ausente ou outro APP_SECRET
Recarregue a página do formulário e confira o APP_SECRET
O envio falha só na primeira aba aberta
Duas abas do formulário: a segunda substituiu o cookie
Use uma aba por vez ou recarregue a primeira
NameError: CHAVE_SECRETA is not defined
Funções novas colocadas antes das constantes do app.py
Posicione-as depois das constantes e do app = Bottle()
Template 'produto_form' not found
Nome ou pasta do arquivo incorretos
Confirme views/produto_form.tpl
A página fica sem estilo
estilo.css fora de static/
Coloque o arquivo em static/estilo.css
O select não mantém a categoria após um erro
Comparação entre número e texto
Compare com str(c['id_categoria']), como no template
/produtos/novo abre sem pedir login
Decoradores na ordem errada
Deixe @app.get(...) acima de @requer_autenticacao
Checklist de verificação
/produtos/novo exige login
Um cadastro válido grava o produto e redireciona para /produtos com o aviso de sucesso
F5 após o cadastro não duplica o produto
Dados inválidos retornam status 400, com mensagens por campo e valores preservados
Envio sem token CSRF válido é recusado com 403
Nomes com HTML aparecem como texto puro na lista
O script testar_validacao.py passa sem erros
Pelo menos um dos desafios foi resolvido
Fechamento
Com este formulário, a aplicação passou a escrever dados pela web de forma segura: validação no servidor, token CSRF, consultas parametrizadas e escape de HTML. O próximo passo natural é completar o CRUD com edição e exclusão (desafios 2 e 3). Volte ao roteiro para revisar os critérios de avaliação.