中級 PHP 画像アップロード セキュリティ

公開フォルダの外に画像を保存して、PHP 経由で表示する

アップロードされた画像を、ブラウザから直接アクセスできない「非公開フォルダ」に保存し、PHP で読み出して表示します。会員だけに見せたい画像などに使える考え方です。

このサンプルは2017〜2018年ごろに作成したものです。PHPのバージョンが上がり、そのままでは動かなくなっていたため、2026年に PHP 8.3 で動くように書き直し、解説も新しくしました。

DEMO 実際に動かしてみよう

アップロード画面 | 保存した画像を見る(0枚)

GIF・JPEG・PNG、1MBまで。保存できるのは5枚までです(あと5枚)。
※ 保存した画像は、見に来たすべての人の画面に表示されます。人に見られて困る画像は送らないでください。

解説コードを読んでみよう

このサンプルでわかること

  • 「公開フォルダ」と「非公開フォルダ」の違いと、非公開にするメリット
  • 1枚の画像を受け取って、安全にチェック・保存する方法
  • PHP でファイルを読み込み、header() と readfile() で画像としてブラウザに送る方法
  • ../ を使ってほかのファイルを読まれないようにする対策

そもそも「公開フォルダの外」とは?

レンタルサーバーには、たいてい public_html のような公開フォルダがあります。この中に置いたファイルは、URL を知っていれば誰でもブラウザで直接見られます。

フォルダの構成 TEXT
/home/ユーザー名/example.com/
├── public_html/              ← 公開フォルダ(https://example.com/ で見える)
│   └── sample/php/uploader_outside/
│       ├── index.php
│       └── img.php           ← 画像はこの PHP を通して表示する
└── private_file_storage/     ← 公開フォルダの外(URL では見られない)
    └── php_uploader_outside/
        └── 7f3a…c9.jpg       ← アップロードされた画像はここ

画像を公開フォルダの外に置けば、URL で直接見ることはできません。表示するときは、PHP(img.php)がファイルを読み込んでブラウザに渡します。PHP を通るので、「ログインしている人だけに見せる」「回数を数える」といった処理を途中にはさめるのがメリットです。

使っているファイル

  • index.phpアップロード画面
  • upload.php画像をチェックして、非公開フォルダに保存する
  • view.php保存した画像の一覧(画像は img.php 経由で表示)
  • img.php非公開フォルダの画像を読み込んでブラウザに送る
  • delete.php「全て削除する」ボタンの処理
  • define.php保存先のフォルダ(IMAGES_DIR)を決める
  • common.php共通の設定(最大枚数など)と関数

処理の流れ

  1. index.php で画像を選んで送信
  2. upload.php がチェックして非公開フォルダに保存し、view.php へ移動
  3. view.php の <img src="img.php?img=ファイル名"> を見たブラウザが、img.php に画像をリクエスト
  4. img.php が非公開フォルダからファイルを読み、画像として返す

1. 保存先を決める(define.php)

$_SERVER['DOCUMENT_ROOT'] は公開フォルダの場所です。dirname() でその1つ上のフォルダを求め、そこに保存用のフォルダを作ります。

define.php PHP
<?php
/*
 * 画像の保存先(公開フォルダの外 = ブラウザから直接見えない場所)
 * 例: /home/ユーザー名/example.com/public_html がサイトの公開フォルダなら、
 *     /home/ユーザー名/example.com/private_file_storage/php_uploader_outside/ に保存します。
 * 場所を変えたいときは template-parts/config.php の PRIVATE_DIR に書きます。
 */
require_once __DIR__ . '/../../template-parts/config.php';

$dir = PRIVATE_DIR !== '' ? PRIVATE_DIR
     : dirname($_SERVER['DOCUMENT_ROOT']) . '/private_file_storage/php_uploader_outside/';

define('IMAGES_DIR', rtrim($dir, '/') . '/');

// フォルダがなければ作る
if (!is_dir(IMAGES_DIR)) {
    @mkdir(IMAGES_DIR, 0755, true);
}

2. 送信フォーム(index.php)

1枚だけ送るので、name="img" には [] を付けません。その場合、$_FILES['img'] は配列の配列ではなく、1枚分の情報がそのまま入ります。

index.php(23〜27行目) PHP
  <form action="upload.php" method="post" enctype="multipart/form-data">
    <input type="hidden" name="MAX_FILE_SIZE" value="1048576">
    <p><input type="file" name="img" accept="image/*" required></p>
    <input type="submit" value="非公開フォルダに保存する">
  </form>
送信内容の例 TEXT
// print_r($_FILES);
Array
(
    [img] => Array
        (
            [name]     => penguin.jpg
            [type]     => image/jpeg
            [tmp_name] => /tmp/phpX7k2Qa
            [error]    => 0
            [size]     => 183205
        )
)

3. チェックして保存する(upload.php)

チェックは「ファイルが届いたか」「大きすぎないか」「もう5枚あるか」「本当に画像か」の順に行い、問題があればエラー画面を出して終わります。

upload.php(12〜48行目) PHP
// ---- チェック ------------------------------------------------
$file = $_FILES['img'] ?? null;   // name="img"(1枚だけなので配列ではない)

if ($file === null || $file['error'] === UPLOAD_ERR_NO_FILE) {
    show_error_page('画像が選ばれていません。');
}
if ($file['error'] === UPLOAD_ERR_INI_SIZE || $file['error'] === UPLOAD_ERR_FORM_SIZE || $file['size'] > MAX_SIZE) {
    show_error_page('ファイルサイズが大きすぎます(1MBまで)。');
}
if ($file['error'] !== UPLOAD_ERR_OK) {
    show_error_page('アップロードに失敗しました(エラー番号 ' . $file['error'] . ')。');
}
if (count(image_list()) >= MAX_IMAGES) {
    show_error_page('すでに' . MAX_IMAGES . '枚保存されています。「全て削除する」を押してから試してください。');
}

// 拡張子ではなく、ファイルの中身から画像の種類を調べる
$info = @getimagesize($file['tmp_name']);
$ext  = match ($info['mime'] ?? '') {
    'image/gif'  => '.gif',
    'image/jpeg' => '.jpg',
    'image/png'  => '.png',
    default      => null,
};
if ($ext === null) {
    show_error_page('画像ファイル(GIF・JPEG・PNG)ではありません。');
}

// ---- 保存 ----------------------------------------------------
$name = sha1(random_bytes(16)) . $ext;   // 他と重ならないランダムな名前

if (!move_uploaded_file($file['tmp_name'], IMAGES_DIR . $name)) {
    show_error_page('画像を保存できませんでした。');
}

header('Location: view.php');
exit;
  • getimagesize() でファイルの中身を調べ、GIF・JPEG・PNG 以外は受け付けません。拡張子は送る人が自由に変えられるので当てになりません。
  • 保存する名前は sha1(random_bytes(16)) で作ったランダムな40文字です。元のファイル名は使いません。
  • 枚数の上限(5枚)は画面でも案内していますが、upload.php でも必ず確認します。画面のチェックだけだと、フォームを使わずに直接送られたときに防げないからです。

4. 画像の一覧を作る(common.php・view.php)

scandir() でフォルダの中身を取り出し、このサンプルが保存した形式のファイル名だけを残して、新しい順に並べます。

common.php(11〜24行目) PHP
/** このサンプルが保存したファイル名か?(ランダムな40文字+拡張子) */
function is_image_name(string $name): bool
{
    return preg_match('/\A[0-9a-f]{40}\.(jpg|png|gif)\z/', $name) === 1;
}

/** 保存済みの画像ファイル名の一覧(新しい順) */
function image_list(): array
{
    $files = is_dir(IMAGES_DIR) ? scandir(IMAGES_DIR) : [];
    $files = array_values(array_filter($files, 'is_image_name'));  // . や .. などを除く
    usort($files, fn($a, $b) => filemtime(IMAGES_DIR . $b) <=> filemtime(IMAGES_DIR . $a));
    return $files;
}

一覧の <img> は、画像ファイルの場所ではなく img.php?img=ファイル名 を指しています。

view.php(21〜27行目) PHP
<div class="gallery">
<?php foreach ($list as $name): ?>
  <figure>
    <!-- 画像の場所は公開されていないので、img.php を通して表示する -->
    <a href="img.php?img=<?= urlencode($name) ?>" target="_blank"><img src="img.php?img=<?= urlencode($name) ?>" alt=""></a>
    <figcaption><?= h(number_format(filesize(IMAGES_DIR . $name) / 1024, 1)) ?> KB</figcaption>
  </figure>

5. PHP から画像を送る(img.php)

ここがこのサンプルの中心です。ブラウザに「これは画像です」と伝える header() を送ってから、readfile() でファイルの中身をそのまま出力します。

img.php PHP
<?php
/*
 * 非公開フォルダの画像を読み込んで、ブラウザに送る
 * 使い方: <img src="img.php?img=ファイル名">
 */
require_once __DIR__ . '/common.php';

// ファイル名の形式を確認する(../ などで他のファイルを読み出されないように)
$name = (string)($_GET['img'] ?? '');
if (!is_image_name($name) || !is_file(IMAGES_DIR . $name)) {
    http_response_code(404);
    exit;
}
$path = IMAGES_DIR . $name;

// ここに「ログインしているか」などのチェックを入れれば、会員だけに見せる画像にできる

$type = image_type_to_mime_type(exif_imagetype($path));  // 例: image/jpeg

header('Content-Type: ' . $type);                          // 画像の種類
header('Content-Length: ' . filesize($path));              // ファイルサイズ
header('Content-Disposition: inline; filename="' . $name . '"');  // ダウンロードさせるなら attachment
header('X-Content-Type-Options: nosniff');
readfile($path);                                           // 画像の中身を出力
ヘッダー意味
Content-Typeデータの種類。image/jpeg なら、ブラウザは JPEG 画像として表示します
Content-Lengthデータの大きさ(バイト)
Content-Dispositioninline は画面に表示、attachment にするとダウンロードになります

img.php は URL の ?img= で指定されたファイルを読みます。もし何もチェックしなければ、img.php?img=../../../etc/passwd のように ../(1つ上のフォルダ)を使って、サーバー上の別のファイルを読み出されるおそれがあります。そこで 10行目で「40文字の英数字+拡張子」という決まった形のファイル名だけを受け付けています。旧版にはこのチェックがありませんでした。

16行目に、たとえば session_start(); と if (empty($_SESSION['user_id'])) { http_response_code(403); exit; } を入れれば、ログインしている人だけに見せる画像になります。画像を公開フォルダの外に置く本当の目的はここにあります。

6. まとめて削除する(delete.php)

delete.php(8〜16行目) PHP
// 削除は POST のときだけ(リンクをたどっただけで消えないように)
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    foreach (image_list() as $name) {
        unlink(IMAGES_DIR . $name);
    }
}

header('Location: index.php');
exit;

削除は POST で送られたときだけ行い、消すのは image_list() で選んだ「このサンプルが保存したファイル」だけです。同じフォルダにほかのファイルがあっても消しません。

PHP 8.3 で書き直したポイント

  • 保存先フォルダがないと止まっていた … 旧版はフォルダがないと scandir() が false を返し、それを count() に渡していました。PHP 8 ではこれがエラーになるため、フォルダがなければ作り、一覧は必ず配列で返すようにしました。
  • 未定義の変数 … 旧版の img.php には、使われていない変数 $filename がありました(PHP 8 では警告が出ます)。
  • 読み出しの安全対策 … 上で説明した ../ 対策を追加しました。
  • エラー画面 … 旧版はエラーの文字だけが表示されていましたが、サイトの画面の中でメッセージを出し、戻るボタンを付けました。

img.php の inline を attachment に変えて、一覧の画像をクリックしたときの動きがどう変わるか試してみましょう(画像を表示する代わりにダウンロードが始まります)。

↑