SWFUpload の Cookie バグに悩まされました

とある案件でファイルのアップローダー機能を作ることになりました。まぁ何も考えずにやるならば、こんな感じ。

<input type="file" name="dummy"size="50">

でも今回の要件はコレではダメなのです。

  • アップロード時にページ遷移はさせたくない
  • ファイルサイズが大きなファイルをアップロードするので、プログレスバーを表示して進捗表示したい
  • 認証済みのユーザだけアップロード機能を使わせたい

とすると Ajax か Flash だなぁ〜と思って探してみました。よく考えたらプログレスバーを表示するためには、事前にファイルサイズを知る必要があります。とすると JavaScript ではローカルのファイルサイズを取得できないので Flash 実装のライブラリを探すのが良さそうです。

いろいろ検証してみた結果 SWFUpload が一番よさげです。コレ結構有名なライブラリですよね。でもどうにも既存のシステムと認証部分でうまくいきませんでした。既存システムの認証は session id を cookie で保持する方式なのですが、SWFUpload は(それとも Flash の仕様?) cookie にバグがあるようで、ie 以外のブラウザで動作させた場合 HTTP cookie を送信してきません。従って cookie ベースの認証方式では認証状態を引き継ぐことができないようです。

- スポンサーリンク -

SWFUpload のこの仕様ってぐぐってもあまり話題になっていないようなのですが、みんなどうやって使ってるんでしょう??見かけたのは下記のページくらい。話題的には Rails + SWFUpload だけど問題的には同じ Cookie の仕様がらみ。

まぁ論より証拠。デモとソースを見ていただくのが早いでしょう。いろいろなブラウザで試してみてください。プログラムのざっくりした仕様は下記の通り。

  • 初回アクセス時に sesssion id を発行して HTTP cookie に記憶させます。リロードで再発行
  • SWFUpload で "*.jpg;*.png;*.gif" 画像をアップロードします。ファイルサイズ制限は 1024000 byte 以下
  • サーバ側では SWFUpload 経由でアクセスされた場合に cookie から session id を取得。なければ session id を新規発行。返値として session id と ファイルサイズをブラウザに返す
  • SWFUpload の cookie bug 回避として cookie の内容を post でも送信する(fix cookie bug にチェックが入っているときに送信。チェックがないときは cookie のみ)

ie で実験したときにはこんな感じになるとおもいます。ポイントは初回アクセス時に表示されている HTTP cookie の session id が同一です。

ie001.png

firefox で実験したときにはこんな感じになるとおもいます。ポイントは初回アクセス時に表示されている HTTP cookie の session id は "fix cookie bug" にチェックした場合は同一、チェックを外した場合は異なる点です。

ff001.png

以上の実験から、ブラウザが保持している cookie と SWFUpload が保持している cookie が異なるということが証明されました。回避方法としては、cookie の内容を post で送信してサーバ側で適宜補完してあげる必要があります。html 側の対応は簡単で、SWFUpload には swfupload.cookies.js ってのが同梱されていて、html 内で呼び出すだけで cookie の内容を post する処理を組み込んでくれます。サーバ側は実験サンプルを参考にどうぞ。
※セキュリティ的にどうなん?という話はここでは話題から外します。

実験サンプルのサーバ側のソースはこんな感じ。(右クリックで保存

#! /usr/local/bin/perl

use CGI;
use CGI::Cookie;
use File::Copy;
use File::Basename;
use Digest::SHA1 qw(sha1_hex);
use Time::HiRes qw(gettimeofday);
use Data::Dumper;

## おまじない。100K 以上のデータは破棄する
$CGI::POST_MAX = 1024 * 100;
my $q = CGI->new;

## upload 画面 or upload ファイル処理
if ( $q->param('uploadfile') ) {
    _uploadfile($q);
}
else {
    _init($q);
}

## SWFUpload 経由でファイルがアップロードされたときの処理
sub _uploadfile {
    my $q = shift;
    eval {
        ## file upload 処理
        my $upload_dir = './tmp/';
        my $fh         = $q->upload('uploadfile');

        ## 異常処理
        if ( $q->cgi_error ) {
            print $q->header( -type => 'text/html', -charset => 'UTF-8' );
            print "failed: Request entity too large'";
            exit;
        }
        unless ($fh) {
            print $q->header( -type => 'text/html', -charset => 'UTF-8' );
            print "failed: no data";
            exit;
        }

        ## コンテンツ処理。とりあえずファイルを所定の場所に移動
        my $temp_path = $q->tmpFileName($fh);
        fileparse_set_fstype('MSDOS');
        my $filename    = basename($fh);
        my $upload_path = "$upload_dir/$filename";
        move( $temp_path, $upload_path ) or die $!;
        close($fh);

        ## まぁファイルをためられても困るので、サンプルではとりあえず削除
        my $filesize = -s $upload_path;
        unlink $upload_path;

        ## cookie 処理
        my $sid_name  = 'sessionid';
        my $sessionid = $q->cookie($sid_name);
        my $cookiebug = $q->param("cookiebug");

        ## cookie bug 対応
        ## SWFuploader 経由のアクセスならば post データから sessionid を上書きする
        if ( $cookiebug && $ENV{HTTP_USER_AGENT} =~ /^(Adobe|Shockwave) Flash/ ) {
            $sessionid = $q->param($sid_name);
        }

        ## sessionid がない場合には sessionid を生成する
        if ( !$sessionid ) {
            $sessionid = _gen_sessionid();
        }

        ## テストのために cookie を送り込む
        my $cookie = $q->cookie( -name => $sid_name, -value => $sessionid );
        print $q->header( -type => 'text/html', -charset => 'UTF-8', -cookie => [$cookie] );
        print "sessionid=$sessionid / filesize=$filesize byte";

        open my $lfh, '>>', 'log.txt';
        print $lfh "$sessionid,$ENV{HTTP_USER_AGENT}\n";
        print $lfh $q->param($sid_name), "\n";
        print $lfh $q->param("cookiebug"), "\n";

        #print $lfh Dumper $q;
        print $lfh Dumper $cookie;
        close $lfh;

    };

    if ($@) {
        open my $lfh, '>>', 'log.txt';
        print $lfh "$@\n";
        close $lfh;
    }
}

## 初期画面を出力する処理
sub _init {
    my $q = shift;

    ## html template
    open my $fh, '<', 'index.txt';
    my $text = do { local $/; <$fh> };
    close $fh;

    ## cookie 処理
    my $sid_name  = 'sessionid';
    my $sessionid = $q->cookie($sid_name);

    ## sessionid がない場合には sessionid を生成する
    $sessionid = _gen_sessionid();

    ## html 出力
    $text =~ s/\$sessionid\$/$sessionid/msxg;
    my $cookie = $q->cookie( -name => $sid_name, -value => $sessionid );
    print $q->header( -type => 'text/html', -charset => 'UTF-8', -cookie => [$cookie] );
    print $text;

}

sub _gen_sessionid {
    my $ipaddr
        = defined( $ENV{'HTTP_X_FORWARDED_FOR'} ) ? $ENV{'HTTP_X_FORWARDED_FOR'}
        : defined( $ENV{'REMOTE_ADDR'} )          ? $ENV{'REMOTE_ADDR'}
        :                                           '255.255.255.255';
    my $unique = $ipaddr . rand();
    my $sessionid = substr( sha1_hex( gettimeofday . $unique ), 0, 32 );
    return $sessionid;
}

実験サンプルの html テンプレートはこんな感じ。fileuploader.cgi で使われます。(右クリックで保存

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>Multiple File Upload With Progress Bar - Web Developer Plus Demos</title>
<script type="text/javascript" src="jquery-1.3.2.js"></script>
<script type="text/javascript" src="swfupload/swfupload.js"></script>
<script type="text/javascript" src="swfupload/swfupload.cookies.js"></script>
<script type="text/javascript" src="jquery.swfupload.js"></script>
<script type="text/javascript">

$(function(){
    $('#swfupload-control').swfupload({
        upload_url: "fileuploader.cgi",
        file_post_name: 'uploadfile',
        file_size_limit : "1024000",
        file_types : "*.jpg;*.png;*.gif",
        file_types_description : "Image files",
        file_upload_limit : 5,
        flash_url : "swfupload/swfupload.swf",
        button_image_url : 'swfupload/wdp_buttons_upload_114x29.png',
        button_width : 114,
        button_height : 29,
        button_placeholder : $('#button')[0],
        debug: false
    })
        .bind('fileQueued', function(event, file){
            var listitem='<li id="'+file.id+'" >'+
                'File: <em>'+file.name+'</em> ('+Math.round(file.size/1024)+' KB) <span class="progressvalue" ></span>'+
                '<div class="progressbar" ><div class="progress" ></div></div>'+
                '<p class="status" >Pending</p>'+
                '<span class="cancel" > </span>'+
                '</li>';
            $('#log').append(listitem);
            $('li#'+file.id+' .cancel').bind('click', function(){
                var swfu = $.swfupload.getInstance('#swfupload-control');
                swfu.cancelUpload(file.id);
                $('li#'+file.id).slideUp('fast');
            });
            // start the upload since it's queued
            $(this).swfupload('startUpload');
        })
        .bind('fileQueueError', function(event, file, errorCode, message){
            alert('Size of the file '+file.name+' is greater than limit');
        })
        .bind('fileDialogComplete', function(event, numFilesSelected, numFilesQueued){
            $('#queuestatus').text('Files Selected: '+numFilesSelected+' / Queued Files: '+numFilesQueued);
        })
        .bind('uploadStart', function(event, file){
            var swfu = $.swfupload.getInstance('#swfupload-control');
            var cookiebug = $("input[name='cookiebug']")[0].checked ? 1 : 0;
            swfu.addFileParam(file.id, 'cookiebug', cookiebug);

            $('#log li#'+file.id).find('p.status').text('Uploading...');
            $('#log li#'+file.id).find('span.progressvalue').text('0%');
            $('#log li#'+file.id).find('span.cancel').hide();
        })
        .bind('uploadProgress', function(event, file, bytesLoaded){
            //Show Progress
            var percentage=Math.round((bytesLoaded/file.size)*100);
            $('#log li#'+file.id).find('div.progress').css('width', percentage+'%');
            $('#log li#'+file.id).find('span.progressvalue').text(percentage+'%');
        })
        .bind('uploadSuccess', function(event, file, serverData){
            var item=$('#log li#'+file.id);
            item.find('div.progress').css('width', '100%');
            item.find('span.progressvalue').text('100%');
            //var pathtofile='<a href="tmp/'+file.name+'" target="_blank" >view »</a>';
            //item.addClass('success').find('p.status').html('Done!!! | '+serverData+' | '+pathtofile);
            item.addClass('success').find('p.status').html('Done!!! | '+serverData);
        })
        .bind('uploadComplete', function(event, file){
            // upload has completed, try the next one in the queue
            $(this).swfupload('startUpload');
        })
    
}); 

</script>
<style type="text/css" >
#swfupload-control p{ margin:10px 5px; font-size:0.9em; }
#log{ margin:0; padding:0; width:500px;}
#log li{ list-style-position:inside; margin:2px; border:1px solid #ccc; padding:10px; font-size:12px; 
    font-family:Arial, Helvetica, sans-serif; color:#333; background:#fff; position:relative;}
#log li .progressbar{ border:1px solid #333; height:5px; background:#fff; }
#log li .progress{ background:#999; width:0%; height:5px; }
#log li p{ margin:0; line-height:18px; }
#log li.success{ border:1px solid #339933; background:#ccf9b9; }
#log li span.cancel{ position:absolute; top:5px; right:5px; width:20px; height:20px; 
    background:url('js/swfupload/cancel.png') no-repeat; cursor:pointer; }
</style>
</head>
<body>

<h3>» Multiple File Upload With Progress Bar</h3>

<form>
<div id="swfupload-control">
    <p>Upload upto 5 image files(jpg, png, gif), each having maximum size of 1MB</p>
    <p>browser sessionid = $sessionid$</p>
    <p><input type="checkbox" name="cookiebug" value="1">fix cookie bug</p>
    <input type="button" id="button" />
    <p id="queuestatus" ></p>
    <ol id="log"></ol>
</div>
</form>

</body>
</html>

実験サイトのファイル一式はこちらからどうぞ。サーバに設置するだけで動くと思います。

ってわけで、SWFUpload で cookie 関連ではまったという件をまとめてみました。なかなか原因がわからず、この実験結果に至るまで二週間ほどかかってしまいました。かかりっきりではないけど、これほど手こずるとは思ってもいませんでした。

この実験とは別なのですが、SWFUpload は(Flashはと言った方が正確か・・・)SSL 環境下で実行させる場合、正式な証明書のサーバ配下でないとセキュリティ上、動作しないという点です。これも気がつくまで相当苦労しました。開発サーバの嘘んこ証明書(本番サーバのコピー)では動かないので、開発しづらいことこの上ないです。オレオレ証明書だとうまくいくのかなぁ〜??

最後にファイルアップローダ関連で見つけたライブラリや参考サイトたちの備忘録

SWFUpload 関連

swfupload - Project Hosting on Google Code
SWFUpload News | SWFUpload
SWFUpload v2.2.0 Demos
Jetpack Flight Log » Rails 2.3.4 and SWFUpload – Rack Middleware for Flash Uploads that Degrade Gracefully
SWFUpload jQuery Plugin : Adam Royle
Multiple File Upload With Progress Bar Using jQuery
複数ファイルのアップロードを可能にしてくれるSWFUploadを今さらながら触ってみた « 岩家ぶろぐ
複数ファイルのアップロードを可能にしてくれるSWFUploadを今さらながら触ってみた « 岩家ぶろぐ

その他のプログレスバーがでるファイルアップローダー

jqUploader- jQuery plugin for file upload and progressbar
File Upload Progress Monitor - drupal.org
JQuery File Upload Plugin Script - JQuery File Upload Script - Uploadify
Uber Uploader - Get Uber Uploader at SourceForge.net

その他のプログレスバーがでないファイルアップローダー

dojox.form.FileUploader !
AjaxFileUpload - Jquery Plugin
jQuery Multiple File Upload Plugin v1.46 (2009-05-12)

- スポンサーリンク -