jQuery Mobile + iPhone で非同期にファイルをアップロードするサンプル


IMG_2891.png

iOS6 では Safari でファイルのアップロードか可能になったらしいのでサンプルを作ってみました。

単にアップロードできるサンプルではつまらないので、jQuery MobilejQuery.upload を使い、ファイルを非同期にアップロードするサンプルにしました。環境はこんな感じです。

  • クライアント
    • jQuery Mobile 1.1.1
    • jQuery.upload 1.0.2
  • サーバー
    • Amon2

Amon2は Perl の軽量フレームワークです。この環境で作ったサンプルを見ながら、ハマった点をまとめます。


jQuery Mobile でページを作る

さくっと簡単なページを作ってみました。jQuery Mobile を使ったのは、単に iPhone で見たときに要素の見た目を良くするためで、他の機能は使っていません。

[% WRAPPER 'include/layout.tt' %]
<div id="my-form">
  <div data-role="fieldcontain">
    <fieldset data-role="controlgroup">
      <label for="my-text1">テキスト1</label>
      <input name="text1" id="my-text1" placeholder="テキスト1" type="text">
    </fieldset>
  </div>
  <div data-role="fieldcontain">
    <fieldset data-role="controlgroup">
      <label for="my-file1">ファイルの指定</label>
      <input name="file1" id="my-file1" type="file">
    </fieldset>
  </div>
  <input type="hidden" name="csrf_token" value="[% c().get_csrf_defender_token() %]">
</div>
<button id="my-upload" data-theme="b" data-icon="check" data-iconpos="right">アップロード開始!</button>
[% END %]

IMG_2892.png

ファイルをアップロードするページなのに <form> タグを使っていません。この理由は後ほど。ハイライトした謎のタグも後で説明します。

iPhone で見てみると右のような感じです。


jQuery.upload で非同期にアップロードする

ファイルのアップロードというのは Web アプリにとって鬼門の一つです。アップロードボタンを押したが最後、別のページに遷移するまで一切の動作が出来ないという、Ajax によるサイト作成者にとっては悪夢のような存在でした。

今回使用した jQuery.upload はこれを解決してくれる非常にシンプルなプラグイン。ファイルのアップロード先を <iframe> にすることで動作を非同期にし、かつ、サーバーからのレスポンスを XML や JSON で返してくれる優れものです。

今回は <button id="my-upload"> をタップ(クリック)したときに発動し、<div id="my-form"> の中身をサーバーに非同期に POST してくれるように書いてみます。

$('#my-upload').on('click', function(e){
    // ローディングインジケータ(くるくる廻るアレ)を表示する
    $.mobile.showPageLoadingMsg();
    // #my-form の中身を全部 POST
    $('#my-form').upload('/upload', function(data) {
        // ローディングインジケータを隠す
        $.mobile.hidePageLoadingMsg();
        // レスポンスを表示
        window.alert("アップロードされました!\n"
            + 'テキスト1 : ' + data.text1 + "\n"
            + '画像形式 : ' + data.content_type + "\n"
            + 'サイズ : ' + data.size + "\n"
            + 'ファイル名 : ' + data.basename + "\n");
    // サーバーからの返値は JSON で受け取る
    }, 'json');

    return false;
});

$.fn.upload() が発動すると、<form><iframe> がページに挿入されてアップロードが実行されます。既にページに <form> があると二重に POST されてしまう(ことがある)ために、<form> は HTML に記述しない方が良いです。

後は、サーバーサイドでこれを受け取るプログラムを書きます。

アップロードを受け付けるサーバースクリプト

今回は Amon2 を使っています。スクリプトの肝となる部分は次の通り。

package UploadSample::Web::Dispatcher;
use strict;
use warnings;
use utf8;
use Amon2::Web::Dispatcher::Lite;
use Encode;
use JSON;

get '/' => sub {
    my ($c) = @_;
    $c->render('index.tt');
};

post '/upload' => sub {
    my ($c) = @_;
    my $p = $c->req->parameters;
    my $file1 = $c->req->upload('file1');

    my %res = (text => $p->{text1});
    if ($file1) {
        $res{size} = $file1->size;
        $res{basename} = $file1->basename;
        $res{content_type} = $file1->content_type;
    }

    $c->create_response(
        200,
        ['Content-Type' => 'text/html; charset=UTF-8'],
        [encode(utf8 => to_json(\%res))],
    );
};

1;

POST された通常のパラメータは $c->req->parameters で取得できますが、アップロードされたファイルは $c->req->upload('名前') のようにして取り出します。

upload() メソッドの返値は Plack::Response::Upload オブジェクトになっており、ファイルを簡単に扱うことが出来ます。

注意すべきはハイライトした 28 行目です。返値は JSON なのだからといって application/json で返そうとするとハマります。

この返値は直接 <iframe> 内に書き込まれるのですが、ブラウザによってはダウンロードダイアログが出現したり、ご丁寧にも <pre> タグで囲んでくれるため、うまく動作しなくなってしまうのです。

バッドノウハウっぽいですが、ここは HTML 文書として JSON を返すのが適切です。

Amon2 特有の問題

ここからは Amon2 特有の問題になりますが、以下の点に気をつける必要があります。

X-Frame-Options ヘッダーに注意

普通に Amon2 でアプリを作ると、各ページに X-Frame-Options ヘッダーが付加されます。これは、この Web アプリを別のサイトの <iframe> に入れられることを拒否するヘッダーです。

The X-Frame-Options response header | MDN
https://developer.mozilla.org/ja/docs/The_X-Frame-Options_response_header

多くの場合においてこのヘッダーを付加するのは正しいことなのですが、今回のようにアップロードのために <iframe> を使う場合は一手間必要です。

具体的には、UploadSample::Web を次のように修正します。

diff --git a/lib/UploadSample/Web.pm b/lib/UploadSample/Web.pm
index 4af8f8e..99e936b 100644
--- a/lib/UploadSample/Web.pm
+++ b/lib/UploadSample/Web.pm
@@ -59,7 +59,9 @@ __PACKAGE__->add_trigger(
         $res->header( 'X-Content-Type-Options' => 'nosniff' );
 
         # http://blog.mozilla.com/security/2010/09/08/x-frame-options/
-        $res->header( 'X-Frame-Options' => 'DENY' );
+        my $frame_policy =
+            $c->req->path_info eq '/upload' ? 'SAMEORIGIN' : 'DENY';
+        $res->header( 'X-Frame-Options' => $frame_policy );
 
         # Cache control.
         $res->header( 'Cache-Control' => 'private' );

通常は X-Frame-Options: DENY を指定するのですが、アップロードに使う URL へのアクセスの場合にだけ SAMEORIGIN というポリシーを使います。これはドメインが同じ場合にだけ <iframe> 内への表示を許可するものです。

CSRF 対策

Amon2 はセキュリティに配慮して、セッションを利用した不正対策が組み込まれています。これはクロスサイトリクエストフォージェリ(長い……)を防ぐためのものです。

利用者がサイトに訪れた際、一意な文字列(ワンタイムトークン)が作成され、<form> でのリクエスト時にはこのトークンをやりとりすることで、第三者に攻撃されにくくすることが出来ます。

Amon2 はページに <form> タグを発見すると、このトークンを自動的に挿入してくれるのですが、今回は <form> タグを HTML 内に使用しないために自分でトークンを書いてあげる必要があります。

<input type="hidden" name="csrf_token" value="[% c().get_csrf_defender_token() %]">

最後に

以上です。長々とおつきあい頂きありがとうございました。なんか後半は最早 iPhone あんまり関係ありませんでしたね。

今回作成したコードは Github に置いてありますので気になった方はご覧ください。

delphinus35/UploadSample · GitHub
https://github.com/delphinus35/UploadSample

コメントを残す