This guide sets up image uploads and the Insert Gallery dialog for RichTextEditor in PHP: a handler that stores only real images, an image path that works on every page, and a gallery endpoint that browses folders on your server. For rendering the editor, saving its HTML and displaying it safely, start with adding a rich text editor to a PHP application.
The PHP sample in the download includes rte-upload.php and rte-upload.js. The code below builds on them: it adds a gallery endpoint, uploads into sub-folders, and returns image URLs that start with /.
How the pieces fit
- Uploading. When someone inserts, pastes or drops an image, the editor calls a JavaScript upload function, which posts the file to
rte-upload.php. PHP answers in plain text:READY:/imageuploads/photo.pngon success, orERROR:messagewith status 500. The URL afterREADY:is what goes into the image’ssrcand is saved with the content. - The gallery. The Insert Gallery dialog shows the images in
config.galleryImages, or, whenconfig.galleryEndpointis set, becomes a folder browser with Up, New folder and Upload.
1. The upload handler
Save this as rte-upload.php in the site root, replacing the sample’s:
<?php
// Upload handler for RichTextEditor images: the editor's image dialog, paste,
// drag-and-drop, and the Insert Gallery "Upload" button.
//
// Replies in plain text: READY:<url> on success, ERROR:<message> (HTTP 500) on failure.
header("Content-Type: text/plain; charset=utf-8");
// TODO: check that the current user is allowed to upload.
$maxBytes = 4000000;
$rootDir = __DIR__ . DIRECTORY_SEPARATOR . "imageuploads"; // where files are saved
$rootUrl = "/imageuploads"; // the URL that serves $rootDir
// Real image types only, detected from the file's content, never from its name.
$types = array(IMAGETYPE_JPEG => "jpg", IMAGETYPE_PNG => "png", IMAGETYPE_GIF => "gif");
if (defined("IMAGETYPE_WEBP")) { $types[IMAGETYPE_WEBP] = "webp"; }
if (defined("IMAGETYPE_BMP")) { $types[IMAGETYPE_BMP] = "bmp"; }
if (defined("IMAGETYPE_AVIF")) { $types[IMAGETYPE_AVIF] = "avif"; }
function upload_fail($message)
{
http_response_code(500);
echo "ERROR:" . $message;
exit;
}
$file = isset($_FILES["fileforphp"]) ? $_FILES["fileforphp"] : null;
if (!$file || $file["error"] !== UPLOAD_ERR_OK || !is_uploaded_file($file["tmp_name"])) {
upload_fail("no file received");
}
if ($file["size"] > $maxBytes) {
upload_fail("file too big");
}
$info = @getimagesize($file["tmp_name"]);
if ($info === false || !isset($types[$info[2]])) {
upload_fail("only JPEG, PNG, GIF, WebP, BMP or AVIF images can be uploaded");
}
if (!is_dir($rootDir) && !mkdir($rootDir, 0755, true)) {
upload_fail("the server cannot create the upload folder");
}
$rootReal = realpath($rootDir);
// Optional sub-folder, sent by the gallery when uploading into the folder being browsed.
$folder = trim(isset($_GET["folder"]) ? (string)$_GET["folder"] : "", "/");
$targetDir = $rootReal;
if ($folder !== "") {
foreach (explode("/", $folder) as $part) {
if ($part === "" || $part === "." || $part === ".." || strpbrk($part, "\\:*?\"<>|") !== false) {
upload_fail("invalid folder");
}
}
$targetDir = realpath($rootReal . DIRECTORY_SEPARATOR . str_replace("/", DIRECTORY_SEPARATOR, $folder));
if ($targetDir === false || strpos($targetDir, $rootReal . DIRECTORY_SEPARATOR) !== 0) {
upload_fail("invalid folder");
}
}
// Never reuse the uploaded file name: it is chosen by whoever sends the request.
$random = function_exists("random_bytes") ? random_bytes(8) : openssl_random_pseudo_bytes(8); // PHP 7+ / older
$name = date("YmdHis") . "-" . bin2hex($random) . "." . $types[$info[2]];
if (!move_uploaded_file($file["tmp_name"], $targetDir . DIRECTORY_SEPARATOR . $name)) {
upload_fail("the server cannot write to the upload folder");
}
// A root-relative URL ("/imageuploads/..."), so the image loads on any page that shows the content.
$parts = $folder === "" ? array() : explode("/", $folder);
$parts[] = $name;
echo "READY:" . $rootUrl . "/" . implode("/", array_map("rawurlencode", $parts));What it does on purpose:
- Detects the image type from the file’s content with
getimagesize(), and ignores the file name and the browser’s claimed type. A PHP script renamed to.pngis refused. SVG is not accepted, because an SVG file can contain script that would run from your own domain. - Generates the stored name, with the extension that matches the real type. Never build a path from
$_FILES["fileforphp"]["name"], which is chosen by whoever sends the request. - Accepts an optional
folderand refuses anything outside the upload folder.
Add your sign-in check at the TODO. Without one, anyone on the internet can store files on your server.
Register it on the page
Include the sample’s rte-upload.js after the editor scripts. It posts the file as the form field fileforphp to the URL on its first line, var uploadhandlerpath = "/rte-upload.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-upload.js"></script> <!-- defines rte_file_upload_handler -->
<div id="div_editor1"></div>
<script>
var config = {};
config.galleryEndpoint = "/rte-gallery.php"; // step 3b
var editor1 = new RichTextEditor("#div_editor1", config);
</script>rte-upload.js defines a global function, rte_file_upload_handler. Keep it global. The editor also accepts config.file_upload_handler, but it must be a functionwith the same signature, not a URL string, and the gallery’s Upload button only looks for the global function.
2. Where images are saved, and which URL is stored
- The folder is
$rootDir, theimageuploadsfolder next to the script. The web server’s user needs write permission on it. - The URL is
$rootUrlplus the file name, such as/imageuploads/20260915093000-3f2a….png. It starts with/, so it works on every page that shows the content. The sample returnsimageuploads/…without the slash, which breaks as soon as the content is displayed from a page in another directory.
If the site lives in a sub-directory such as /app/, set $rootUrl = "/app/imageuploads"; and change uploadhandlerpath and galleryEndpoint to match.
Stop scripts running in the upload folder. Even with the content check, make sure the web server never executes a file from it. On Apache, add this .htaccess to imageuploads/:
# .htaccess in imageuploads/
php_flag engine off
<FilesMatch "\.ph(p[0-9]?|tml|ar)$">
Require all denied
</FilesMatch>Size limits. The handler refuses files over 4,000,000 bytes, but PHP’s upload_max_filesizedefaults to 2 MB, so a 3 MB image is dropped by PHP before the handler runs and the editor reports “no file received”. Raise both settings in php.ini:
upload_max_filesize = 8M
post_max_size = 10MWhen the content is displayed on another domain, such as in an email, it needs full URLs. The urlType option rewrites every src and href in the HTML the editor returns:
var config = {};
config.urlType = "absolute"; // "default" | "absolute" | "relative""absolute" turns /imageuploads/a.png into https://your-site/imageuploads/a.png, using the address of the page the editor is on; "relative" does the reverse.
3a. The gallery from a fixed list
With no endpoint configured, the gallery shows config.galleryImages. Replace the default: out of the box it lists six demo pictures hosted on richtexteditor.com.
var config = {};
config.galleryImages = [
"/imageuploads/logo.png",
["/imageuploads/team.jpg", "Team photo", "Taken March 2026"],
{ url: "/imageuploads/banner.webp", thumbnail: "/imageuploads/thumbs/banner.webp", name: "Homepage banner" }
];Each entry is a URL, an array of URL, name and description, or an object with url and optional thumbnail, name and meta. You can also build the list in PHP, for example by echoing json_encode() of the files in a folder.
3b. The gallery from a server folder
Set config.galleryEndpoint and the dialog becomes a folder browser. Save this as rte-gallery.php:
<?php
// Gallery endpoint for RichTextEditor's Insert Gallery dialog (config.galleryEndpoint).
//
// GET rte-gallery.php?folder=a/b list that folder
// POST action=create-folder, folder=a/b, name=New create a sub-folder, then list it
header("Content-Type: application/json; charset=utf-8");
// TODO: check that the current user is allowed to browse images and create folders.
$rootDir = __DIR__ . DIRECTORY_SEPARATOR . "imageuploads"; // same folder as rte-upload.php
$rootUrl = "/imageuploads";
$imageExts = array("jpg", "jpeg", "jfif", "png", "gif", "webp", "bmp", "avif");
function gallery_fail($status, $message)
{
http_response_code($status);
echo json_encode(array("error" => $message));
exit;
}
function gallery_valid_name($part)
{
return $part !== "" && $part !== "." && $part !== ".." && strpbrk($part, "/\\:*?\"<>|") === false;
}
// The real directory for "a/b" inside the root, or false if it is invalid, missing, or outside the root.
function gallery_dir($rootReal, $folder)
{
if ($folder === "") {
return $rootReal;
}
foreach (explode("/", $folder) as $part) {
if (!gallery_valid_name($part)) {
return false;
}
}
$dir = realpath($rootReal . DIRECTORY_SEPARATOR . str_replace("/", DIRECTORY_SEPARATOR, $folder));
return ($dir !== false && is_dir($dir) && strpos($dir, $rootReal . DIRECTORY_SEPARATOR) === 0) ? $dir : false;
}
function gallery_url($rootUrl, $folder, $name)
{
$parts = $folder === "" ? array() : explode("/", $folder);
$parts[] = $name;
return $rootUrl . "/" . implode("/", array_map("rawurlencode", $parts));
}
if (!is_dir($rootDir)) {
mkdir($rootDir, 0755, true);
}
$rootReal = realpath($rootDir);
if ($_SERVER["REQUEST_METHOD"] === "POST") {
if ((isset($_POST["action"]) ? $_POST["action"] : "") !== "create-folder") {
gallery_fail(400, "unsupported action");
}
$parent = trim(isset($_POST["folder"]) ? (string)$_POST["folder"] : "", "/");
$name = trim(isset($_POST["name"]) ? (string)$_POST["name"] : "");
$parentDir = gallery_dir($rootReal, $parent);
if ($parentDir === false || !gallery_valid_name($name)) {
gallery_fail(400, "invalid folder name");
}
$newDir = $parentDir . DIRECTORY_SEPARATOR . $name;
if (!is_dir($newDir) && !mkdir($newDir, 0755)) {
gallery_fail(500, "the server cannot create the folder");
}
$folder = $parent === "" ? $name : $parent . "/" . $name;
} else {
$folder = trim(isset($_GET["folder"]) ? (string)$_GET["folder"] : "", "/");
}
$dir = gallery_dir($rootReal, $folder);
if ($dir === false) {
$folder = "";
$dir = $rootReal;
}
$folders = array();
$images = array();
foreach (scandir($dir) as $entry) {
if ($entry === "." || $entry === "..") {
continue;
}
$path = $dir . DIRECTORY_SEPARATOR . $entry;
if (is_dir($path)) {
$folders[] = array("name" => $entry, "folder" => $folder === "" ? $entry : $folder . "/" . $entry);
} elseif (in_array(strtolower(pathinfo($entry, PATHINFO_EXTENSION)), $imageExts, true)) {
$images[] = array(
"name" => $entry,
"folder" => $folder,
"size" => filesize($path),
"url" => gallery_url($rootUrl, $folder, $entry),
);
}
}
$slash = strrpos($folder, "/");
echo json_encode(array(
"currentFolder" => $folder,
"currentFolderDisplay" => "/" . $folder,
"parentFolder" => $folder === "" ? null : ($slash === false ? "" : substr($folder, 0, $slash)),
"folders" => $folders,
"images" => $images,
));The dialog sends GET ?folder=Products to list a folder, and a POST with action=create-folder, folder and name to create one. Both return:
{
"currentFolder": "Products",
"currentFolderDisplay": "/Products",
"parentFolder": "",
"folders": [ { "name": "Summer 2026", "folder": "Products/Summer 2026" } ],
"images": [ { "name": "a.png", "folder": "Products", "size": 9, "url": "/imageuploads/Products/a.png" } ]
}parentFolder is null at the top level, which disables Up. If the endpoint fails, the dialog reports it and falls back to galleryImages.
Uploading into the folder being browsed
The gallery’s Upload button normally uses rte_file_upload_handler, so every file lands in the top-level folder. To upload into the folder the user is looking at, also define window.richTextBoxUploadFile. The gallery prefers it and passes the current folder:
// Lets the Insert Gallery dialog upload into the folder being browsed.
// Without this, the gallery's Upload button uses rte_file_upload_handler and
// every file lands in the top-level upload folder.
window.richTextBoxUploadFile = function (file, callback, options) {
var folder = (options && options.folder) || "";
var data = new FormData();
data.append("fileforphp", file);
var xh = new XMLHttpRequest();
xh.open("POST", "/rte-upload.php?folder=" + encodeURIComponent(folder), true);
xh.onload = function () {
var text = xh.responseText || "";
if (xh.status === 200 && text.indexOf("READY:") === 0) callback(text.substring(6));
else callback(null, text.indexOf("ERROR:") === 0 ? text.substring(6) : "http-" + xh.status);
};
xh.onerror = function () { callback(null, "network-error"); };
xh.send(data);
};Keep rte-upload.js too: the image dialog, paste and drag-and-drop still use it.
Checklist
rte-upload.jsincluded, withrte_file_upload_handlerkept global.- Image URLs that start with
/, including any sub-directory. - No script execution in
imageuploads/, and write permission for the web server. upload_max_filesizeandpost_max_sizeabove your largest image.galleryImagesreplaced, orgalleryEndpointset.- A sign-in check in
rte-upload.phpandrte-gallery.php.
Both scripts were run on PHP 5.6 and tested over HTTP: an image upload served back byte for byte, fake-image and SVG uploads refused, folder traversal refused, folder creation, uploads into a sub-folder, non-image files left out of listings, and the gallery JSON. They use nothing removed in PHP 8.
Related: adding a rich text editor to a PHP application, what an upload handler has to get right, and the upload handler reference.
RichTextEditor is a perpetual-licence JavaScript editor — one purchase, self-hosted, no metered editor loads. Download the evaluation or see how it compares.