Um formulário HTML é o jeito mais antigo e mais robusto de um site receber dados: funciona em qualquer navegador, com ou sem JavaScript. Neste artigo a Livraria ganha três formulários, e com eles o binding de dados postados, a validação nos dois lados, a proteção contra CSRF e o padrão que evita envios duplicados.
Três formulários, três lições
| Formulário | Verbo | Onde | Lição principal |
|---|---|---|---|
| Busca no catálogo | GET | vitrine | formulários que não alteram nada vão por GET |
| Cotação de frete | POST | página do livro | validação, reexibição com erros, handlers nomeados |
| "Avise-me quando chegar" | POST | livro esgotado | Post/Redirect/Get, consentimento, erros vindos da API |
A busca: um formulário GET
Uma busca não altera nada no servidor, então é uma leitura, e leituras são GET, pela mesma semântica de "A API da Livraria". Um formulário GET coloca os campos na query string:
<form method="get" role="search" class="busca">
<label asp-for="Termo">Buscar livros</label>
<input asp-for="Termo" type="search" placeholder="título ou autor" />
<button type="submit">Buscar</button>
</form>
public sealed class IndexModel(ICatalogoDaLoja catalogo) : PageModel
{
[BindProperty(SupportsGet = true)]
[StringLength(100)]
public string? Termo { get; set; }
public PaginaDe<ProdutoResposta> Vitrine { get; private set; } = default!;
public async Task OnGetAsync(int pagina = 1, CancellationToken ct = default)
{
var termo = ModelState.IsValid ? Termo?.Trim() : null;
Vitrine = await catalogo.ListarAsync(Math.Max(pagina, 1), 12, termo, ct);
}
}
O resultado é uma URL como /?Termo=machado, que pode ser compartilhada, favoritada e indexada. Um formulário POST para busca produziria uma página sem endereço, e o botão Voltar mostraria o aviso "reenviar formulário?".
[BindProperty] liga uma propriedade do PageModel aos dados da requisição. Por padrão só em POST, e SupportsGet = true estende o binding ao GET. Os links de paginação passam a carregar o termo com asp-route-Termo="@Model.Termo".
Do lado da API, GET /v1/produtos ganha o parâmetro opcional termo, e cada repositório busca com o recurso do seu banco: unaccent com ILIKE no PostgreSQL, a collation ai_ci no MySQL e o índice de texto no Mongo, todos vistos na Fase 5. É por isso que "memorias postumas" encontra "Memórias Póstumas".
O modelo de entrada da cotação
Na página do livro, o visitante informa o CEP e a quantidade e vê o frete. A entrada tem uma classe própria, com os mesmos atributos de validação de "Validação, ProblemDetails e OpenAPI":
public sealed class EntradaDeFrete
{
[Required(ErrorMessage = "Informe o CEP.")]
[RegularExpression(@"^\d{5}-?\d{3}$", ErrorMessage = "Use o formato 00000-000.")]
[Display(Name = "CEP")]
public string Cep { get; set; } = "";
[Range(1, 20, ErrorMessage = "Escolha entre 1 e 20 exemplares.")]
[Display(Name = "Quantidade")]
public int Quantidade { get; set; } = 1;
}
Aqui a classe tem setters públicos e construtor sem parâmetros, ao contrário dos records da API. O model binding de formulários do Razor Pages cria o objeto vazio e preenche propriedade por propriedade. [Display(Name = ...)] define o rótulo que os tag helpers vão gerar e o nome que aparece nas mensagens padrão.
A marcação: tag helpers de formulário
<section aria-labelledby="titulo-frete" class="frete">
<h2 id="titulo-frete">Calcule o frete</h2>
<form method="post" asp-page-handler="Cotar" asp-route-slug="@Model.Slug" novalidate>
<div asp-validation-summary="ModelOnly" role="alert"></div>
<label asp-for="Frete.Cep"></label>
<input asp-for="Frete.Cep" inputmode="numeric" autocomplete="postal-code" />
<span asp-validation-for="Frete.Cep" class="erro"></span>
<label asp-for="Frete.Quantidade"></label>
<input asp-for="Frete.Quantidade" min="1" max="20" />
<span asp-validation-for="Frete.Quantidade" class="erro"></span>
<button type="submit">Calcular</button>
</form>
@if (Model.Cotacao is { } c)
{
<p role="status">
@c.Transportadora: <strong>@c.Valor.ToString("C")</strong>,
em até @c.PrazoDiasUteis dias úteis.
</p>
}
</section>
Cada tag helper gera bastante marcação a partir de uma linha:
<label asp-for>escreve o texto do[Display]e o atributoforapontando para o id do campo. Um leitor de tela anuncia "CEP, campo de edição", e clicar no rótulo foca o campo.<input asp-for>geraid,name="Frete.Cep", otypea partir do tipo C# (intviratype="number"), o valor atual e os atributosdata-val-*que a validação no navegador lê.<span asp-validation-for>exibe a mensagem de erro daquele campo.asp-validation-summary="ModelOnly"exibe os erros que não pertencem a um campo específico, como "serviço de frete indisponível".<form method="post">acrescenta um campo oculto__RequestVerificationToken, que é o assunto da seção sobre CSRF.asp-page-handler="Cotar"faz o formulário postar para o handlerOnPostCotarAsync. Uma página pode ter vários formulários, cada um com seu handler.
Os atributos inputmode="numeric" e autocomplete="postal-code" não têm nada de ASP.NET. São HTML puro: o celular abre o teclado numérico e o navegador oferece o CEP salvo. Pequenos detalhes de formulário decidem se alguém termina uma compra no celular.
O handler: validar, reexibir, agir
public sealed class DetalheModel(ICatalogoDaLoja catalogo, ICotadorDeFrete cotador) : PageModel
{
public ProdutoResposta Produto { get; private set; } = default!;
public string Slug { get; private set; } = "";
public EntradaDeFrete Frete { get; set; } = new();
public EntradaDeAviso Aviso { get; set; } = new();
public CotacaoResposta? Cotacao { get; private set; }
[TempData]
public string? Mensagem { get; set; }
public async Task<IActionResult> OnGetAsync(int id, string? slug, CancellationToken ct)
{
// igual ao artigo anterior: carrega, 404 ou redireciona para o slug canônico
}
public async Task<IActionResult> OnPostCotarAsync(
int id,
[Bind(Prefix = nameof(Frete))] EntradaDeFrete frete,
CancellationToken ct)
{
if (!await CarregarAsync(id, ct))
return NotFound();
Frete = frete;
if (!ModelState.IsValid)
return Page();
var resultado = await cotador.CotarAsync(frete.Cep, id, frete.Quantidade, ct);
switch (resultado)
{
case ResultadoDeCotacao.Sucesso s:
Cotacao = s.Cotacao;
break;
case ResultadoDeCotacao.Invalida inv:
ModelState.AddModelError("Frete.Cep", inv.Mensagem);
break;
case ResultadoDeCotacao.Indisponivel:
ModelState.AddModelError(string.Empty,
"Não foi possível calcular o frete agora. Tente novamente em instantes.");
break;
}
return Page();
}
private async Task<bool> CarregarAsync(int id, CancellationToken ct)
{
var produto = await catalogo.ObterAsync(id, ct);
if (produto is null) return false;
Produto = produto;
Slug = Slugs.De(produto.Titulo);
return true;
}
}
Há três pontos que tropeçam quem está começando.
O POST precisa recarregar a página inteira. O HTTP não guarda estado. A requisição POST não sabe nada do GET que exibiu a página antes: o Produto não está lá. Se o handler só calculasse o frete e chamasse Page(), o template quebraria em Model.Produto.Titulo. Por isso CarregarAsync roda em todos os handlers. Não há como pular essa etapa, só evitar repeti-la escrevendo-a uma vez.
ModelState é o registro de tudo o que deu errado no binding e na validação. Quando o formulário chega, o framework tenta converter cada campo (texto "abc" num int gera um erro de conversão), executa os atributos de validação e anota cada problema com a chave do campo (Frete.Cep). ModelState.IsValid é falso se houver qualquer anotação. AddModelError permite acrescentar erros próprios: com a chave de um campo, a mensagem aparece junto ao campo; com a chave vazia, aparece no resumo.
return Page() depois de um erro reexibe o formulário com o que o usuário digitou. Os tag helpers leem os valores do ModelState, e não só do modelo, e por isso até um valor inválido, como "abc" no campo de quantidade, volta ao campo para ser corrigido, em vez de sumir.
Por que [Bind(Prefix)] e não [BindProperty]
A página tem dois formulários: frete e aviso. Se Frete e Aviso fossem [BindProperty], ambos seriam preenchidos e validados a cada POST, qualquer que fosse o formulário enviado. Postar o frete deixaria Aviso.Email vazio, o [Required] dele falharia, e ModelState.IsValid seria falso para sempre, com uma mensagem "informe o e-mail" aparecendo no formulário que ninguém tocou. Receber cada entrada como parâmetro do seu handler, com o prefixo que corresponde aos nomes gerados (Frete.Cep), faz o framework ligar e validar só o que foi enviado. As propriedades sem atributo continuam servindo para a exibição.
[BindProperty] num modelo grande também é o caminho clássico para o overposting de "A API da Livraria": qualquer propriedade pública com setter no modelo ligado pode ser preenchida por um campo extra forjado no formulário. Um modelo de entrada que contém só os campos do formulário elimina o risco pela mesma razão que o DTO eliminava na API.
Validação no navegador
A validação no servidor é a que vale, porque qualquer um pode enviar um POST sem passar pelo navegador. Mesmo assim, avisar "CEP inválido" antes de uma ida e volta à rede melhora muito a experiência. O template já traz a validação unobtrusive: os atributos data-val-* gerados a partir dos atributos C# são lidos pelas bibliotecas jQuery Validation e jQuery Validation Unobtrusive, que validam ao sair do campo e ao enviar:
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}
[Required], [StringLength], [Range], [RegularExpression], [EmailAddress] e [Compare] têm equivalentes no navegador. Atributos próprios, como o [Isbn] de "Validação, ProblemDetails e OpenAPI", só validam no servidor, a menos que se escreva o adaptador JavaScript correspondente. Isso é aceitável: o servidor pega o erro e a página volta com a mensagem.
O novalidate no formulário desliga a validação nativa do HTML5 (os balões do required e do type="email"), para que o usuário não receba dois estilos de mensagem ao mesmo tempo. A alternativa, sem jQuery, é usar só a validação nativa do navegador, que cobre bem os casos simples.
A armadilha da vírgula. O jQuery Validation valida números com ponto decimal. Num site em português, "39,90" num campo decimal é marcado como inválido no navegador, embora o servidor, com UseRequestLocalization("pt-BR") do artigo anterior, o aceitasse. Os formulários desta página só têm inteiros, mas qualquer campo de preço num formulário como estes vai precisar lidar com isso: ou substituindo o método number do validador por uma versão que aceite vírgula, ou usando type="text" com inputmode="decimal" e deixando a validação desse campo para o servidor.
CSRF: o token escondido
CSRF (Cross-Site Request Forgery) é o ataque em que um site malicioso faz o navegador da vítima enviar um formulário para outro site onde ela está logada:
<!-- em site-malicioso.exemplo -->
<form action="https://loja.livraria.exemplo/conta/endereco" method="post">
<input type="hidden" name="Cep" value="endereco-do-atacante" />
</form>
<script>document.forms[0].submit()</script>
Se a loja autentica por cookie, o navegador anexa o cookie da vítima automaticamente, e a loja vê uma requisição legítima. O CORS não ajuda: ele impede o site malicioso de ler a resposta, mas o POST foi executado mesmo assim.
A defesa é o token antiforgery. O servidor gera um valor aleatório, coloca-o num cookie e num campo oculto do formulário, e na chegada do POST confere se os dois correspondem. O site malicioso não consegue ler nem o cookie nem a página da loja, e por isso não tem como forjar o campo. Nas Razor Pages a proteção é automática: o <form method="post"> gera o campo, e todo handler POST valida o token, respondendo 400 se ele faltar ou não conferir. Não é preciso escrever nada, desde que não se desligue a proteção.
O atributo SameSite=Lax, padrão dos cookies de autenticação do ASP.NET Core, já impede o navegador de enviar o cookie nesse tipo de POST vindo de outro site, e isso é uma segunda camada. O token continua necessário para navegadores antigos e para cenários em que SameSite não se aplica, como subdomínios do mesmo site. A API da Fase 6 não precisa de token antiforgery porque se autentica por cabeçalho Authorization, que o navegador nunca anexa sozinho. É o mesmo raciocínio do exercício 1 de "Produção: CORS, rate limiting, versionamento e OpenTelemetry".
"Avise-me quando chegar": Post/Redirect/Get
Quando o livro está esgotado, a parcial _Disponibilidade mostra um segundo formulário:
public sealed class EntradaDeAviso
{
[Required(ErrorMessage = "Informe seu e-mail.")]
[EmailAddress(ErrorMessage = "E-mail inválido.")]
[StringLength(254)]
[Display(Name = "Seu e-mail")]
public string Email { get; set; } = "";
[Range(typeof(bool), "true", "true",
ErrorMessage = "Precisamos da sua autorização para enviar o aviso.")]
[Display(Name = "Autorizo a Livraria a me enviar um único e-mail quando este livro chegar.")]
public bool Consentimento { get; set; }
// armadilha para robôs: campo invisível que humanos não preenchem
public string? Site { get; set; }
}
<form method="post" asp-page-handler="AviseMe" asp-route-slug="@Model.Slug">
<label asp-for="Aviso.Email"></label>
<input asp-for="Aviso.Email" autocomplete="email" />
<span asp-validation-for="Aviso.Email" class="erro"></span>
<div class="consentimento">
<input asp-for="Aviso.Consentimento" />
<label asp-for="Aviso.Consentimento"></label>
<span asp-validation-for="Aviso.Consentimento" class="erro"></span>
</div>
<div class="armadilha" aria-hidden="true">
<label for="Aviso_Site">Não preencha</label>
<input asp-for="Aviso.Site" tabindex="-1" autocomplete="off" />
</div>
<button type="submit">Avise-me</button>
</form>
@if (Model.Mensagem is not null)
{
<p role="status" class="sucesso">@Model.Mensagem</p>
}
public async Task<IActionResult> OnPostAviseMeAsync(
int id,
[Bind(Prefix = nameof(Aviso))] EntradaDeAviso aviso,
CancellationToken ct)
{
if (!await CarregarAsync(id, ct))
return NotFound();
Aviso = aviso;
if (!string.IsNullOrEmpty(aviso.Site)) // robô: finge sucesso e ignora
return RedirectToPage(new { id, slug = Slug });
if (!ModelState.IsValid)
return Page();
var resultado = await catalogo.RegistrarAvisoAsync(id, aviso.Email, ct);
if (resultado.Erros is { } erros)
{
foreach (var (campo, mensagens) in erros)
foreach (var mensagem in mensagens)
ModelState.AddModelError($"{nameof(Aviso)}.{campo}", mensagem);
return Page();
}
if (resultado.LimiteExcedido)
{
ModelState.AddModelError(string.Empty, "Muitas tentativas. Aguarde um minuto e tente de novo.");
return Page();
}
Mensagem = "Pronto! Vamos avisar você quando este livro chegar.";
return RedirectToPage(new { id, slug = Slug }); // Post/Redirect/Get
}
Por que redirecionar
Se o handler de sucesso devolvesse Page() direto, a página exibida seria a resposta a um POST. Um F5 reenviaria o formulário (o navegador pergunta, mas quase todo mundo clica "sim"), e o botão Voltar faria o mesmo. Para um aviso de disponibilidade isso é só incômodo. Num formulário de pedido, seria uma compra em dobro.
O padrão Post/Redirect/Get (PRG) resolve: depois de um POST bem-sucedido, o servidor responde 303 See Other (ou 302) para uma URL de GET. O navegador segue o redirecionamento, e a página na tela passa a ser a resposta a um GET, que pode ser recarregado à vontade. Os erros de validação não redirecionam: reexibem a página, porque é preciso mostrar os valores digitados e as mensagens, e nada foi gravado.
Como a mensagem de sucesso sobrevive ao redirecionamento se o HTTP não guarda estado? Pelo TempData: um dicionário gravado, por padrão, num cookie criptografado, que dura até ser lido uma vez. O atributo [TempData] na propriedade Mensagem faz o PageModel gravá-la no POST e lê-la de volta no GET seguinte, exibe o aviso e o descarta. Um F5 posterior já não mostra a mensagem.
Erros que vêm da API
O site valida o e-mail, mas a API valida de novo, porque é ela quem garante o contrato para todos os clientes. Quando a API recusa com o 400 de validação, o corpo é o HttpValidationProblemDetails de "Validação, ProblemDetails e OpenAPI", com o dicionário errors por campo. O cliente do site o traduz para as chaves do formulário:
public async Task<ResultadoDeAviso> RegistrarAvisoAsync(int produtoId, string email, CancellationToken ct)
{
using var resposta = await http.PostAsJsonAsync(
$"v1/produtos/{produtoId}/avisos", new { email }, ct);
return resposta.StatusCode switch
{
HttpStatusCode.Accepted or HttpStatusCode.NoContent => ResultadoDeAviso.Ok,
HttpStatusCode.TooManyRequests => ResultadoDeAviso.Limitado,
HttpStatusCode.BadRequest => ResultadoDeAviso.ComErros(
(await resposta.Content.ReadFromJsonAsync<HttpValidationProblemDetails>(ct))!.Errors
.ToDictionary(e => Capitalizar(e.Key), e => e.Value)),
_ => throw new HttpRequestException($"Resposta inesperada da API: {(int)resposta.StatusCode}")
};
}
Os nomes de campo da API podem chegar em camelCase (email), enquanto as chaves do formulário usam o nome C# (Aviso.Email). O Capitalizar alinha as duas convenções. É um detalhe pequeno, mas se for esquecido os erros da API vão parar numa chave que nenhum asp-validation-for exibe, e o usuário vê o formulário voltar sem explicação.
Do lado da API, o endpoint POST /v1/produtos/{id}/avisos é anônimo, protegido pela política de rate limiting "Leitura" (ou uma mais estrita), e guarda o e-mail num conjunto do Redis por produto. Quando o evento produto.reposto passar pelo RabbitMQ, um consumidor no Livraria.Worker envia os avisos e apaga os e-mails. O consentimento pedia um único aviso, e a LGPD pede que o dado pessoal não fique guardado além da finalidade para a qual foi coletado.
O campo-armadilha
O campo Site fica escondido por CSS (.armadilha { position: absolute; left: -9999px; }), fora da ordem de tabulação e oculto de leitores de tela. Pessoas não o veem nem o preenchem. Robôs de spam, que preenchem todos os campos que encontram, preenchem. O handler finge sucesso para não ensinar ao robô que foi detectado. É uma defesa barata e sem atrito, que não substitui o rate limiting mas elimina a maior parte do spam trivial, sem obrigar pessoas a resolver um CAPTCHA.
Acessibilidade dos formulários
Formulários são onde a acessibilidade mais falha, e boa parte do trabalho os tag helpers já fazem: rótulos ligados aos campos, nomes consistentes e mensagens junto ao campo. O resto depende de nós:
- Todo campo tem um
<label>visível. Umplaceholdernão é rótulo: desaparece ao digitar e muitos leitores de tela não o anunciam. - Erros são anunciados. O resumo tem
role="alert", e a mensagem de sucesso temrole="status", para que leitores de tela os leiam sem que o usuário precise procurá-los. - A cor não é a única pista. O campo com erro recebe também um ícone ou um texto, não apenas uma borda vermelha.
- O teclado basta. Os formulários podem ser preenchidos e enviados só com Tab e Enter, e o foco fica visível.
autocompletecorreto (email,postal-code) ajuda a todos e é exigido pelas diretrizes WCAG 2.2 para dados pessoais.
A Livraria agora escuta seus visitantes: busca por GET com URLs compartilháveis, cotação de frete com validação nos dois lados e mensagens junto aos campos, e um aviso de chegada protegido contra CSRF, robôs e envios duplicados, que trata os erros vindos da API como se tivessem nascido no próprio formulário. Cada interação, porém, ainda custa uma ida e volta completa ao servidor e uma página inteira redesenhada. Para uma vitrine isso é ótimo. Para um carrinho que soma itens, recalcula o frete e atualiza o total enquanto o cliente mexe nas quantidades, é pouco.
Fontes e leituras recomendadas
- Microsoft Learn — Model validation in ASP.NET Core MVC and Razor Pages: https://learn.microsoft.com/aspnet/core/mvc/models/validation
- Microsoft Learn — Model binding in ASP.NET Core (incluindo overposting e
[Bind]): https://learn.microsoft.com/aspnet/core/mvc/models/model-binding - Microsoft Learn — Prevent Cross-Site Request Forgery (XSRF/CSRF) attacks in ASP.NET Core: https://learn.microsoft.com/aspnet/core/security/anti-request-forgery
- OWASP — Cross-Site Request Forgery Prevention Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html
- W3C WAI — Forms Tutorial: https://www.w3.org/WAI/tutorials/forms/
- MDN Web Docs — Client-side form validation: https://developer.mozilla.org/docs/Learn_web_development/Extensions/Forms/Form_validation
Exercícios
Exercício 1
Um desenvolvedor transformou a cotação de frete num formulário GET (/livros/7?cep=01310100&quantidade=2) "para poder compartilhar o link". Isso é aceitável? E se fosse o "avise-me"?
Ver resposta
✓ Resposta: Para a cotação é aceitável, e até tem vantagens. Cotar não altera nada no servidor, então é uma leitura segura e idempotente, e o link compartilhável é um benefício real. Os cuidados: o CEP fica em logs de servidor e de proxy e no histórico do navegador, o que para um CEP costuma ser aceitável, e buscadores podem seguir esses links. Vale marcar as páginas de cotação com <meta name="robots" content="noindex"> para não gerar milhares de páginas duplicadas. Para o "avise-me", não: ele grava um dado pessoal. Por GET, um pré-carregamento do navegador, um robô de busca ou um link numa imagem cadastrariam e-mails sem ação do usuário, e o e-mail ficaria exposto em logs e no histórico. E não haveria proteção contra CSRF, que as Razor Pages não aplicam a GET. Tudo o que altera estado vai por POST.
Exercício 2
O formulário de frete foi enviado com o CEP "abc" e quantidade "3". O handler devolve Page(). Descreva o que o usuário vê em cada campo, e por que o campo CEP mostra "abc" e não um valor vazio.
Ver resposta
✓ Resposta: O campo CEP mostra "abc" com a mensagem "Use o formato 00000-000." abaixo dele. O campo quantidade mostra "3" sem mensagem. Os tag helpers de input buscam o valor primeiro no ModelState, que guarda o valor tentado (o texto bruto enviado) junto com os erros, e só depois no modelo. Isso é o que permite devolver ao usuário exatamente o que ele digitou, mesmo quando o valor nem pode ser convertido para o tipo da propriedade: "abc" num int geraria erro de conversão e o modelo ficaria com 0, mas o campo mostraria "abc". Uma consequência que surpreende: se o handler alterar Frete.Cep no código e chamar Page(), o campo continua mostrando o valor antigo do ModelState. Para substituí-lo é preciso chamar ModelState.Remove("Frete.Cep") antes.
Exercício 3
Explique por que o handler de sucesso do "avise-me" redireciona e o de erro de validação não. Qual seria o problema de também redirecionar no erro?
Ver resposta
✓ Resposta: No sucesso, algo foi gravado, e a página que resulta do POST não deve poder ser reenviada por F5 ou pelo botão Voltar. Redirecionar para um GET faz a tela exibir uma resposta segura de recarregar, e o TempData leva a mensagem de sucesso através do redirecionamento. No erro, nada foi gravado: reenviar o mesmo formulário inválido só produziria o mesmo erro, sem dano. E a página precisa mostrar os valores digitados e as mensagens por campo, que vivem no ModelState da requisição atual. Um redirecionamento os perderia: o GET seguinte começaria com o formulário vazio e sem mensagens. Para preservá-los seria necessário serializar o ModelState no TempData, uma complicação sem benefício. Por isso a regra do PRG é: redirecionar depois de sucesso, reexibir depois de erro.
Exercício 4
Um atacante descobre o endpoint e envia 10 mil POSTs ao "avise-me" com e-mails de terceiros, cada um com um token antiforgery válido obtido com um GET antes. Quais das defesas do artigo o impedem, e o que mais seria recomendável?
Ver resposta
✓ Resposta: O token antiforgery não impede: ele protege contra requisições forjadas a partir do navegador de uma vítima, e não contra um robô que obtém um token legítimo para si. O campo-armadilha também não, se o atacante estudar o formulário. O que contém o ataque é o rate limiting na API, e no site, por IP, que limita o volume. A real proteção de terceiros vem do desenho: o sistema envia um único e-mail por inscrição e só quando o livro chegar, então o dano para quem teve o endereço cadastrado é pequeno. Recomendações adicionais: (1) confirmação dupla (double opt-in): enviar um e-mail com link de confirmação e só ativar o aviso depois do clique, o que é a prática certa quando se coleta e-mail de desconhecidos; (2) limitar inscrições por e-mail e por produto no próprio conjunto do Redis; (3) um CAPTCHA acessível ou um desafio de prova de trabalho só quando o volume por IP for anômalo.
Exercício 5
Acrescente ao modelo de frete um campo opcional "Presente?" (bool) e explique o que acontece se o usuário desmarcar a caixa. Por que o tag helper gera um <input type="hidden"> extra para checkboxes?
Ver resposta
✓ Resposta: Acrescente [Display(Name = "É para presente")] public bool Presente { get; set; } e <input asp-for="Frete.Presente" /><label asp-for="Frete.Presente"></label>. O HTML tem uma peculiaridade: uma checkbox desmarcada não é enviada no formulário. Nem o nome, nem um valor. Sem mais nada, o binder não receberia o campo, e o valor do modelo ficaria no padrão, o que funciona para bool mas é ambíguo ("desmarcou" e "o campo não existia" ficam iguais), e é um problema em edições em que o valor anterior era true. Por isso o tag helper gera, junto com a checkbox, um <input type="hidden" name="Frete.Presente" value="false">. Marcada, o formulário envia true e false, e o binder usa o primeiro. Desmarcada, envia só o false do campo oculto. O resultado é que o valor sempre chega de forma explícita. Nas versões recentes, esse campo oculto é emitido no fim do formulário.