Subir imágenes desde un editor de texto enriquecido en PHP

Cuando alguien inserta una imagen en el editor, el navegador tiene que enviarla a algún sitio y recibir una URL para ponerla en el <img>. Esta página te da ese endpoint en PHP 8 simple: un script que toma el archivo de $_FILES, lo comprueba, lo guarda con move_uploaded_file() y responde con JSON. No necesitas ningún framework ni extensiones de PHP adicionales.
Conecta el editor con tu servidor
La página carga el editor y un pequeño archivo para el navegador, rte-files.js, que envía cada imagen a /rte/upload como multipart/form-data. El cuadro de diálogo de la galería lo usa para subir archivos a la carpeta abierta. Arrastrar y soltar, pegar y el cuadro de diálogo Insert Image también lo usan; esas cargas no tienen carpeta y van a la raíz 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 el cuadro de diálogo de la galería rechace un archivo demasiado grande antes de enviarlo. El servidor vuelve a comprobar el tamaño, porque una comprobación en el navegador es solo una comodidad.
La ruta
El servidor integrado de PHP necesita un script de enrutamiento para asociar URL limpias con archivos. En el proyecto de ejemplo, router.php envía /rte/upload a rte/upload.php y sirve las imágenes guardadas desde /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 handlingEl router nunca devuelve false. Si lo hiciera, el servidor integrado atendería la solicitud por su cuenta, y ejecuta cualquier archivo .php que encuentre bajo la raíz de documentos, incluido uno que haya acabado en uploads/. Lo probamos con un router de una línea que devuelve false: al solicitar /uploads/probe.php se ejecutó el script. En Apache, el ejemplo usa en su lugar reglas de reescritura en .htaccess; en nginx, bloques location exactos. La guía de carga segura muestra ambos.
Qué te entrega PHP
Antes de que se ejecute tu script, PHP ya ha analizado la solicitud y ha escrito el archivo en una carpeta temporal. $_FILES['file'] te dice cómo fue, y su campo error es lo primero que hay que comprobar:
// 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.');
}Aquí hay dos detalles propios de PHP. Cuando la solicitud supera post_max_size, PHP descarta el cuerpo completo: $_POST y $_FILES quedan vacíos, así que el script tiene que comparar CONTENT_LENGTH con el límite para saber por qué. Y upload_max_filesize es 2M de forma predeterminada, por debajo del límite de 4,000,000 bytes de este ejemplo. La guía de límites de tamaño explica todo esto.
Qué comprueba el servidor
El nombre determina la extensión, y los bytes deben coincidir con ella. $_FILES['file']['type'] es lo que el cliente haya escrito en la solicitud, así que se ignora.
// 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() forma parte del núcleo de PHP. No necesita GD ni fileinfo.
Dónde se guardan los archivos
La carpeta de la solicitud es una ruta virtual como blog o products/2026, nunca una ruta de disco. Primero se resuelve dentro de la raíz de almacenamiento. Después, el archivo se guarda con un nombre generado, para que dos cargas llamadas photo.jpg nunca se sobrescriban:
$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])];La respuesta es {"url": "/uploads/blog/harbor-2839881c.jpg"}, y el editor inserta esa URL. Si falla, el script responde 400 o 413 con {"error": "..."}.
move_uploaded_file(). Un error vuelve al navegador como JSON.Cuando una carga falla, el cuadro de diálogo de la galería muestra por ahora su propio mensaje, "Upload failed for holiday.jpg.", y no el texto del servidor. El motivo que da el servidor sigue estando en el cuerpo de la respuesta, donde puedes verlo con las herramientas de desarrollo del navegador.
Pruébalo
Descarga el proyecto de ejemplo de esta guía, coloca la carpeta richtexteditor de la descarga del editor junto a index.php e inicia el servidor integrado de PHP:
php -d upload_max_filesize=5M -d display_errors=0 -d log_errors=1 -S localhost:5201 router.phpDespués sube una imagen con curl, una vez a la raíz y otra a la carpeta 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"}La carpeta tiene que existir ya. Abre http://localhost:5201/, haz clic en Insert Gallery y verás ahí los archivos subidos. El ejemplo también pasa la prueba de contrato compartida que ejecuta el ejemplo de cada plataforma: node shared/contract-test.mjs http://localhost:5201 termina con ALL 35 CHECKS PASSED.
La misma guía para otras plataformas
JavaScript · Next.js · ASP.NET Core · Razor Pages · Blazor · MVC 5 · Web Forms