k-holyのPHPとか諸々メモ

Webで働くk-holyがPHP(スクリプト言語)とか諸々のことをメモしていきます。ソースコードはだいたいWindowsで動かしてます。

マイクロフレームワークをつくろう - Pimpleの上に(とりあえずフォーム編)

Pimpleを拡張して自分好みに使うために作成した小さなアプリケーションクラスを使って、マイクロフレームワークっぽいものを作る試みです。

コードはWindows版 PHP5.4 ビルトインWebサーバにて動作確認しています。

準備編 アプリケーションクラスをComposerのクラスローダに登録

前回は省略しましたが、ApplicationクラスはPimpleに依存しており、Pimpleの配置にはComposerパッケージマネージャを利用しています。

今後のことも考えて、Applicationクラスの名前空間 Acme を src ディレクトリに設定して、Composerのオートロードを有効にします。

{
    "license": "MIT",
    "authors": [
        {
            "name": "k-holy",
            "email": "k.holy74@gmail.com"
        }
    ],
    "config": {
            "vendor-dir": "vendor"
    },
    "autoload": {
        "psr-0": {
            "Acme":"src"
        }
    },
    "require": {
        "php": ">=5.4",
        "pimple/pimple": "1.0.2"
    }
}

Step 1 リクエスト変数取得

ApplicationクラスにはWeb特有の処理が何も含まれていませんが、PHPにはスーパーグローバル変数という強い味方がおります。

まずは何も考えずに、GET|POST|COOKIE|SERVERからパラメータを取得するための関数をアプリケーションオブジェクトに登録してみます。

filter_input() の利用も検討しましたが、いわゆるサニタイズとバリデーションがごっちゃになった関数で、フィルタフラグもバリデーション用途以外では微妙に使いにくそうなのでやめました。

(filter_var() の方はバリデーションで便利に使えるかもしれません。メジャーなフレームワークの中にもSymfonyのHttpFoundationコンポーネントや、CakePHP, Lithium, fuelPHP等で使われているようです。)

www/index.php

<?php
/**
 * Create my own framework on top of the Pimple
 *
 * Step 1
 *
 * @copyright 2011-2013 k-holy <k.holy74@gmail.com>
 * @license The MIT License (MIT)
 */
include_once realpath(__DIR__ . '/../vendor/autoload.php');

use Acme\Application;

$app = new Application();

// リクエスト変数を取得する
$app->findVar = $app->protect(function($key, $name, $default = null) {
    $value = null;
    switch ($key) {
    // $_GET
    case 'G':
        $value = (isset($_GET[$name])) ? $_GET[$name] : null;
        break;
    // $_POST
    case 'P':
        $value = (isset($_POST[$name])) ? $_POST[$name] : null;
        break;
    // $_COOKIE
    case 'C':
        $value = (isset($_COOKIE[$name])) ? $_COOKIE[$name] : null;
        break;
    // $_SERVER
    case 'S':
        $value = (isset($_SERVER[$name])) ? $_SERVER[$name] : null;
        break;
    }
    if (!isset($value) ||
        (is_string($value) && strlen($value) === 0) ||
        (is_array($value) && count($value) === 0)
    ) {
        $value = $default;
    }
    return $value;
});
// ?name=foo
// ?name=<script>alert('hello')</script>
?>
<html>
<body>
<h1>test</h1>
<p><?=$app->findVar('G', 'name')?></p>
</body>
</html>

このスクリプトをビルトインWebサーバで動かして ?name=foo でリクエストしたところ、foo と表示されました。

Step 2 HTMLエスケープ

リクエスト変数の取得はできましたが、このままでは ?name=foo<script>alert('hello!')</script> とリクエストされた場合に hello! されてしまいます。

HTMLエスケープを行う関数をアプリケーションオブジェクトに登録します。

ついでに、配列やTraversableなオブジェクトにも対応した作りにしておきます。

www/index.php

<?php
/**
 * Create my own framework on top of the Pimple
 *
 * Step 2
 *
 * @copyright 2011-2013 k-holy <k.holy74@gmail.com>
 * @license The MIT License (MIT)
 */
include_once realpath(__DIR__ . '/../vendor/autoload.php');

use Acme\Application;

$app = new Application();

// リクエスト変数を取得する
$app->findVar = $app->protect(function($key, $name, $default = null) {
    $value = null;
    switch ($key) {
    // $_GET
    case 'G':
        $value = (isset($_GET[$name])) ? $_GET[$name] : null;
        break;
    // $_POST
    case 'P':
        $value = (isset($_POST[$name])) ? $_POST[$name] : null;
        break;
    // $_COOKIE
    case 'C':
        $value = (isset($_COOKIE[$name])) ? $_COOKIE[$name] : null;
        break;
    // $_SERVER
    case 'S':
        $value = (isset($_SERVER[$name])) ? $_SERVER[$name] : null;
        break;
    }
    if (!isset($value) ||
        (is_string($value) && strlen($value) === 0) ||
        (is_array($value) && count($value) === 0)
    ) {
        $value = $default;
    }
    return $value;
});

// HTMLエスケープ
$app->escape = $app->protect(function($value, $default = '') {
    $map = function($filter, $value) use (&$map) {
        if (is_array($value) || $value instanceof \Traversable) {
            $results = array();
            foreach ($value as $val) {
                $results[] = $map($filter, $val);
            }
            return $results;
        }
        return $filter($value);
    };
    return $map(function($value) use ($default) {
        $value = (string)$value;
        if (strlen($value) > 0) {
            return htmlspecialchars($value, ENT_QUOTES);
        }
        return $default;
    }, $value);
});
// ?name=<script>alert('hello')</script>
// ?name[]=foo&name[]=<script>alert('hello!')</script>
$name = $app->findVar('G', 'name');
?>
<html>
<body>
<h1>test</h1>
<?php if (is_array($name)) : ?>
<?php foreach ($name as $_name) : ?>
<p><?=$app->escape($_name)?></p>
<?php endforeach ?>
<?php else : ?>
<p><?=$app->escape($name)?></p>
<?php endif ?>
</body>
</html>

これで ?name=foo<script>alert('hello!')</script> や ?name=foo&name=<script>alert('hello!')</script> でリクエストされても hello! されなくなりました。

Step 3 リクエスト変数の正規化とHTMLフォーム

いわゆるサニタイズ…っていうと怒られるみたいですが、リクエスト変数は文字列または文字列を要素に持つ配列として受け入れることを前提とすると、セキュリティのためにもNULLバイトを代表とする不要な制御コードをどこかで除去した方がいいと思います。

ついでに、個人的に気になる改行コードの混在も入口で統一するため、リクエスト変数を正規化する関数をアプリケーションオブジェクトに登録し、リクエスト変数を取得する関数から呼び出すことにします。

先ほどのHTMLエスケープ関数に含まれていた、配列やTraversableなオブジェクトに関数を適用する無名関数がこの処理に再利用できそうなので、これもアプリケーションオブジェクトに独立した関数として登録します。

また、$POSTや$SERVERからの取得も試していなかったので、この機に「名前」と「コメント」を受け取るフォームにしてみます。

www/index.php

<?php
/**
 * Create my own framework on top of the Pimple
 *
 * Step 3
 *
 * @copyright 2011-2013 k-holy <k.holy74@gmail.com>
 * @license The MIT License (MIT)
 */
include_once realpath(__DIR__ . '/../vendor/autoload.php');

use Acme\Application;

$app = new Application();

// リクエスト変数を取得する
$app->findVar = $app->protect(function($key, $name, $default = null) use ($app) {
    $value = null;
    switch ($key) {
    // $_GET
    case 'G':
        $value = (isset($_GET[$name])) ? $_GET[$name] : null;
        break;
    // $_POST
    case 'P':
        $value = (isset($_POST[$name])) ? $_POST[$name] : null;
        break;
    // $_COOKIE
    case 'C':
        $value = (isset($_COOKIE[$name])) ? $_COOKIE[$name] : null;
        break;
    // $_SERVER
    case 'S':
        $value = (isset($_SERVER[$name])) ? $_SERVER[$name] : null;
        break;
    }
    if (isset($value)) {
        $value = $app->normalize($value);
    }
    if (!isset($value) ||
        (is_string($value) && strlen($value) === 0) ||
        (is_array($value) && count($value) === 0)
    ) {
        $value = $default;
    }
    return $value;
});

// リクエスト変数の正規化
$app->normalize = $app->protect(function($value) use ($app) {
    $filters = array(
        // HT,LF,CR,SP以外の制御コード(00-08,11,12,14-31,127,128-159)を除去
        // ※参考 http://en.wikipedia.org/wiki/C0_and_C1_control_codes
        function($val) {
            return preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]|\xC2[\x80-\x9F]/S', '', $val);
        },
        // 改行コードを統一
        function($val) {
            return str_replace("\r", "\n", str_replace("\r\n", "\n", $val));
        },
    );
    foreach ($filters as $filter) {
        $value = $app->map($filter, $value);
    }
    return $value;
});

// HTMLエスケープ
$app->escape = $app->protect(function($value, $default = '') use ($app) {
    return $app->map(function($value) use ($default) {
        $value = (string)$value;
        if (strlen($value) > 0) {
            return htmlspecialchars($value, ENT_QUOTES);
        }
        return $default;
    }, $value);
});

// 全ての要素に再帰処理
$app->map = $app->protect(function($filter, $value) use ($app) {
    if (is_array($value) || $value instanceof \Traversable) {
        $results = array();
        foreach ($value as $val) {
            $results[] = $app->map($filter, $val);
        }
        return $results;
    }
    return $filter($value);
});

$form = array(
    'name'    => $app->findVar('P', 'name'),
    'comment' => $app->findVar('P', 'comment'),
);
?>
<html>
<body>
<h1>test</h1>

<form method="post" action="<?=$app->escape($app->findVar('S', 'REQUEST_URI'))?>">

<dl>
<dt>名前</dt>
<dd>
<input type="text" name="name" value="<?=$app->escape($form['name'])?>" />
</dd>
<dt>コメント</dt>
<dd>
<textarea name="comment">
<?=$app->escape($form['comment'])?></textarea>
</dd>
</dl>

<input type="submit" value="送信" />
</form>

<hr />

<dl>
<dt>名前</dt>
<dd><?=$app->escape($form['name'])?></dd>
<dt>コメント</dt>
<dd><pre><?=$app->escape($form['comment'])?></pre></dd>
</dl>

</body>
</html>

受け取って再び表示するだけで何も処理していませんが、とりあえずフォームができました。