Enviar imagens de um editor de texto rico em PHP

Quando alguém insere uma imagem no editor, o navegador precisa enviá-la para algum lugar e receber de volta uma URL para colocar no <img>. Esta página traz esse endpoint em PHP 8 puro: um script que pega o arquivo de $_FILES, verifica, armazena com move_uploaded_file() e responde com JSON. Não é preciso framework nem extensões extras do PHP.
Ligue o editor ao seu servidor
A página carrega o editor e um pequeno arquivo de navegador, rte-files.js, que envia cada imagem por POST para /rte/upload como multipart/form-data. A caixa de diálogo da galeria o usa para enviar para a pasta aberta. Arrastar e soltar, colar e a caixa de diálogo Insert Image também o usam; esses uploads não têm pasta e vão para a raiz de uploads/.
<link rel="stylesheet" href="/richtexteditor/rte_theme_default.css">
<script src="/richtexteditor/rte.js"></script>
<script src="/richtexteditor/plugins/all_plugins.js"></script>
<script src="/rte-files.js"></script>
<script>
new RichTextEditor("#editor", {
toolbar: "full",
galleryEndpoint: "/rte/gallery",
maxUploadFileSize: 4000000 // same limit as rte/upload.php, checked before sending
});
</script>maxUploadFileSize permite que a caixa de diálogo da galeria recuse um arquivo grande demais antes que ele seja enviado. O servidor verifica o tamanho de novo, porque uma verificação no navegador é só uma conveniência.
A rota
O servidor embutido do PHP precisa de um script de roteamento para mapear URLs limpas para arquivos. No projeto de exemplo, router.php envia /rte/upload para rte/upload.php e serve as imagens armazenadas a partir de /uploads/:
if ($path === '/' || $path === '/index.php') {
require __DIR__ . '/index.php';
} elseif ($path === '/rte/gallery') {
require __DIR__ . '/rte/gallery.php';
} elseif ($path === '/rte/upload') {
require __DIR__ . '/rte/upload.php';
} elseif ($path === '/demo-login') {
require __DIR__ . '/demo-login.php'; // stand-in for your login, see that file
} elseif ($path === '/rte-files.js') {
rte_send_static($sharedDir, 'rte-files.js', RTE_STATIC_TYPES);
} elseif (str_starts_with($path, '/richtexteditor/')) {
rte_send_static($editorDir, substr($path, strlen('/richtexteditor/')), RTE_STATIC_TYPES);
} elseif (str_starts_with($path, '/uploads/')) {
// Images only, sent as bytes. Returning false here instead would let the
// built-in server EXECUTE a .php file that ended up in uploads/.
rte_send_static(__DIR__ . '/uploads', substr($path, strlen('/uploads/')), RTE_UPLOAD_TYPES);
} else {
rte_not_found();
}
return true; // never false: false hands the request to the built-in server's own file handlingO roteador nunca retorna false. Se retornasse, o servidor embutido trataria a requisição por conta própria, e ele executa qualquer arquivo .php que encontre sob a raiz de documentos, inclusive um que tenha ido parar em uploads/. Testamos com um roteador de uma linha que retorna false: requisitar /uploads/probe.php executou o script. No Apache, o exemplo usa regras de rewrite no .htaccess; no nginx, blocos location exatos. O guia de upload seguro mostra os dois.
O que o PHP entrega a você
Antes de o seu script rodar, o PHP já fez o parsing da requisição e gravou o arquivo em uma pasta temporária. $_FILES['file'] diz como isso correu, e o campo error dele é a primeira coisa a verificar:
// Over post_max_size, PHP drops the WHOLE body: $_POST and $_FILES are both empty.
$length = (int)($_SERVER['CONTENT_LENGTH'] ?? 0);
if (empty($_FILES) && empty($_POST) && $length > rte_ini_bytes('post_max_size')) {
throw new RteHttpError(413, 'The upload is larger than the server accepts (post_max_size).');
}
$file = $_FILES['file'] ?? null;
// One file only: "file[]" arrives as nested arrays, which this refuses.
if (!is_array($file) || !is_int($file['error'] ?? null) || !is_string($file['tmp_name'] ?? null)) {
throw new RteHttpError(400, 'No file received. Send it as the "file" field.');
}
switch ($file['error']) {
case UPLOAD_ERR_OK:
break;
case UPLOAD_ERR_INI_SIZE: // larger than upload_max_filesize
case UPLOAD_ERR_FORM_SIZE: // larger than a MAX_FILE_SIZE form field
throw new RteHttpError(413, 'The image is larger than the server accepts.');
case UPLOAD_ERR_PARTIAL:
throw new RteHttpError(400, 'The upload was interrupted. Please try again.');
case UPLOAD_ERR_NO_FILE:
throw new RteHttpError(400, 'No file received.');
default: // NO_TMP_DIR, CANT_WRITE, EXTENSION: server-side problems
error_log('rte upload: UPLOAD_ERR code ' . $file['error']);
throw new RteHttpError(500, 'The server could not store the upload.');
}
if (!is_uploaded_file($file['tmp_name'])) {
throw new RteHttpError(400, 'No file received.');
}
$size = filesize($file['tmp_name']);
if ($size === false || $size === 0) {
throw new RteHttpError(400, 'The file is empty.');
}
if ($size > RTE_MAX_BYTES) {
throw new RteHttpError(413, 'Images can be at most 4,000,000 bytes.');
}Dois detalhes aqui são específicos do PHP. Quando a requisição é maior que post_max_size, o PHP descarta o corpo inteiro: $_POST e $_FILES ficam vazios, então o script precisa comparar CONTENT_LENGTH com o limite para saber o motivo. E upload_max_filesize é 2M por padrão, abaixo do limite de 4,000,000 bytes deste exemplo. O guia de limites de tamanho trata de tudo isso.
O que o servidor verifica
O nome define a extensão, e os bytes precisam concordar com ela. $_FILES['file']['type'] é o que o cliente escreveu na requisição, então é ignorado.
// The name decides the extension; the bytes must agree with it.
// $file['type'] is whatever the browser (or attacker) sent, so it is ignored.
$ext = rte_extension((string)($file['name'] ?? ''));
if (!in_array($ext, RTE_IMAGE_EXTENSIONS, true)) {
throw new RteHttpError(400, 'Only .jpg, .jpeg, .png, .gif and .webp images can be uploaded.');
}
$types = ['jpg' => IMAGETYPE_JPEG, 'jpeg' => IMAGETYPE_JPEG, 'png' => IMAGETYPE_PNG, 'gif' => IMAGETYPE_GIF, 'webp' => IMAGETYPE_WEBP];
// Signature first, then getimagesize() (core PHP, no GD needed) parses the header.
// A bare signature followed by junk still parses, but as 0x0, so require real dimensions.
$info = @getimagesize($file['tmp_name']);
if (!rte_signature_matches($file['tmp_name'], $ext) || $info === false
|| $info[2] !== $types[$ext] || $info[0] < 1 || $info[1] < 1) {
throw new RteHttpError(400, 'The file is not a valid .' . $ext . ' image.');
}getimagesize() faz parte do núcleo do PHP. Não precisa de GD nem de fileinfo.
Para onde vão os arquivos
A pasta da requisição é um caminho virtual como blog ou products/2026, nunca um caminho em disco. Primeiro ela é resolvida dentro da raiz de armazenamento. Depois o arquivo é armazenado com um nome gerado, para que dois uploads chamados photo.jpg nunca se sobrescrevam:
$folder = rte_resolve_folder($_POST['folder'] ?? '');
// Generated name: uploads never overwrite each other and never keep the client's name as-is.
$base = rte_safe_base((string)$file['name']);
for ($try = 0; $try < 5; $try++) {
$stored = $base . '-' . bin2hex(random_bytes(4)) . '.' . $ext;
$target = $folder['dir'] . DIRECTORY_SEPARATOR . $stored;
if (!file_exists($target)) {
break;
}
}
if (file_exists($target) || !move_uploaded_file($file['tmp_name'], $target)) {
throw new RteHttpError(500, 'The server could not save the image.');
}
return ['url' => rte_url($folder['root'], [...$folder['segments'], $stored])];A resposta é {"url": "/uploads/blog/harbor-2839881c.jpg"}, e o editor insere essa URL. Em caso de falha, o script responde 400 ou 413 com {"error": "..."}.
move_uploaded_file(). Uma falha volta ao navegador como JSON.Quando um upload falha, a caixa de diálogo da galeria atualmente mostra a própria mensagem, "Upload failed for holiday.jpg.", e não o texto do servidor. O motivo dado pelo servidor continua no corpo da resposta, onde você pode vê-lo nas ferramentas de desenvolvedor do navegador.
Teste
Baixe o projeto de exemplo deste guia, coloque a pasta richtexteditor do download do editor ao lado de index.php e inicie o servidor embutido do PHP:
php -d upload_max_filesize=5M -d display_errors=0 -d log_errors=1 -S localhost:5201 router.phpDepois envie uma imagem com curl, uma vez para a raiz e outra para a pasta blog:
curl -F "file=@harbor.jpg" http://localhost:5201/rte/upload
{"url":"/uploads/harbor-f95d28e0.jpg"}
curl -F "file=@harbor.jpg" -F folder=blog http://localhost:5201/rte/upload
{"url":"/uploads/blog/harbor-2839881c.jpg"}A pasta já precisa existir. Abra http://localhost:5201/, clique em Insert Gallery, e os arquivos enviados aparecem listados ali. O exemplo também passa no teste de contrato compartilhado que o exemplo de cada plataforma executa: node shared/contract-test.mjs http://localhost:5201 termina com ALL 35 CHECKS PASSED.
O mesmo guia para outras plataformas
JavaScript · Next.js · ASP.NET Core · Razor Pages · Blazor · MVC 5 · Web Forms