mod_rewriteの基本を再確認

mod_rewriteで設定を行ったのですが、意外にはまったので再確認してみます。基本的な書き換えなんですけどね。
やりたかったことは、フレームワーク等でおなじみの処理を少し拡張して、探索対象をドキュメントルート外のユーザーディレクトリも含めるというものです。
public以下はデフォルトのファイルを用意しておき、userは独自にアップしたファイルで動作を変更できるといったモデルです。ただし、userにはphpcgiの実行はさせたくないので、.phpを置くディレクトリには触らせないというのが原則になります。
簡単そうだったのですが、いくつか躓きました。
ディレクトリ構造はこんな感じ

root
├───public
│   │    index.php
│   │
│   └───styles
│              base.css
│              some.css
│
└───users
    └───foo
        └───public
            └───styles
                       base.css
                       some1.css

期待する結果

url filepath 目的
/foo/bar /public/index.php FWで処理
/styles/base.css /users/foo/public/styles/base.css ユーザーdirを優先
/styles/some.css /public/styles/some.css グローバルを採用
/index.php forbidden 直アクセス禁止
/public/index.php forbidden 直アクセス禁止
/public/styles/some.css /public/index.php FWで使う可能性あり

躓いたのは、通常の処理をindex.phpに送る一方で、ユーザーからの直接の/public/以下へのリクエストは実在するpublicディレクトリではなく、publicコントローラーとして処理させたいという点です。
また、半分は学習目的もあったので、httpd.confでvirtualhost内に書いた場合と、.htaccessを使った場合とで検討してみました。実際には.htaccessで書く方が理解を助けると思いますが、パフォーマンスも下がりますし今回は内容が重複するのでブログからは省きました。

フレームワークでよくある設定

フレームワークの場合、/publicをDocumentRootにしつつ、/public/.htaccessで下記のようにするのがとりあえず基本として、Userディレクトリについて考えてみます。

;/public/.htaccess内の記述
RewriteEngine On

RewriteCond %{REQUEST_FILENAME} -s [OR]
RewriteCond %{REQUEST_FILENAME} -l [OR]
RewriteCond %{REQUEST_FILENAME} .ico$ [OR]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^.*$ - [NC,L]

RewriteRule ^.*$ /index.php [NC,L]

RewriteCondでファイルが存在しなければという定義になっているので、ここでユーザーディレクトリについても探索してあげれば、一歩近づきそうです。しかし、

ドキュメントルート外へリクエストを転送するには、Alias、AliasMatch、ScriptAlias、ScriptAliasMatch、UserDirといった、URL=>pathを直接扱う機能を利用する必要があります。一方、mod_rewriteはpathへの解決も行っているように見えますが、実質的にはURL書き換えを行って再投函するようなシステムのため、DocumentRootをさかのぼってリクエストを転送することはできません。mod_rewriteはあまりにも機能が豊富なためなんでもできてしまうので一言では表せないのですが、URLを書き換えて内部リクエストに回すというところだけは重要なポイントではないかと思います。

疑似URLを用いた解決方法

この問題を解決するには、疑似的にAlias転送を設定したディレクトリに書き換えるとう方法が考えられます。たとえば、

  • フルパスでファイル探索を行い、疑似URLに変換してPTフラグで通しつつ、そのURLをAliasでusersのpublicに渡す。

フルパスでという部分、%{DOCUMENT_ROOT}/%{REQUEST_FILENAME}を使う事例が多くありますが、DOCUMENT_ROOTをさかのぼる場合は個別に指定すれば可能だろうと思われます。たとえば、

;virtualhost内での記述
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !^/dummy
;/users/foo/public/はURL表現と一致してしまってわかりにくいですが、この場合は物理パスです
RewriteCond /users/foo/public/%{REQUEST_FILENAME} -d
RewriteCond /users/foo/public/%{REQUEST_FILENAME} -s [OR]
RewriteCond /users/foo/public/%{REQUEST_FILENAME} -l 
RewriteRule ^(.*)$ /dummy$1 [PT]
Alias /dummy /users/foo/public/

これで、ドキュメントルートの外へリクエストを送ることができます。/publicでは通常通りの前記した.htaccessでいけます。RewriteCond %{REQUEST_FILENAME} !^/dummyこの行がないと書き換えが無限ループします。/dummyへ書き換えた後、内部リクエストとして再投函されており、この行がないと、さらに/dummy/dummyと書き換えられてループしてしまうわけです。
さて、面白くないのはRewriteCondでフルパスをべたで書いてしまっているところと、/dummyというパスがフレームワークのモジュールやコントローラーで使えなくなってしまう点です。もう少しクールな方法はないですかね。

Aliasを使わない案

mod_rewriteは非常に優れたツールなのですが、唯一url書き換えが仕事であるだけに、ドキュメントルート外への転送ができません。そこで、上記のようにAliasを併用することで可能になるわけですが、そもそもpublicフォルダをDocumentRootにしないという方法があります。ドキュメントルート外に転送ができないなら、ドキュメントルート内に全部いれればいいじゃないか。ということです。実際、DocumentRoot内の書き換えであればmod_rewriteは非常に素直に振舞います。
たまに、.htaccessでDocumentRootを書き換えたいといった話を聞くことがあるのですが、そういう場合はDocumentRootの階層を上げて、両方のディレクトリを含むようにし、あとはmod_rewriteで設定するとうまくいきます。

  • mod_rewriteはDocumentRoot内で仮想DocumentRootへ転送するのにも使える

提示したディレクトリ構造のpublicおよびuser用publicが両方含まれるようにルートを設定すると、/publicと/users/foo/publicという二つの仮想DocumentRootができ、その間をmod_rewriteで結ぶというイメージになります。
DocumentRootの階層を引き上げると、余計なファイルにアクセスされる危険を心配されるかもしれませんが、全転送をかけてあれば、基本的には問題ありません。しかし、各ディレクトリを一括で保護して、使う部分だけ許可するようにした方が無難です。その部分は今回のテーマとはずれるので記載しません。

;virtualhost内
;DocumentRootの階層を上げています

RewriteEngine On

RewriteRule ^(.*) /users/foo/public/$1 [NC,L]

<Directory "/users/foo/public">
	RewriteCond %{REQUEST_FILENAME} .php$
	RewriteRule ^.*$ - [F]

	RewriteCond %{REQUEST_FILENAME} -s [OR]
	RewriteCond %{REQUEST_FILENAME} -l [OR]
	RewriteCond %{REQUEST_FILENAME} .ico$ [OR]
	RewriteCond %{REQUEST_FILENAME} -d
	RewriteRule ^.*$ - [NC,L]

	RewriteRule ^/users/foo/public/(.*)$ /public/$3 [NC,QSA,L]
</Directory>
<Directory "/public">
	RewriteCond %{REQUEST_FILENAME} -s [OR]
	RewriteCond %{REQUEST_FILENAME} -l [OR]
	RewriteCond %{REQUEST_FILENAME} .ico$ [OR]
	RewriteCond %{REQUEST_FILENAME} -d
	RewriteRule ^.*$ - [NC,L]

	RewriteRule ^.*$ /public/index.php [NC,QSA,L]

</Directory>

Aliasを使わず、DocumentRootを1階層上げた場合の例です。まずは/usersへ転送し、ファイルの実在をチェック、なければ、publicに転送し、ファイルの実在をチェック。それでも発見されなければ、phpで処理する。という流れとなっています。ここまではいいのですが、残念ながらこの設定は無限ループします。なぜなら、2度目の書き換えで/public以下に書き換えられたリクエストは内部リクエストされて、ふたたび、/users以下に書き換えられて、パスが延びていくからです。NSフラグの説明を見る限り、こういうケースで使えないかと思ったのですが、精読してみるとそういう用途ではないようです。
何か対策が必要になりそうです。ひとつは、前記した/dummyを見つけたときには書き換えないという方法があります。この場合は/publicへのリクエストは書き換えないというルールにすると解決しそうです。しかし、この方法は/dummyのときと同様、フレームワーク側でpublicディレクトリを使えなくなるばかりかpublicへの直接アクセスを識別できなくなってしまいます。

mod_headersを用いてリクエストヘッダをつけてみる

内部リクエストが行われるなら、mod_rewriteのオプションでsetEnvする方法もありそうですが、サブリクエストではsetEnvした環境変数は引き継がれないようです。ここではmod_headersで追加のリクエストヘッダをつけてみます。するとこんな感じ。*2

RewriteEngine On

RewriteRule .php$ - [F]

<IfModule !headers_module>
	RewriteCond %{REQUEST_FILENAME} !^/public
</IfModule>
<IfModule  headers_module>
	RewriteCond %{HTTP:IS_SUBREQ} !1
</IfModule>

RewriteRule ^(.*) /users/foo/public/$1 [NC,L]


<Directory "/users">
	php_flag engine off
	AllowOverride None
	<IfModule  headers_module>
		RequestHeader set IS_SUBREQ 1
	</IfModule>
	RewriteCond %{REQUEST_FILENAME} -s [OR]
	RewriteCond %{REQUEST_FILENAME} -l [OR]
	RewriteCond %{REQUEST_FILENAME} .ico$ [OR]
	RewriteCond %{REQUEST_FILENAME} -d
	RewriteRule ^.*$ - [NC,L]
	RewriteRule ^/users/foo/public/(.*)$ /public/$1 [NC,QSA,L]
</Directory>
<Directory "/public">
	php_flag engine On
	RewriteCond %{REQUEST_FILENAME} -s [OR]
	RewriteCond %{REQUEST_FILENAME} -l [OR]
	RewriteCond %{REQUEST_FILENAME} .ico$ [OR]
	RewriteCond %{REQUEST_FILENAME} -d
	RewriteRule ^.*$ - [NC,L]
	RewriteRule ^.*$ /public/index.php [NC,QSA,L]

</Directory>

これで、無限ループせず、すべてのパスが利用可能になります。ちょっとした味付けも加えてあります。内に書いた記述はそのまま該当ディレクトリの.htaccessへ持っていってもいけるので、配布パッケージなどではこんな感じの設定を添付するしかないかもしれません。

サーバーコンテキストだけでまとめる

.htaccessへの展開を考えて手を抜いていくと、↑のようになりますが、サーバーコンテキストでおさめると下記のようになります。RewriteCondの左辺はサーバーコンテキストではパスが解決されていないのでフルパスになるように自前で解決する必要があります。

;virtualhostでの記述
    RewriteEngine On

    RewriteRule .php$ - [F]

    RewriteCond %{DOCUMENT_ROOT}/users/foo/public%{REQUEST_FILENAME} -s [OR]
    RewriteCond %{DOCUMENT_ROOT}/users/foo/public%{REQUEST_FILENAME} -l [OR]
    RewriteCond %{DOCUMENT_ROOT}/users/foo/public%{REQUEST_FILENAME} -d [OR]
    RewriteCond %{DOCUMENT_ROOT}/users/foo/public%{REQUEST_FILENAME} .ico$
    RewriteRule .* /users/foo/public%{REQUEST_FILENAME} [NC,L]
	
    RewriteCond %{DOCUMENT_ROOT}/public%{REQUEST_FILENAME} -s [OR]
    RewriteCond %{DOCUMENT_ROOT}/public%{REQUEST_FILENAME} -l [OR]
    RewriteCond %{DOCUMENT_ROOT}/public%{REQUEST_FILENAME} -d [OR]
    RewriteCond %{DOCUMENT_ROOT}/public%{REQUEST_FILENAME} .ico$
    RewriteRule .* /public%{REQUEST_FILENAME} [NC,L]

    RewriteRule .* /public/index.php [NC,L]

DocumentRootの階層を上げたことで処理がかなり楽になっています。ディレクトリコンテキストではなく、Aliasもないので無限ループの心配もないですし。対象とするディレクトリが固定であれば、virtualhostに書く方が効率がかなりいいですね。

DirectoryIndexに対応する(追記)

ここまでの設定だと、usersディレクトリ内に空のディレクトリがあるとその部分は、フレームワークに渡らなくなります。ディレクトリが存在していた場合にどのように処理したいかはサイト設計によると思いますが、唯一ルートディレクトリについていえば否応なく存在してしまうので、この部分でサイトルートへのアクセスでDirectoryIndexに依存してしまうのは面白くないかもしれません。
そこで、ユーザーディレクトリ内では、ディレクトリが存在していても中にindex.htmやindex.htmlがなければ、publicに転送するという記述を加えておいた方がよさそうです。-dによるチェックで置換を停止していた部分を消し、index.htm等のチェックを先行して行うとよさそうです。

virtualhost内のDirectory内
;<Directory>内の場合
        RewriteCond %{REQUEST_FILENAME} -d
        RewriteCond %{REQUEST_FILENAME}/index.htm -s
        RewriteRule ^. /users/foo/public%{REQUEST_URI}index.htm [NC,L]

        RewriteCond %{REQUEST_FILENAME} -d
        RewriteCond %{REQUEST_FILENAME}/index.html -s
        RewriteRule ^. /users/foo/public/%{REQUEST_URI}index.html [NC,L]   

.htaccess内の場合、/users/foo/public/は不要になる
※しかし、リダイレクト数が多すぎるので、基本的に.htaccess方式は使わない方がよい。

サーバーコンテキストでのほぼ完成系
;virtualhostでの記述
    RewriteEngine On

    RewriteRule .php$ - [F]

    RewriteCond %{DOCUMENT_ROOT}/users/foo/public%{REQUEST_FILENAME} -d
    RewriteCond %{DOCUMENT_ROOT}/users/foo/public%{REQUEST_FILENAME}index.htm -s
    RewriteRule .* /users/foo/public%{REQUEST_FILENAME}index.htm [NC,L]

    RewriteCond %{DOCUMENT_ROOT}/users/foo/public%{REQUEST_FILENAME} -d
    RewriteCond %{DOCUMENT_ROOT}/users/foo/public%{REQUEST_FILENAME}index.html -s
    RewriteRule .* /users/foo/public%{REQUEST_FILENAME}index.html [NC,L]

    RewriteCond %{DOCUMENT_ROOT}/users/foo/public%{REQUEST_FILENAME} -s [OR]
    RewriteCond %{DOCUMENT_ROOT}/users/foo/public%{REQUEST_FILENAME} -l [OR]
    RewriteCond %{DOCUMENT_ROOT}/users/foo/public%{REQUEST_FILENAME} .ico$
    RewriteRule .* /users/foo/public%{REQUEST_FILENAME} [NC,L]
    #typeがあったので修正しました↓{が[になっていた。
    RewriteCond %{DOCUMENT_ROOT}/public%{REQUEST_FILENAME} -s [OR]
    RewriteCond %{DOCUMENT_ROOT}/public%{REQUEST_FILENAME} -l [OR]
    RewriteCond %{DOCUMENT_ROOT}/public%{REQUEST_FILENAME} .ico$
    RewriteRule .* /public%{REQUEST_FILENAME} [NC,L]

    RewriteRule .* /public/index.php [NC,L]

まとめると

  • mod_rewriteはURL書き換えを行うのでDocumentRoot外へのアクセスは苦手というかできないと割り切るぐらいでいい
  • Aliasと併用するときはPTフラグを使う
  • mod_rewriteをDocumentRoot外へ使いたくなったらDocumentRootの階層引き上げを検討してみる
  • RewriteCondはフルパスが使えるが、${DOCUMENT_ROOT}を使うなら.htaccessではない方がいい。
  • 一番のハマりどころは無限ループ。ディレクトリコンテキストでのrewriteでは内部リクエストにリダイレクトされるため、再書き換えが起きます。無限ループしそうになったら、mod_headers等でパンくずを落とすか、転送先の.htaccessでrewriteEngine Offを検討する。
  • virtualhost内を触れるなら.htaccessに書く必要はほとんどない。

*1:と言ってみる

*2:実運用を想定する場合、RequestHeaderはユーザーが自由に送ることができるので、推測不能な文字列にしたり、他の機能を利用したほうがいいかもしれません