Programação Por Equipe DevBras

Capa do artigo: Editor rico salva vazio: sincronize e sanitize o HTML

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.

  1. Coloque um campo escondido com name dentro do formulário. É ele que viaja no envio.
  2. Dê nome acessível ao editor. role="textbox", aria-multiline="true" e aria-labelledby apontando para o rótulo visível.
  3. Copie o conteúdo a cada evento input e de novo no submit. Assim, qualquer caminho de envio encontra o campo atualizado.
  4. 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.
  5. Use requestSubmit() quando precisar enviar por código. Ele dispara o evento submit e a validação; submit() não dispara nenhum dos dois.
  6. 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 a form.submit() ou envio por fetch escapa 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 name dentro do formulário que recebe o HTML do editor.
  • A cópia acontece a cada input e de novo no submit.
  • Envios por código usam requestSubmit() ou montam o corpo incluindo o campo do editor.
  • O editor tem role="textbox", aria-multiline e 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.

Avalie este artigo

Referência: http://stackoverflow.com/questions/16941360/how-to-create-a-placeholder-for-div-that-act-like-textfield