PHP+MySQLで簡易画像アップローダ

# **前書き**

はい、また出ました **[アップロードシリーズ](http://qiita.com/mpyw/items/939964377766a54d4682)**！$n$番煎じとか言わないで！

今まで通りファイル1つで全部の処理を行う方針で書きました。原寸大画像・サムネイルともにファイルとしてではなく<ins>データベース内に保存する</ins>のがポイントです。サムネイル表示に先日の **[「QiitaにXSS脆弱性」](http://qiita.com/mpyw/items/8367f4e0983e734294da)** でも話題になった **データURIスキーム** を利用していることに着目してください。

![ss (2014-04-06 at 12.26.20).png](https://qiita-image-store.s3.amazonaws.com/0/25060/4d203b32-bdc0-e512-121c-6bd75b84fae4.png)

# **実装**

## **テーブル定義**

```sql
CREATE TABLE image(
  `id` int UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT COMMENT 'ID',
  `name` varchar(255) NOT NULL COMMENT 'ファイル名',
  `type` tinyint(2) NOT NULL COMMENT 'IMAGETYPE定数',
  `raw_data` mediumblob NOT NULL COMMENT '原寸大データ',
  `thumb_data` blob NOT NULL COMMENT 'サムネイルデータ',
  `date` datetime NOT NULL COMMENT '日付'
) DEFAULT CHARACTER SET utf8 COLLATE utf8_general_ci 
```

## **ソースコード** 

```html+php
<?php

/* HTML特殊文字をエスケープする関数 */
function h($str) {
    return htmlspecialchars($str, ENT_QUOTES, 'UTF-8');
}

// XHTMLとしてブラウザに認識させる
// (IE8以下はサポート対象外ｗ)
header('Content-Type: application/xhtml+xml; charset=utf-8');

try {

    // データベースに接続
    $pdo = new PDO(
        'mysql:host=localhost;dbname=imagedb;charset=utf8',
        'root',
        '',
        [
            PDO::ATTR_EMULATE_PREPARES => false,
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        ]
    );

    /* アップロードがあったとき */
    if (isset($_FILES['upfile']['error']) && is_int($_FILES['upfile']['error'])) {

        // バッファリングを開始
        ob_start();

        try {

            // $_FILES['upfile']['error'] の値を確認
            switch ($_FILES['upfile']['error']) {
                case UPLOAD_ERR_OK: // OK
                    break;
                case UPLOAD_ERR_NO_FILE:   // ファイル未選択
                    throw new RuntimeException('ファイルが選択されていません', 400);
                case UPLOAD_ERR_INI_SIZE:  // php.ini定義の最大サイズ超過
                case UPLOAD_ERR_FORM_SIZE: // フォーム定義の最大サイズ超過
                    throw new RuntimeException('ファイルサイズが大きすぎます', 400);
                default:
                    throw new RuntimeException('その他のエラーが発生しました', 500);
            }

            // $_FILES['upfile']['mime']の値はブラウザ側で偽装可能なので
            // MIMEタイプを自前でチェックする
            if (!$info = @getimagesize($_FILES['upfile']['tmp_name'])) {
                throw new RuntimeException('有効な画像ファイルを指定してください', 400);
            }
            if (!in_array($info[2], [IMAGETYPE_GIF, IMAGETYPE_JPEG, IMAGETYPE_PNG], true)) {
                throw new RuntimeException('未対応の画像形式です', 400);
            }

            // サムネイルをバッファに出力
            $create = str_replace('/', 'createfrom', $info['mime']);
            $output = str_replace('/', '', $info['mime']);
            if ($info[0] >= $info[1]) {
                $dst_w = 120;
                $dst_h = ceil(120 * $info[1] / max($info[0], 1));
            } else {
                $dst_w = ceil(120 * $info[0] / max($info[1], 1));
                $dst_h = 120;
            }
            if (!$src = @$create($_FILES['upfile']['tmp_name'])) {
                throw new RuntimeException('画像リソースの生成に失敗しました', 500);
            }
            $dst = imagecreatetruecolor($dst_w, $dst_h);
            imagecopyresampled($dst, $src, 0, 0, 0, 0, $dst_w, $dst_h, $info[0], $info[1]);
            $output($dst);
            imagedestroy($src);
            imagedestroy($dst);

            // INSERT処理
            $stmt = $pdo->prepare('INSERT INTO image(name,type,raw_data,thumb_data,date) VALUES(?,?,?,?,?)');
            $stmt->execute([
                $_FILES['upfile']['name'],
                $info[2],
                file_get_contents($_FILES['upfile']['tmp_name']),
                ob_get_clean(), // バッファからデータを取得してクリア
                (new DateTime('now', new DateTimeZone('Asia/Tokyo')))->format('Y-m-d H:i:s'),
            ]);

            $msgs[] = ['green', 'ファイルは正常にアップロードされました'];

        } catch (RuntimeException $e) {

            while (ob_get_level()) {
                ob_end_clean(); // バッファをクリア
            }
            http_response_code($e instanceof PDOException ? 500 : $e->getCode());
            $msgs[] = ['red', $e->getMessage()];

        }

    /* ID指定があったとき */
    } elseif (isset($_GET['id'])) {

        try {

            $stmt = $pdo->prepare('SELECT type, raw_data FROM image WHERE id = ? LIMIT 1');
            $stmt->bindValue(1, $_GET['id'], PDO::PARAM_INT);
            $stmt->execute();
            if (!$row = $stmt->fetch()) {
                throw new RuntimeException('該当する画像は存在しません', 404);
            }
            header('X-Content-Type-Options: nosniff');
            header('Content-Type: ' . image_type_to_mime_type($row['type']));
            echo $row['raw_data'];
            exit;

        } catch (RuntimeException $e) {

            http_response_code($e instanceof PDOException ? 500 : $e->getCode());
            $msgs[] = ['red', $e->getMessage()];

        }

    }

    // サムネイル一覧取得
    $rows = $pdo->query('SELECT id,name,type,thumb_data,date FROM image ORDER BY date DESC')->fetchAll();

} catch (PDOException $e) {
    
    http_response_code(500);
    $msgs[] = ['red', $e->getMessage()];
    
}

?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
  <title>画像アップロード</title>
  <style><![CDATA[
    fieldset { margin: 10px; }
    legend { font-size: 12pt; }
    img {
        border: none;
        float: left;
    }
  ]]></style>
</head>
<body>
  <form enctype="multipart/form-data" method="post" action="">
    <fieldset>
      <legend>画像ファイルを選択(GIF, JPEG, PNGのみ対応)</legend>
      <input type="file" name="upfile" /><br />
      <input type="submit" value="送信" />
    </fieldset>
  </form>
<?php if (!empty($msgs)): ?>
  <fieldset>
    <legend>メッセージ</legend>
<?php foreach ($msgs as $msg): ?>
    <ul>
        <li style="color:<?=h($msg[0])?>;"><?=h($msg[1])?></li>
    </ul>
<?php endforeach; ?>
  </fieldset>
<?php endif; ?>
<?php if (!empty($rows)): ?>
   <fieldset>
     <legend>サムネイル一覧(クリックすると原寸大表示)</legend>
<?php foreach ($rows as $i => $row): ?>
<?php if ($i): ?>
     <hr />
<?php endif; ?>
     <p>
       <?=sprintf(
           '<a href="?id=%d"><img src="data:%s;base64,%s" alt="%s" /></a>',
           $row['id'],
           image_type_to_mime_type($row['type']),
           base64_encode($row['thumb_data']),
           h($row['name'])
       )?><br />
       ファイル名: <?=h($row['name'])?><br />
       日付: <?=h($row['date'])?><br clear="all" />
    </p>
<?php endforeach; ?>
   </fieldset>
<?php endif; ?>
</body>
</html>
```

# **留意点**

以下のような問題点があるのでそのまま使うことは避けてください。デザイン的にダサいから誰も使わないか（笑

- **[CSRF](http://ja.wikipedia.org/wiki/%E3%82%AF%E3%83%AD%E3%82%B9%E3%82%B5%E3%82%A4%E3%83%88%E3%83%AA%E3%82%AF%E3%82%A8%E3%82%B9%E3%83%88%E3%83%95%E3%82%A9%E3%83%BC%E3%82%B8%E3%82%A7%E3%83%AA)** 対策をしていません
- 削除機能がありません
- ページング機能がありません

# **Internet Explorer のみで起こり得る「画像XSS」を防止する**

@rana_kualuさんの以下の記事を参照してください。

[Qiita - getimagesize/finfoのMIMEタイプ判定はザルい](http://qiita.com/rana_kualu/items/aec5c3d93e9eb2c8f0ba)

IE8以降では、以下のどちらかもしくは両方を実装することによってXSSを防止することが出来ます。

```php:データ出力時に以下のようにヘッダーを送る
header('X-Content-Type-Options: nosniff');
```

```apache:.htaccessに以下のように記述する
Header set X-Content-Type-Options nosniff
```

IE7以前では相変わらず危険です…