前回は、 ファイルがないときにステータスコード404を返すこと、 ディレクトリが指定されたときに301を返すことに対応しました。
残作業として、以下のふたつがあります。
今回はこの2点に対する対応を行います。
ディレクトリ名やファイル名に空白や日本語が含まれると、 ブラウザはそれをURLエンコードしてサーバに送ります。
そこで具体的に何が起きているのかを、 以前作った Server01.javaで確認してみましょう。
ブラウザからは、以下のHRLをリクエストします。
http://localhost:8001/日本語 ディレクトリ/日本語 ファイル名.cgi?hoge=日本語
「日本語」と「ディレクトリ」および「日本語」と「ファイル名」 の間に入っているのは半角のスペースです。 また、今回、実験のため、 ?以降のクエリストリングも付けておきました。 クエリストリングをつけるので、一応拡張子は.cgiにしています(意味はないですが)。
上記のURLをブラウザのアドレスバーに入力し、Server01を叩いてみます。
うちのFirefox(23.0.1)では以下のserver_recv.txt が出力されました。1行目は長すぎるので、適当に改行と空白で整形しています。
1: GET /%E6%97%A5%E6%9C%AC%E8%AA%9E%20%E3%83%87%E3%82%A3%E3%83%AC%E3%82%AF%E3%83
%88%E3%83%AA/%E6%97%A5%E6%9C%AC%E8%AA%9E%20%E3%83%95%E3%82%A1%E3%82%A4%E3%83
%AB%E5%90%8D.cgi?hoge=%E6%97%A5%E6%9C%AC%E8%AA%9E HTTP/1.1
2: Host: localhost:8001
3: User-Agent: Mozilla/5.0 (Windows NT 6.0; rv:23.0) Gecko/20100101 Firefox/23.0
4: Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
5: Accept-Language: ja,en-us;q=0.7,en;q=0.3
6: Accept-Encoding: gzip, deflate
7: Connection: keep-alive
8:
IE9だと以下です。 上と同様、1行目は整形しています。
1: GET /%E6%97%A5%E6%9C%AC%E8%AA%9E%20%E3%83%87%E3%82%A3%E3%83%AC%E3%82%AF%E3%83
%88%E3%83%AA/%E6%97%A5%E6%9C%AC%E8%AA%9E%20%E3%83%95%E3%82%A1%E3%82%A4%E3%83
%AB%E5%90%8D.cgi?hoge=日本語 HTTP/1.1
2: Accept: image/gif, image/jpeg, image/pjpeg, application/x-ms-application, (以下略)
3: Accept-Language: ja
4: User-Agent: Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 6.0; Trident/5.0; (以下略)
5: Accept-Encoding: gzip, deflate
6: Host: localhost:8001
7: Connection: Keep-Alive
8:
――見てわかるとおり、IEとFirefoxで挙動が異なるのがいやらしいところです。
URLエンコードとは、ざっくり言えば、元の文字列をバイト単位で解釈し、 %の後ろにその16進表現をつなげたものです ※1。 よって、日本語では、元の文字列がどのようなエンコーディングであったか( たとえばShift-JISかUTF8かEUCか)によって結果が変わってきます。
上のURLエンコードの結果を見ると、クエリストリング以外の部分、 つまり「日本語 ディレクトリ/日本語 ファイル名.cgi」の部分までは、 Firefox, IEともにUTF8でエンコードしたものをURLエンコードしています。 Web上でURLエンコード/デコードできるサイトはいくらでもありますので (Googleで先頭にきたのは ここ)、 そういうサイトで試すことで確認可能です。
現状のWebサーバはいずれにしてもクエリストリングを解釈しませんから、 パスの部分については、 UTF8としてURLデコードすればよいということになります。
FirefoxとIEがそうなっているのはいいとして、 それってどこかに規格として文書化されてるの? というのが気になるところですが、 W3Cの HTML仕様のドキュメントに記載があります。
こうした場合に非ASCII文字を扱うため、ユーザエージェントが次の規則に従うことを推奨する。
- 与えられた各文字を、UTF-8 ([RFC2279]参照)の1バイトあるいは複数バイトで表現する。
- URIのエスケープ機構により、このバイトをエスケープする。すなわち、各バイトをバイト値の十六進表現HHを用いて「%HH」で表す。
今回は、もうひとつの修正点、 ディレクトリトラバーサル対応も同時に入れるので、 ソースは後で載せます。
上で、?より後ろのクエリストリング部分については、 IEとFirefoxで挙動が異なると書きました。
挙動が異なるのは困ったものですが、GETのクエリストリングは Googleをはじめ各種検索エンジン等で検索文字列を渡したりする時に 広く使われています。ブラウザごとに挙動が異なって困らないでしょうか。
実は、上の例では ブラウザのアドレスバーに直接日本語のクエリストリングを打ち込みましたが、 HTMLでform要素を書いてそのmethod属性をgetにする、という方法では、 IE, Firefoxともに、そのHTMLの文字コードに合わせたコードで送られます。
で、それはどこかの規格に文書化されてるの? という疑問が当然出てくるわけですが―― どうも文書化された挙動ではないようです。 少なくとも私は知りません。ご存知の人がいたら教えてください。
なお、HTMLのform要素には、accept-charsetという属性があり、 これを使うと、送信時の文字コードを指定できることになっています。 ただ、今試したのですが、これはFirefoxでは効きましたが IE9では効きませんでした。
「../」のようなパスが渡されたとき、 DOCUMENT_ROOTの外のファイルを返してしまわないよう対策します。
今回はJava7を使っているので、 java.nio.fileパッケージのPathクラスのtoRealPath()を使用して、 パスをいったん絶対パスに変換し、 それがDOCUMENT_ROOTで始まっていることを確認します。
以下、ソースです。まずはServerThread.javaです。
import java.io.*;
import java.net.*;
import java.nio.file.*;
class ServerThread implements Runnable {
private static final String DOCUMENT_ROOT = "C:\\maebashi\\homepage";
private static final String ERROR_DOCUMENT = "C:\\webserver\\error_document";
private static final String SERVER_NAME = "localhost:8001";
private Socket socket;
@Override
public void run() {
OutputStream output = null;
try {
InputStream input = socket.getInputStream();
String line;
String path = null;
String ext = null;
String host = null;
while ((line = Util.readLine(input)) != null) {
if (line == "")
break;
if (line.startsWith("GET")) {
path = URLDecoder.decode(line.split(" ")[1], "UTF-8");
String[] tmp = path.split("\\.");
ext = tmp[tmp.length - 1];
} else if (line.startsWith("Host:")) {
host = line.substring("Host: ".length());
}
}
if (path == null)
return;
if (path.endsWith("/")) {
path += "index.html";
ext = "html";
}
output = new BufferedOutputStream(socket.getOutputStream());
FileSystem fs = FileSystems.getDefault();
Path pathObj = fs.getPath(DOCUMENT_ROOT + path);
Path realPath;
try {
realPath = pathObj.toRealPath();
} catch (NoSuchFileException ex) {
SendResponse.SendNotFoundResponse(output, ERROR_DOCUMENT);
return;
}
if (!realPath.startsWith(DOCUMENT_ROOT)) {
SendResponse.SendNotFoundResponse(output, ERROR_DOCUMENT);
return;
} else if (Files.isDirectory(realPath)) {
String location = "http://"
+ ((host != null) ? host : SERVER_NAME)
+ path + "/";
SendResponse.SendMovePermanentlyResponse(output, location);
return;
}
try (InputStream fis
= new BufferedInputStream(Files.newInputStream(realPath))) {
SendResponse.SendOkResponse(output, fis, ext);
} catch (FileNotFoundException ex) {
SendResponse.SendNotFoundResponse(output, ERROR_DOCUMENT);
}
} catch (Exception ex) {
ex.printStackTrace();
} finally {
try {
if (output != null) {
output.close();
}
socket.close();
} catch (Exception ex) {
ex.printStackTrace();
}
}
}
ServerThread(Socket socket) {
this.socket = socket;
}
}
25行目で、パスのURLデコードを行っています。
45行目で、Path#toRealPath()を使用して絶対パスを取得し、 50行目でそれがDOCUMENT_ROOTで始まっていることを確認しています (そうでなければ404扱いとしました)。 toRealPath()は実際にファイルがなければNoSuchFileExceptionを返すので、 そこで404も返しています。 ただ、実際にファイルをオープンするところ(60行目)で FileNotFoundExceptionが発生しないという保証もないので (確率は低いですが、45行目から60行目まで実行される間に ファイルが削除された場合)、再度例外のチェックは行っています。
特に変更はないですが、Main.java、Util.java、SendResponse.javaも掲載します。
Main.java:
import java.io.*;
import java.net.*;
import java.util.*;
public class Main {
public static void main(String[] argv) throws Exception {
try (ServerSocket server = new ServerSocket(8001)) {
for (;;) {
Socket socket = server.accept();
ServerThread serverThread = new ServerThread(socket);
Thread thread = new Thread(serverThread);
thread.start();
}
}
}
}
Util.java:
import java.io.*;
import java.util.*;
import java.text.*;
class Util {
// InputStreamからのバイト列を、行単位で読み込むユーティティメソッド
static String readLine(InputStream input) throws Exception {
int ch;
String ret = "";
while ((ch = input.read()) != -1) {
if (ch == '\r') {
// 何もしない
} else if (ch == '\n') {
break;
} else {
ret += (char)ch;
}
}
if (ch == -1) {
return null;
} else {
return ret;
}
}
// 1行の文字列を、バイト列としてOutputStreamに書き込む
// ユーティリティメソッド
static void writeLine(OutputStream output, String str)
throws Exception {
for (char ch : str.toCharArray()) {
output.write((int)ch);
}
output.write((int)'\r');
output.write((int)'\n');
}
// 現在時刻から、HTTP標準に合わせてフォーマットされた日付文字列を返す
static String getDateStringUtc() {
Calendar cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"));
DateFormat df = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss",
Locale.US);
df.setTimeZone(cal.getTimeZone());
return df.format(cal.getTime()) + " GMT";
}
// 拡張子とContent-Typeの対応表
static final HashMap<String, String> contentTypeMap =
new HashMap<String, String>() {{
put("html", "text/html");
put("htm", "text/html");
put("txt", "text/plain");
put("css", "text/css");
put("png", "image/png");
put("jpg", "image/jpeg");
put("jpeg", "image/jpeg");
put("gif", "image/gif");
}
};
// 拡張子を受け取りContent-Typeを返す
static String getContentType(String ext) {
String ret = contentTypeMap.get(ext.toLowerCase());
if (ret == null) {
return "application/octet-stream";
} else {
return ret;
}
}
}
SendReponse.java:
import java.io.*;
import java.net.*;
class SendResponse {
static void SendOkResponse(OutputStream output, InputStream fis,
String ext) throws Exception {
Util.writeLine(output, "HTTP/1.1 200 OK");
Util.writeLine(output, "Date: " + Util.getDateStringUtc());
Util.writeLine(output, "Server: Server06.java");
Util.writeLine(output, "Connection: close");
Util.writeLine(output, "Content-type: "
+ Util.getContentType(ext));
Util.writeLine(output, "");
int ch;
while ((ch = fis.read()) != -1) {
output.write(ch);
}
}
static void SendMovePermanentlyResponse(OutputStream output,
String location)
throws Exception {
Util.writeLine(output, "HTTP/1.1 301 Moved Permanently");
Util.writeLine(output, "Date: " + Util.getDateStringUtc());
Util.writeLine(output, "Server: Server06.java");
Util.writeLine(output, "Location: " + location);
Util.writeLine(output, "Connection: close");
Util.writeLine(output, "");
}
static void SendNotFoundResponse(OutputStream output,
String errorDocumentRoot)
throws Exception {
Util.writeLine(output, "HTTP/1.1 404 Not Found");
Util.writeLine(output, "Date: " + Util.getDateStringUtc());
Util.writeLine(output, "Server: Server06.java");
Util.writeLine(output, "Connection: close");
Util.writeLine(output, "Content-type: text/html");
Util.writeLine(output, "");
try (InputStream fis
= new BufferedInputStream(new FileInputStream(errorDocumentRoot
+ "/404.html"))) {
int ch;
while ((ch = fis.read()) != -1) {
output.write(ch);
}
}
}
}
今回は細かい修正だけでした。
次回はPOSTを扱おうと思います。そして、POSTを使う限り、 何らかのプログラムでそれを受けなければ意味がないので、 いよいよ「Webアプリケーション」に踏み込んでいく予定です。
いつになるかはわかりませんが、気長にお待ちくださいませ。