PHP でリッチテキストエディターに画像ギャラリーを追加する

「Insert Gallery」ダイアログ。PHP のサンプルの uploads フォルダーにある blog、products、team の各フォルダーと 4 つの画像が表示され、1 つが選択されている

エディターの「Insert Gallery」ダイアログには、すでにサーバーにある画像を表示できます。そのため、同じ画像をもう一度アップロードしなくても、既存の画像から選べます。必要なのは、JSON で応答する URL 1 つだけです。このページでは、そのエンドポイントを素の PHP 8 で紹介します。scandir() で uploads/ 以下のフォルダーを読み取り、そのサブフォルダーと画像を一覧にするスクリプトです。

ギャラリーを有効にする

エディターの作成時に galleryEndpoint を設定します。すると、ダイアログは開くたび、またはフォルダーを切り替えるたびに、その URL に一覧を要求します。

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>

rte-files.js は、ダイアログからのアップロードを /rte/upload に送信する小さなブラウザー用ファイルです。そのサーバー側については、画像アップロードのガイドで紹介しています。

ルート

GET /rte/gallery?folder=blog は、1 つのフォルダーの一覧を返します。処理は 2 つのヘルパーが担っているので、スクリプトは短くなっています。

PHP · rte/gallery.php
if ($method === 'GET') {
    return rte_listing(rte_resolve_folder($_GET['folder'] ?? ''));
}

rte_resolve_folder() は、クエリ文字列の仮想フォルダーをストレージルート内の実際のディレクトリに変換するか、400 または 404 を返します。これについてはフォルダーのガイドで説明しています。rte_listing() は JSON を組み立てます。

ギャラリーが受け取るもの

ダイアログに必要なのは、現在のフォルダー、その親フォルダー(ルートでは null)、サブフォルダー、そして URL とサイズを含む画像の情報です。

PHP のサンプルで GET /rte/gallery?folder=blog が返した JSON レスポンス。currentFolder は blog、parentFolder は空、サブフォルダーはなく、url、name、folder、size を持つ画像が並んでいる
サンプルが実際に返したレスポンス。画像は 2 つに省略しています。

これを生成するコードは次のとおりです。

PHP · rte/files-lib.php
// The listing for one folder, in the shape the gallery dialog expects.
function rte_listing(array $folder): array
{
    $folders = [];
    $images = [];
    $names = scandir($folder['dir']);
    if ($names === false) {
        throw new RteHttpError(500, 'The folder cannot be read.');
    }
    foreach ($names as $name) {
        if ($name === '' || $name[0] === '.') {
            continue; // ".", "..", and hidden files such as .htaccess
        }
        $path = $folder['dir'] . DIRECTORY_SEPARATOR . $name;
        // Skip symlinks and Windows junctions. is_link() alone is not enough: PHP 8.2 on
        // Windows returns false for a junction, so also require realpath() to be unchanged.
        $real = realpath($path);
        $same = DIRECTORY_SEPARATOR === '\\' ? strcasecmp($real ?: '', $path) === 0 : $real === $path;
        if (is_link($path) || !$same) {
            continue;
        }
        $segments = [...$folder['segments'], $name];
        if (is_dir($path)) {
            if (rte_is_valid_name($name) && !rte_is_reserved($segments)) { // only folders the routes accept back
                $folders[] = ['folder' => implode('/', $segments), 'name' => $name];
            }
        } elseif (is_file($path) && in_array(rte_extension($name), RTE_IMAGE_EXTENSIONS, true)) {
            $images[] = [
                'url' => rte_url($folder['root'], $segments),
                'name' => $name,
                'folder' => $folder['virtual'],
                'size' => (int)filesize($path),
            ];
        }
    }
    $byName = fn(array $a, array $b): int => strcasecmp($a['name'], $b['name']) ?: strcmp($a['name'], $b['name']);
    usort($folders, $byName);
    usort($images, $byName);

    $virtual = $folder['virtual'];
    $parent = null;
    if ($virtual !== '') {
        $cut = strrpos($virtual, '/');
        $parent = $cut === false ? '' : substr($virtual, 0, $cut);
    }
    return [
        'currentFolder' => $virtual,
        'currentFolderDisplay' => '/' . $virtual,
        'parentFolder' => $parent,
        'folders' => $folders,
        'images' => $images,
    ];
}

この中には、知っておく価値のある判断がいくつかあります。

  • ドットで始まる名前はスキップするため、uploads/.htaccess が表示されることはありません。
  • 一覧に含めるのは .jpg .jpeg .png .gif .webp のファイルだけです。フォルダー内のそれ以外のものは表示されません。
  • シンボリックリンクと Windows のジャンクションはスキップします。当社のテストでは、Windows 上の PHP 8.2 の is_link() はジャンクションに対して false を返したため、コードでは realpath() でパスが変わらないことも確認しています。
  • 画像の URL の各部分は rawurlencode() を通すので、my photo.jpg は /uploads/products/my%20photo.jpg として一覧に表示されます。
  • フォルダーと画像は、strcasecmp() を使い、大文字と小文字を区別せずに名前順で並べます。

ダイアログが提示する形式とサーバーが受け付ける形式

リリース版のギャラリーダイアログは、受け付ける形式として「JPG, JPEG, JFIF, PNG, GIF, WEBP, BMP, AVIF」を表示し、ファイル選択画面でもそれらを選べるようにしています。このサーバーが受け付けるのは、そのうち .jpg .jpeg .png .gif .webp の 5 つです。.bmp ファイルを選ぶと、ダイアログはそれを送信し、サーバーは 400 {"error":"Only .jpg, .jpeg, .png, .gif and .webp images can be uploaded."} を返し、ダイアログには「Upload failed for photo.bmp.」と表示されます。サーバーが示した理由はレスポンスの本文に含まれているので、ブラウザーの開発者ツールで確認できます。

サイズの場合は事情が異なります。maxUploadFileSize: 4000000 を指定すると、ダイアログは大きなファイルを送信前に拒否します。4.5 MB のファイルでは「big.jpg is 4.3 MB. The maximum allowed size is 3.8 MB.」と表示されました(ダイアログは 2 進数のメガバイトで計算します)。

画像の配信

一覧は /uploads/... を指しているので、それらの URL でファイルを返す必要があります。サンプルプロジェクトでは、PHP の組み込みサーバー向けに router.php がそれらを配信します。画像の拡張子のものだけを、固定の Content-Type と X-Content-Type-Options: nosniff を付けて送信し、そのフォルダーにあるものを実行することは決してありません。Apache や nginx では、Web サーバーが直接配信します。安全なアップロードのガイドで紹介しているとおり、実行を禁止するルールは必ず残しておいてください。

テストする

このガイドのサンプルプロジェクトを php -d upload_max_filesize=5M -d display_errors=0 -d log_errors=1 -S localhost:5201 router.php で起動し、一覧を要求します。

Shell
curl "http://localhost:5201/rte/gallery?folder=team"
{"currentFolder":"team","currentFolderDisplay":"/team","parentFolder":"","folders":[],"images":[]}

curl "http://localhost:5201/rte/gallery?folder=blog/2027"
{"error":"Folder not found."}

2 つ目のリクエストは HTTP 404 になります。http://localhost:5201/ を開いて「Insert Gallery」をクリックすると、ダイアログにルートのフォルダーと画像が表示され、フォルダーをクリックするとそれが開きます。

ほかのプラットフォーム向けの同じガイド

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

次に読む