Editor rico salva vazio: sincronize e sanitize o HTML
Editor rico com contenteditable que salva vazio? Copie o HTML para um campo do formulário a cada input e sanitize no servidor com lista de tags antes de gravar.
A redatora escreve três parágrafos, coloca um link em negrito, clica em Salvar e a página de edição volta em branco. No banco, a coluna corpo está vazia. Nada deu erro: o editor era uma <div contenteditable>, e div não faz parte do formulário. A resposta curta: o conteúdo do editor precisa ser copiado para um campo com name dentro do <form> antes do envio, de preferência a cada alteração. No servidor, esse HTML é tratado como entrada não confiável: passa por um sanitizador com lista de tags permitidas e só então é gravado.
O que você vê na tela não é o que o POST conhece. O POST conhece apenas os controles de formulário com nome.
Este guia cobre o caso do editor de blog com barra de ferramentas: sincronizar o HTML com o formulário e limpar esse HTML no servidor antes de gravar. Se o problema é só um campo de texto simples trocado por uma div, a explicação mais curta está em div com contenteditable no lugar do campo. E se o editor nem carregou, o caminho é outro: a textarea que assume quando o editor rico falha.
Por que o conteúdo da div não chega ao servidor
Quando um formulário é enviado, o navegador monta a lista de dados (o algoritmo da especificação HTML) a partir dos controles associados a ele: input, textarea, select e afins, desde que tenham name e não estejam desabilitados. Uma div, editável ou não, não entra nessa lista. O atributo contenteditable deixa o texto editável na tela e não muda nada no envio.
Por isso as bibliotecas de editor rico costumam manter um campo escondido sincronizado com o conteúdo editado. Quando o editor é caseiro, essa sincronização é responsabilidade sua. E ela falha de jeitos previsíveis: a cópia é feita só no evento submit, e algum código chama form.submit() direto, que não dispara esse evento. Ou o envio é por fetch montando o corpo à mão, e o campo do editor ficou de fora. Se você envia por fetch, monte o corpo com new FormData(form) depois de sincronizar, como no guia de enviar formulário com fetch sem recarregar.
Do outro lado, o HTML que chega é o que a pessoa (ou um script mal-intencionado) quis mandar. Nada impede um POST com <script> ou onerror num atributo, mesmo que a barra de ferramentas não ofereça esses botões. A limpeza no navegador é conveniência; a que protege é a do servidor, como recomenda o guia de prevenção de XSS da OWASP.
Como sincronizar o editor com o formulário
O exemplo é o cadastro de posts de um blog: um editor com negrito, itálico, listas e links, e uma rota POST /posts em Node.js com Express.
- Coloque um campo escondido com
namedentro do formulário. É ele que viaja no envio. - Dê nome acessível ao editor.
role="textbox",aria-multiline="true"earia-labelledbyapontando para o rótulo visível. - Copie o conteúdo a cada evento
inpute de novo nosubmit. Assim, qualquer caminho de envio encontra o campo atualizado. - Trate o editor "vazio" que não está vazio. Navegadores costumam deixar um
<br>ou um parágrafo vazio depois que a pessoa apaga tudo. - Use
requestSubmit()quando precisar enviar por código. Ele dispara o eventosubmite a validação;submit()não dispara nenhum dos dois. - No servidor, sanitize com lista de tags e atributos permitidos, confira se sobrou texto e só então grave.
O HTML do formulário
<form id="form-post" method="post" action="/posts">
<p id="rotulo-corpo">Texto do post</p>
<div id="editor" contenteditable="true"
role="textbox" aria-multiline="true"
aria-labelledby="rotulo-corpo"
data-placeholder="Comece a escrever..."></div>
<input type="hidden" name="corpo" id="corpo">
<p id="erro-corpo" role="alert"></p>
<button type="submit">Salvar</button>
</form>
O placeholder da div, dúvida comum de quem monta o próprio editor, sai em CSS. O seletor :empty só casa quando a div não tem nem um <br>, por isso o script abaixo limpa o editor quando ele fica sem texto.
#editor:empty::before {
content: attr(data-placeholder);
color: #6b6b6b;
}
O JavaScript da sincronização
const form = document.getElementById('form-post');
const editor = document.getElementById('editor');
const campo = document.getElementById('corpo');
const erro = document.getElementById('erro-corpo');
function sincronizar() {
if (editor.textContent.trim() === '' && !editor.querySelector('img')) {
editor.innerHTML = '';
}
campo.value = editor.innerHTML;
}
editor.addEventListener('input', sincronizar);
form.addEventListener('submit', (evento) => {
sincronizar();
if (campo.value === '') {
evento.preventDefault();
erro.textContent = 'Escreva o texto do post antes de salvar.';
editor.focus();
}
});
A conferência de vazio no navegador existe porque o atributo required não tem efeito em input type="hidden": campos escondidos ficam fora da validação nativa. Ela melhora a experiência, mas não substitui a conferência do servidor.
A rota que limpa e grava
No servidor, o pacote sanitize-html recebe o HTML e devolve só o que está na lista de permitidos. A função salvarPost representa a sua gravação no banco.
const express = require('express');
const sanitizeHtml = require('sanitize-html');
const app = express();
app.use(express.urlencoded({ extended: false, limit: '500kb' }));
const regras = {
allowedTags: ['p', 'br', 'strong', 'em', 'ul', 'ol', 'li', 'a', 'h2', 'h3', 'blockquote'],
allowedAttributes: { a: ['href'] },
allowedSchemes: ['http', 'https', 'mailto']
};
app.post('/posts', async (req, res) => {
const bruto = typeof req.body.corpo === 'string' ? req.body.corpo : '';
const limpo = sanitizeHtml(bruto, regras);
const textoVisivel = sanitizeHtml(limpo, { allowedTags: [], allowedAttributes: {} }).trim();
if (textoVisivel === '') {
return res.status(422).send('O texto do post está vazio.');
}
await salvarPost(limpo);
res.redirect(303, '/posts');
});
O limite do express.urlencoded é pequeno por padrão (100 kb, segundo a documentação do body-parser), e um post longo com muito HTML passa disso. Quando passa, o Express responde 413 antes de chegar à sua rota. Ajuste o limite ao tamanho real dos seus textos, sem abrir para megabytes sem motivo.
Repare que a rota grava o HTML já limpo, nunca o bruto. Se mais tarde você mudar a lista de tags, o que está no banco continua seguro para exibir. E se o editor não carregar por algum motivo, a pessoa precisa de outro caminho para escrever, assunto do guia sobre a textarea de reserva.
Erros comuns
- Esperar que a div editável seja enviada sozinha: o banco recebe vazio e ninguém percebe até a primeira reclamação de texto perdido.
- Copiar o conteúdo só no evento
submit: qualquer chamada aform.submit()ou envio porfetchescapa da cópia e grava a versão antiga. - Confiar na barra de ferramentas como filtro: um POST feito à mão leva
<script>e atributos de evento para o banco, e daí para a tela de quem lê. - Sanitizar com expressão regular caseira: HTML tem variações demais, e uma regex que remove
<script>deixa passar<img onerror>. - Considerar
<p><br></p>como conteúdo: o sistema aceita posts "vazios" que quebram a listagem e o resumo. - Editor sem nome acessível: o leitor de tela anuncia uma área editável sem dizer o que deve ser escrito ali.
Checklist
- Existe um campo com
namedentro do formulário que recebe o HTML do editor. - A cópia acontece a cada
inpute de novo nosubmit. - Envios por código usam
requestSubmit()ou montam o corpo incluindo o campo do editor. - O editor tem
role="textbox",aria-multilinee rótulo ligado. - O servidor sanitiza com lista de tags e atributos permitidos antes de gravar.
- O servidor recusa com mensagem clara quando não sobra texto visível.
- O limite de tamanho do corpo da requisição comporta o maior post real.
Abra a ferramenta de rede do navegador, salve um texto no seu editor e olhe o corpo do POST. O campo com o HTML está lá, com o conteúdo que você acabou de digitar? Se não está, o banco está recebendo o que o formulário conhece, não o que a tela mostra.
Referência: http://stackoverflow.com/questions/16941360/how-to-create-a-placeholder-for-div-that-act-like-textfield