Displaying posts tagged with

“CakePHP”

CakePHPで特定のコントローラに対するBasic認証をApache側でかけようとしたが無理だった

2010-11-17: 記事のタイトル・内容を修正しました。最終的に、Cakeの特定のコントローラにApache側で認証をかけることは不可能という結論に至りましたので、「なぜ不可能なのか」を説明する内容に差し替えました。コメント欄にて重要な指摘をくださったshin1x1さんに感謝します。

CakePHP 1.2/1.3

CakePHPで特定のコントローラ(URL)に対して、Apacheの機能を使ってBasic認証をかけようとして、実際にその方法を見つけたとして記事を公開したのですが、無理だということに気がつきました。

Cakeは最終的に $_GET['url'] しか見ていないので、一つでも認証無しでアクセス可能なコントローラ/アクションが存在すれば、

/controller/action?url=/protected

というパラメータを使うことで任意のURL(上では /protected)に対して認証無しでアクセスできてしまいます。

以下に私が使おうとしていた .htaccess のコードを示しておきます。これは同じようなアイデアを思いついた人に対して、このような方法では無理だということを示すためのものなので、決して使用しないでください。

# Basic認証の設定
AuthType Basic
AuthName "Members only"
AuthUserFile /path/to/.htpasswd
Require valid-user
 
# 認証対象となるURLの指定
SetEnvIf Request_URI ".*" allowed
SetEnvIf Request_URI "^/members" !allowed
# !INSERTED! /index.php に直接アクセスするのを許可しない
SetEnvIf Request_URI "^/index\.php" !allowed
 
# 認証をバイパスさせるための設定
Order Deny,Allow
Deny from all
Allow from env=allowed
Satisfy Any
 
# 通常のCakeのrewrite設定
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^(.*)$ index.php?url=$1 [QSA,L]

なおこの記事は以下のブログ記事を参考にして書きましたが、同様の問題が存在すると考えられますので注意してください。

テスト時に全テーブルのfixtureを自動で読み込む

環境:CakePHP 1.3

テスト時に誰もが一度は経験するであろう

Missing Database Table
Error:  Database table users for model User was not found.

というエラーを回避する方法です。

……

CakePHPのテストでは必要なfixtureを全て手動で列挙する必要があります。一つ二つなら良いのですが、モデルにアソシエーションを設定すると関連するテーブル全てが再帰的に必要になるので、非常に面倒です。

それならいっそ全テーブルを自動で読み込んでくれよ、ということで自動で読み込む方法を考えました。以下のクラスに示す2メソッドを CakeTestCase のサブクラスで定義してください(CakeTestCaseを独自の拡張クラスに置き換えるも参照)。

例えばdefaultデータベースに users, posts, tags, posts_tags の4つのテーブルがある場合、

$fixtures = array('IMPORT' => 'default');

と書くと

$fixtures = array('app.user', 'app.post', 'app.tag', 'app.posts_tag');

と書いたのと同じことになります。

やっていることは非常に単純で、指定されたデータソースからテーブル一覧を得て app.fixture_name の形式で $fixtures に追加しているだけです。

もし特定のfixtureがコンフリクトする場合は除外するfixtureを EXCLUDE キーで指定してください。

//usersテーブルは special_user フィクスチャからインポートするので
//app.user は読み込まない
$fixtures = array('app.special_user',
                  'IMPORT' => 'default',
                  'EXCLUDE' => array('app.user'));

おまけ:

以下の2メソッドも加えると存在しないfixtureクラスを自動生成してくれます。

CakeTestCaseを独自の拡張クラスに置き換える

環境: CakePHP 1.3

CakePHPでTestCaseの機能を拡張したい場合、CakeTestCase クラスを継承した独自のベースクラスを作る。

abstract class MyTestCase extends CakeTestCase {
  function assertHoge() {
    //...
  }
}

しかし標準ではこのクラスを自動的に読み込む手段がない。各テストファイルの先頭で毎回 App::import するのはあまりにも面倒なので、自動で読み込むための方法を調べてみた。

前提:

  • 必要なファイルは全て一つのプラグイン(以下 my_test)の中にまとめる
  • Webからの実行(test.php)、コンソールからの実行(cakeshell)、両方に対応する

my_test/config/bootstrap.php

この bootstrap.php の中で MyTestCase はじめとする必要なクラスファイルを読み込む。下の例では ComponentTestCase も読み込んでいる。

my_test/libs/test_suite_dispatcher.php

test.phpからの実行時に必要なファイル

my_test/vendors/shells/my_test.php

コンソールからの実行に必要なファイル

以上の3ファイルが app/plugins/my_test 以下にあれば良い。

コンソールから実行する場合

cake testsuite の代わりに cake my_test を実行するだけ。

Webからtest.phpで実行する場合

test.php の書き換えが必要。といってもファイル末尾の CakeTestSuiteDispatcher を MyTestSuiteDispatcher に入れ替えるだけ。

require_once CAKE_TESTS_LIB . 'cake_test_suite_dispatcher.php';
//
App::import('lib', 'MyTest.MyTestSuiteDispatcher');
$Dispatcher = new MyTestSuiteDispatcher();
//
$Dispatcher->dispatch();

参考: CakePHP1.3 CakeTestCase の拡張を読み込む bootstrap 的なものをつくる

CakePHPのデータベース情報を使ってmysqlコマンドで接続する

環境: CakePHP 1.3

CakePHPでの開発中、コマンドラインから直接SQLを実行したいことがままあるので、CakePHPのdatabase.phpから情報を取得してmysqlコマンドで接続するためのシェルスクリプトを作った(要CLI版php)。

connect_db.sh

何もオプションを指定しなければそのままmysqlのインタラクティブシェルに入る。

./connect_db.sh

コマンドライン引数はそのままmysqlコマンドに渡されるので、例えば -e オプションを使うとそのままSQLを実行できる。

./connect_db.sh -e 'select * from users'

標準では default 接続が使われる。その他の接続設定を使いたい場合は connect_db.sh に対して connect_db_{接続名} という名前でシンボリックリンクを作る。

# DATABASE_CONFIG->test に接続
ln -s connect_db.sh connect_db_test
./connect_db_test

私の場合 app と同じ階層に bin というディレクトリを作ってそこにシェルスクリプトを置いているので、パスもそれに合わせてある。異なるディレクトリ構成で使う場合は最初のAPPの定義を適宜書き換える。

CakePHPでトランザクション制御

環境: CakePHP 1.3

CakePHPのModelクラスには直接トランザクションを制御するメソッドがない。そこで例えば次のようにAppModelにメソッドを定義する。

class AppModel extends Model {
    function begin() {
        return $this->getDataSource()->begin($this);
    }
    function commit() {
        return $this->getDataSource()->commit($this);
    }
    function rollback() {
        return $this->getDataSource()->rollback($this);
    }
}

しかしこの方法ではテストがやりにくい。明示的にトランザクションを制御したい時というのは、複数のテーブルにまたがる複雑なロジックを組むことが多いので、commit/rollbackされたことを確認するのにいちいちDBの状態を確認していては手間がかかりすぎる。

そもそも「commitされたらそれまでの実行結果が反映される」「rollbackされたらトランザクション開始前の状態に戻る」というのはDBの機能なので、改めてテストし直す意味はない。アプリケーションのテストとしてはcommit/rollbackが発行されたことさえ分かればいい。

そこでAppModelで定義する代わりに、専用のモデルクラスを作ってそこでメソッドの定義を行うことにした。$useTable を false にしてもDataSource自体は取得できるというのがポイントである。

class TransactionManager extends AppModel {
    var $useTable = false;
 
    function begin() {
        return $this->getDataSource()->begin($this);
    }
    function commit() {
        return $this->getDataSource()->commit($this);
    }
    function rollback() {
        return $this->getDataSource()->rollback($this);
    }
}

これでMockを使ったテストができるようになった。トランザクションを使用する際は ClassRegistry 経由でこのモデルを取得する。

$tx = ClassRegistry::init('TransactionManager');
 
if($tx->begin()) {
  //...
 
  if(doSomething() && $tx->commit()) {
    return $values;
  }
}
$tx->rollback();
return false;

テスト時には ClassRegistry にMockのインスタンスを登録しておいて、begin/commit/rollbackの各メソッドが呼び出されたことだけを調べる。もちろんそれぞれの操作が失敗した場合のテストもできる。

function startTest() {
  $this->tx = new MockTransactionManager;
  ClassRegistry::addObject('TransactionManager', $this->tx);
}
 
function testA() {
  $this->tx->expectOnce('begin');
  $this->tx->setReturnValue('begin', true);
  $this->tx->expectOnce('commit');
  $this->tx->setReturnValue('commit', true);
  $this->tx->expectNever('rollback');
 
  //...
}

フィールドの値をシリアライズするSerializableビヘイビア

環境: CakePHP 1.3

特定のフィールドの値をシリアライズしてから保存する Serializable ビヘイビアを作りました。配列などの構造を持つデータを、ひとつのフィールドに保存することができます。

serializable.php – gist
(いずれ正式なリポジトリに移すかもしれない)

fields オプションに指定したフィールドがsave時にJSONでエンコードして保存され、find時には自動的にデコードされます(PHP標準の serialize ではなくJSONを使用するのは互換性を重視しているため)。

var $actsAs = array('Serializable'
  => array('fields' => array('field1', 'field2')));
 
// 保存する前に自動的にJSONエンコードされる
$Model->save(array(
  'field1' => array(1,2,3),
  'field2' => array('assoc' => 'data')
));
 
// find時に自動でデコードされる
$values = $Model->find('first');
// field1 == array(1,2,3)
// field2 == array('assoc' => 'data')

この種のフィールド・シリアライズには様々なバリエーションが考えられます:

  • JSON以外の形式でエンコードする
  • 複数のフィールドの値をひとつにまとめて保存する
  • シリアライズしたフィールドを別のモデルにマップして、hasOne関連のように扱えるようにする

このbehaviorは最も単純な作りになっているので、適当なメソッドを上書きすれば上のような動作も実現できるのではないかと思います。

動的にコールバックを設定するObservableビヘイビア

環境: CakePHP 1.3

モデル用のコールバック関数を動的に登録/解除できるようにする Observable Behaviorを作りました。コールバックを使用するためだけにサブクラスを作ったりビヘイビアを作ったりする必要がなくなります。

observable.php – gist
(いずれ正式なリポジトリに移すかもしれない)

callable なオブジェクトなら何でもコールバックとして使用できますが、PHP 5.3のクロージャと組み合わせると特に便利です。

$actsAs = array('Observable');
 
// addObserver メソッドで登録。戻り値は一意なid
$oid = $Model->addObserver('beforeSave', function($model) {
 
    // 第1引数はモデル、それ以後は通常のコールバック引数
    $model->data[$model->alias] = ....;
 
    // false を返すとコールバックの実行が停止する
    // 何も返さなかった場合(null)は true を返したのと同じことになる
    //return false;
});
 
// 不要になったら取り除く
$Model->removeObserver($oid);

コメント中にもある通り、明示的に引数を返さなかった場合は true と見なされるので、 beforeSave や beforeValidate で戻り値を忘れてその後の処理が実行されない……といったことはありません。

Searchプラグインによる検索条件をページングで引き継ぐ(Prgコンポーネントを置き換える)

環境: CakePHP 1.3

CakeDCのSearchプラグインは、全体としては非常に優れたプラグインなのですが、Prgコンポーネントの機能がどうしても私の使い方には合いませんでした。具体的に言うと

  • 検索パラメータの引き継ぎにnamedを使うのは問題が多い(参考)。代わりにQueryStringを使いたい。
  • Searchableビヘイビアと同じような内容の $filterArgs を書くのが手間。
  • そもそも値の検証や制限はモデルでやるべき、と考える。コンポーネント内でバリデーションを実行したりDBアクセスまでするのは仕事のし過ぎ。

これらの点を解消するため、昔作ったSearchPaginationプラグインを全面改訂して、Searchプラグインと連携できるようにしました。Prgコンポーネントの代わりに使うことができます。

http://github.com/tkyk/cakephp-search-pagination (旧版は 1.2 ブランチを参照)

使い方は非常に簡単で、コントローラの中で SearchPagination コンポーネントの setup メソッドを実行するだけです。

class UsersController extends AppController {
  var $components = array('SearchPagination.SearchPagination');
 
  function search() {
    $this->SearchPagination->setup('User');
    //...
  }
}

これによってQueryStringから検索パラメータが取得され、data[ModelName] に格納されるので、あとはそれを Searchable ビヘイビアの parseCriteria に渡すなどして、検索条件を組み立てます。

function search() {
  $this->SearchPagination->setup('User');
 
  // 常に $this->data['User'] に検索パラメータが入っている
 
  $this->paginate['conditions'] = $this->User->parseCriteria($this->data['User']);
  $this->set('users', $this->paginate('User'));
 
  $this->set('groups', $this->User->Group->find('list'));
}

ビューでは特に何もしなくても自動的に検索パラメータが引き継がれます。普通にFormヘルパーやPaginatorヘルパーを使ってください。

echo "<h2>Search Form<h2>";
echo $this->Form->create('User', array('type' => 'get'));
echo $this->Form->input('username');
echo $this->Form->input('created');
echo $this->Form->input('group_id');
echo $this->Form->end('Search');
 
echo $this->Paginator->numbers(array('modulus' => 10));

Formヘルパーのcreateメソッドには、typeとして ‘get’ を指定することをおすすめします。もし指定しなかった場合は(Prgコンポーネントに倣って)自動的にGET用URLを生成してリダイレクトされます。

CakeのURLパラメータに特殊文字を使ってはいけない

環境: CakePHP 1.3

CakePHPはURLパラメータのエンコードを一切行わないため、特殊な文字がパラメータに入り込むと容易にルーティングが破綻する。ここで言う「URLパラメータ」とはRoute中に埋め込まれたパラメータ、namedパラメータ、passパラメータのことを指す。

例えば以下のようなRouteがあったとする。

/controller/action/:keyword

ここでパラメータkeywordに特殊な文字を与えると…

keyword => '%'
URL: /controller/action/%
結果: URLとして不正な形式なので"400 Bad Request"になる
keyword => '?foo'
URL: /controller/action/?foo
結果: ?以降はQueryStringと見なされてルーティングから除外され、Routeにマッチしなくなる
keyword => 'a/b/c'
URL: /controller/action/a/b/c
結果: /がそのままパラメータ区切りになるのでRouteにマッチしなくなる

この問題は単純に rawurlencode/rawurldecode を使うだけでは回避できない。またCakeの設計上の問題なので、簡単な修正方法もない。よって任意の文字が含まれる可能性のあるパラメータをURLに埋め込んではいけない。最も簡単な回避策は、URLパラメータを諦めてQueryStringを使うことである。

Router::url(array('?' => array('keyword' => $any_characters)));

問題の原因と回避策の考察

なぜ rawurlencode で回避できないかというと、CakePHPが mod_rewrite 経由で $_GET['url'] から元のURLを取得しているからだ。

RewriteRule ^(.*)$ index.php?url=$1 [QSA,L]

RewriteRuleの後方参照($1の部分)では%エンコーディングのデコードが行われる。またQueryStringから $_GET に格納される際にもPHPによってデコードが行われる。つまりCakeのルーティングが実行される前に、既に2回のデコードが行われている。よってもしこの問題を rawurlencode で回避するなら、3重に rawurlencode した上で rawurldecode しなければならない、ということになる。

ちなみに RewriteRule の[B]オプションを指定することで後方参照のデコードを回避することができる。この場合は rawurlencode は2回で済む。しかしそれでもURLが汚くなることは変わりないし、検索エンジンのロボット等もURL中の文字列を認識してくれないだろう。

RewriteRule ^(.*)$ index.php?url=$1 [QSA,L,B]

別の考え方として、%エンコーディング以外の安全なエンコーディングを用いる方法もある。例えば16進数のバイナリ表現に変換すれば、[a-f0-9]しか現れないので確実に安全である。

$encoded = bin2hex($data);
 
//[0-9a-f]しか含まれないことを事前にチェックしておくこと
$decoded = pack('H*', $encoded);

ただしこの方法だと長さは元の文字列の2倍になるし、可読性も全く損なわれる。ここまでするくらいならやはりQueryStringを使った方が良いと思う。

第2回CakePHP勉強会@福岡 発表資料

7月3日に福岡で開催されたCakePHP勉強会で、『MarkupHelperの紹介および PHPによるDSL実装の可能性について』という題で発表を行いました。以下に資料をアップロードしておきます(右キーで進む、左キーで戻る、上キーでサムネイル表示切り替え)。

資料へのリンク

Studio ODIN – 高橋メソッド風 プレゼンスクリプトを使用しています。発表本番では改造版を使ったのですが、ライセンスが不明なので本家のスクリプトに直接リンクを貼っています。

githubのMarkupHelperリポジトリはこちら:
http://github.com/tkyk/cakephp-markup-helper

補足

質疑応答より。多少内容を変更しています。

Q. デザイナーに触ってもらうのは難しいのではないか?
A. このヘルパーはもともと「プログラマーがHTMLを書くのを楽にしよう」というコンセプトで作られているため、デザイナーとの連携が必要になる場面では使用できない・使用すべきでないと言えます。(デザインが比較的どうでもいい)内部向けの管理画面、ごく小さなelement、HTMLを生成する別のヘルパーの中、などで使用するのに向いています。
別の言い方をするとコードの分量が HTML > PHP の場合には使う意味は薄いです。コードの分量が PHP >= HTML の場合に威力を発揮します。
Q. XHTML以外のマークアップ言語を作ることはできるか?
A. MarkupHelperは「正しいXHTMLのタグか否か」をチェックしていないので、基本的にはどんなマークアップでも作ることができるはずです。ただし「そのタグが閉じタグを持つか否か」という情報を $emptyElements プロパティ(public)で管理しているので、必要に応じて再定義する必要があります。
Q. ループや条件分岐をメソッドチェーンに組み込めるか?
A.PHP言語の機能的な制約により、不可能です(PHP5.3のクロージャを使えば一応はできるかもしれませんが、書式が煩雑なのでDSLとしては実装できないでしょう)。
ループや条件分岐に相当する処理を入れつつメソッドチェーンを切りたくない場合は、その部分の処理を別のヘルパーのメソッドにまとめて連結することをおすすめします。
$markup->ul->MyHelper_loop_li($arr)->end;

……

勉強会で発表するのが初めてだったので緊張してしまい、発表中は聞いている人の反応を見ている余裕がありませんでした。早口に一気にまくしたててしまったような気がして、理解してもらえたかどうか不安だったのですが、あとで感想を聞くとそれなりに好評だったようでほっとしました。それから高橋メソッドによる大きな文字は、Ustream経由でも見やすかったようです。

勉強会の進行は(いくつか想定外の事態があったにも関わらず)大変スムーズで、気持ちよく発表できました。@k1LoWさんはじめ株式会社Fusicの皆さん、素晴らしい時間をありがとうございました。

福岡は魚もお酒も美味しくて、2次会以降ついつい飲み過ぎてしまいました。酔った勢いで、特に3次会ではずいぶん大口を叩いてしまったような記憶があります…。酔っぱらいの戯言が戯言でなくなるように、もっと精進していこうと思います。