Bilder aus einem Rich-Text-Editor in PHP hochladen

Ein Bild in RichTextEditor, nachdem es auf den PHP-Beispielserver hochgeladen wurde, darüber die vollständige Symbolleiste

Wenn jemand im Editor ein Bild einfügt, muss der Browser es irgendwohin senden und eine URL für das <img> zurückerhalten. Diese Seite liefert Ihnen diesen Endpoint in reinem PHP 8: ein Skript, das die Datei aus $_FILES übernimmt, sie prüft, mit move_uploaded_file() speichert und mit JSON antwortet. Sie brauchen kein Framework und keine zusätzlichen PHP-Erweiterungen.

Den Editor mit Ihrem Server verbinden

Die Seite lädt den Editor und eine kleine Browserdatei, rte-files.js, die jedes Bild als multipart/form-data an /rte/upload sendet. Der Galerie-Dialog lädt damit in den geöffneten Ordner hoch. Auch Drag & Drop, Einfügen und der Dialog „Insert Image“ verwenden sie; diese Uploads haben keinen Ordner und landen im Stammordner von 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>

Mit maxUploadFileSize kann der Galerie-Dialog eine zu große Datei ablehnen, bevor sie gesendet wird. Der Server prüft die Größe erneut, weil eine Prüfung im Browser nur eine Komfortfunktion ist.

Die Route

Der eingebaute Server von PHP braucht ein Router-Skript, das saubere URLs auf Dateien abbildet. Im Beispielprojekt leitet router.php /rte/upload an rte/upload.php weiter und liefert gespeicherte Bilder aus /uploads/ aus:

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

Der Router gibt nie false zurück. Täte er das, würde der eingebaute Server den Request selbst behandeln, und er führt jede .php-Datei aus, die er unter dem Document Root findet, auch eine, die in uploads/ gelandet ist. Wir haben das mit einem einzeiligen Router ausprobiert, der false zurückgibt: Ein Request an /uploads/probe.php führte das Skript aus. Unter Apache verwendet das Beispiel stattdessen Rewrite-Regeln in .htaccess, unter nginx exakte location-Blöcke. Die Anleitung zu sicheren Uploads zeigt beides.

Was PHP Ihnen übergibt

Bevor Ihr Skript läuft, hat PHP den Request bereits geparst und die Datei in einen temporären Ordner geschrieben. $_FILES['file'] sagt Ihnen, wie das verlaufen ist, und das Feld error ist das Erste, was Sie prüfen sollten:

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.');
}

Zwei Details hier sind PHP-spezifisch. Ist der Request größer als post_max_size, verwirft PHP den gesamten Body: $_POST und $_FILES sind beide leer, und das Skript muss CONTENT_LENGTH mit dem Limit vergleichen, um den Grund zu erfahren. Und upload_max_filesize ist standardmäßig 2M, also kleiner als das Limit von 4,000,000 Bytes in diesem Beispiel. Die Anleitung zu Größenlimits behandelt das alles.

Was der Server prüft

Der Name bestimmt die Dateiendung, und die Bytes müssen dazu passen. $_FILES['file']['type'] ist das, was der Client in den Request geschrieben hat, und wird deshalb ignoriert.

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() gehört zum PHP-Kern. Es braucht weder GD noch fileinfo.

Wo die Dateien landen

Der Ordner aus dem Request ist ein virtueller Pfad wie blog oder products/2026, niemals ein Pfad auf der Festplatte. Er wird zuerst innerhalb des Stammordners aufgelöst. Danach wird die Datei unter einem generierten Namen gespeichert, sodass sich zwei Uploads namens photo.jpg nie gegenseitig überschreiben:

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])];

Die Antwort lautet {"url": "/uploads/blog/harbor-2839881c.jpg"}, und der Editor fügt diese URL ein. Bei einem Fehler antwortet das Skript mit 400 oder 413 und {"error": "..."}.

Diagramm: Der Browser sendet an /rte/upload, PHP füllt $_FILES, upload.php prüft Dateiendung, Signatur und getimagesize(), move_uploaded_file() speichert die Datei, der Server antwortet mit der URL
Jede Prüfung findet vor move_uploaded_file() statt. Ein Fehler geht als JSON an den Browser zurück.

Wenn ein Upload fehlschlägt, zeigt der Galerie-Dialog derzeit seine eigene Meldung „Upload failed for holiday.jpg.“ an, nicht den Text des Servers. Der Grund des Servers steht trotzdem im Body der Antwort, wo Sie ihn in den Entwicklertools des Browsers sehen können.

Testen

Laden Sie das Beispielprojekt zu dieser Anleitung herunter, legen Sie den Ordner richtexteditor aus dem Editor-Download neben index.php und starten Sie den eingebauten Server von PHP:

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

Laden Sie dann mit curl ein Bild hoch, einmal in den Stammordner und einmal in den Ordner 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"}

Der Ordner muss bereits existieren. Öffnen Sie http://localhost:5201/ und klicken Sie auf Insert Gallery: Dort sind die hochgeladenen Dateien aufgeführt. Das Beispiel besteht auch den gemeinsamen Vertragstest, den das Beispiel jeder Plattform durchläuft: node shared/contract-test.mjs http://localhost:5201 endet mit ALL 35 CHECKS PASSED.

Dieselbe Anleitung für andere Plattformen

JavaScript · Next.js · ASP.NET Core · Razor Pages · Blazor · MVC 5 · Web Forms

Weiterlesen