Enviar imagens de um editor de texto rico em PHP

Uma imagem inserida no RichTextEditor depois de ser enviada ao servidor de exemplo em PHP, com a barra de ferramentas completa acima dela

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/.

HTML · index.php
<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/:

PHP · router.php
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 handling

O 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:

PHP · rte/upload.php
// 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.

PHP · rte/upload.php
// 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:

PHP · rte/upload.php
$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": "..."}.

Diagrama: o navegador envia por POST para /rte/upload, o PHP preenche $_FILES, upload.php verifica extensão, assinatura e getimagesize(), move_uploaded_file() armazena o arquivo e o servidor responde com a URL
Todas as verificações acontecem antes de 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:

Shell
php -d upload_max_filesize=5M -d display_errors=0 -d log_errors=1 -S localhost:5201 router.php

Depois envie uma imagem com curl, uma vez para a raiz e outra para a pasta blog:

Shell
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

Leia a seguir