Téléverser des images depuis un éditeur de texte enrichi en PHP

Quand quelqu’un insère une image dans l’éditeur, le navigateur doit l’envoyer quelque part et récupérer une URL à placer dans la balise <img>. Cette page vous donne ce point de terminaison en PHP 8 pur : un seul script qui récupère le fichier dans $_FILES, le vérifie, le stocke avec move_uploaded_file() et répond en JSON. Aucun framework ni aucune extension PHP supplémentaire n’est nécessaire.
Relier l’éditeur à votre serveur
La page charge l’éditeur et un petit fichier côté navigateur, rte-files.js, qui envoie chaque image à /rte/upload en multipart/form-data. La boîte de dialogue de la galerie s’en sert pour téléverser dans le dossier ouvert. Le glisser-déposer, le collage et la boîte de dialogue Insert Image l’utilisent aussi ; ces envois n’ont pas de dossier et vont à la racine 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 permet à la boîte de dialogue de la galerie de refuser un fichier trop volumineux avant son envoi. Le serveur vérifie à nouveau la taille, car une vérification dans le navigateur n’est qu’une commodité.
La route
Le serveur intégré de PHP a besoin d’un script de routage pour associer des URL propres à des fichiers. Dans le projet d’exemple, router.php envoie /rte/upload vers rte/upload.php et sert les images stockées depuis /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 handlingLe routeur ne renvoie jamais false. S’il le faisait, le serveur intégré traiterait lui-même la requête, et il exécute tout fichier .php qu’il trouve sous la racine des documents, y compris un fichier qui se serait retrouvé dans uploads/. Nous l’avons vérifié avec un routeur d’une ligne qui renvoie false : demander /uploads/probe.php a exécuté le script. Sous Apache, l’exemple utilise à la place des règles de réécriture dans .htaccess ; sous nginx, des blocs location exacts. Le guide sur l’envoi sécurisé montre les deux.
Ce que PHP vous transmet
Avant que votre script ne s’exécute, PHP a déjà analysé la requête et écrit le fichier dans un dossier temporaire. $_FILES['file'] vous indique comment cela s’est passé, et son champ error est la première chose à vérifier :
// 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.');
}Deux détails sont ici propres à PHP. Quand la requête dépasse post_max_size, PHP jette tout le corps de la requête : $_POST et $_FILES sont tous deux vides, et le script doit donc comparer CONTENT_LENGTH à la limite pour savoir pourquoi. Par ailleurs, upload_max_filesize vaut 2M par défaut, ce qui est inférieur à la limite de 4,000,000 octets de cet exemple. Le guide sur les limites de taille traite tout cela.
Ce que vérifie le serveur
Le nom détermine l’extension, et les octets du fichier doivent lui correspondre. $_FILES['file']['type'] contient ce que le client a écrit dans la requête, quoi que ce soit : il est donc ignoré.
// 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() fait partie du cœur de PHP. Elle ne nécessite ni GD ni fileinfo.
Où vont les fichiers
Le dossier indiqué dans la requête est un chemin virtuel comme blog ou products/2026, jamais un chemin sur le disque. Il est d’abord résolu à l’intérieur de la racine de stockage. Le fichier est ensuite stocké sous un nom généré, si bien que deux envois nommés photo.jpg ne s’écrasent jamais l’un l’autre :
$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 réponse est {"url": "/uploads/blog/harbor-2839881c.jpg"}, et l’éditeur insère cette URL. En cas d’échec, le script répond 400 ou 413 avec {"error": "..."}.
move_uploaded_file(). Un échec est renvoyé au navigateur en JSON.Quand un envoi échoue, la boîte de dialogue de la galerie affiche pour l’instant son propre message, « Upload failed for holiday.jpg. » (l’envoi de holiday.jpg a échoué), et non le texte du serveur. La raison donnée par le serveur figure toujours dans le corps de la réponse, où vous pouvez la consulter avec les outils de développement du navigateur.
Tester
Téléchargez le projet d’exemple de ce guide, placez le dossier richtexteditor issu du téléchargement de l’éditeur à côté de index.php, puis démarrez le serveur intégré de PHP :
php -d upload_max_filesize=5M -d display_errors=0 -d log_errors=1 -S localhost:5201 router.phpEnvoyez ensuite une image avec curl, une fois à la racine et une fois dans le dossier 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"}Le dossier doit déjà exister. Ouvrez http://localhost:5201/, cliquez sur Insert Gallery : les fichiers envoyés y sont listés. L’exemple passe aussi le test de contrat commun que l’exemple de chaque plateforme exécute : node shared/contract-test.mjs http://localhost:5201 se termine par ALL 35 CHECKS PASSED.
Le même guide pour d’autres plateformes
JavaScript · Next.js · ASP.NET Core · Razor Pages · Blazor · MVC 5 · Web Forms