« Shell CommandをWrapしてappに変換するShell Commandを書いてみた | メイン | XMLHttpRequestでファイルをDataSchemeで取得する実験 その2 »

XMLHttpRequestでファイルをDataSchemeで取得する実験

2009/04/15 追記
このエントリーの後に問題は解決しbinaryを取得することができました。最後のほうにある「その2」を参照してください。
画像等のファイルのデータをBase64化したDataSchemeでなんとか取得できないかと試してみました。結論からいうと失敗です。ただ、もう少し調査するともしかするとできるようになるかもしれません。しかし、自分の現時点の知識では、これが限界かと思います。もしかすると、問題箇所を誰かが解決してくれるのかもしれないと淡い期待を持って公開します。また、提示するcodeは使い方によってはDoS攻撃と思われてしまうような挙動をします。詳細は後述しますが実行時は注意してください。
自分で開発しているようなサイトの場合、自分でJavaScriptからデータを取得する仕組みを実装すればいいのでここでは考えないことにします。サーバ上の静的なファイルのデータ取得をしたいのは、クロスサイトで実行可能なXMLHttpRequestを使えるGreasemonkeyからではないでしょうか。これらの理由から、以下Firefox3上のGreasemonkeyを前提にしています。
そもそもXMLHttpRequestには、responseXMLとresponseTextというテキストを前提としたメソッドしかないためバイナリが取得できません。初期のXMLHttpRequestが実装された当時のIEにはresponseStreamというメソッドがあったらしいのですが、残しておいて欲しかったですね。まあ、セキュリティホールになりそうな気はしますけど。
バイナリをXMLHttpRequest.responseTextを使って取得した場合、文字化けが発生します。ただ文字化けが発生するのではなくUnicodeとして認識できなかった場合、文字化けが発生した場所が「�」に変換されてしまいます。「�」は「REPLACEMENT CHARACTER」はUnicodeで定義されている文字で、文字コードはFFFDとなっています。これは不可逆変換のため元のデータを推測することができません。もちろん、偶然にも文字化けが発生し得ないデータのみでバイナリが構成されていた場合はこの限りではありませんが...。
文字化けを発生させずにデータを取得する方法は本当にないのでしょうか。もしかすると、文字化けがマルチバイトの文字でしか発生しないのではないかと考えました。そうだとすると、強制的に1Byteずつ取得できればバイナリも取得可能なのではないでしょうか。後述しますが、HTTPには1Byteずつデータを取得する方法があります。そこで、次のコードをFirebug上で実行し「REPLACEMENT CHARACTER」が表示されないかを確認してみました。
for(var i=0;i<256;i++){
    var str=String.fromCharCode(i);
    console.log(str,str.charCodeAt(0),str.charCodeAt(0)==i,str.charCodeAt(0)==0xFFFD);
}
表示されない文字がありますが、少なくとも文字コードは温存されており、「REPLACEMENT CHARACTER」に変換されるようなことはありませんでした。これは、実現へ向けて期待が持てます。
データを1Byteずつ取得するにはどのようにすればいいのでしょうか。それは、HTTPヘッダを利用する方法です。Requestヘッダには取得するデータの範囲を指定する「Range」というものが、Responseヘッダには取得するデータのサイズを表す「Content-Length」があります。これらはダウンローダ等のレジューム機能の実装に使われています。取得しようとしているデータが途中で更新される場合がありますが、データの整合性を保つための仕組みも提供されています。今回は、最終変更日時を表す「Last-Modified」というResponseヘッダと、指定された日時以降にデータが更新されていないことを保証するための「If-Unmodified-Since」というRequestヘッダを利用します。整合性のためのHTTPヘッダはこの他にも色々ありますので、詳細を知りたい方はRFCや以下の連載を参照してください。
今回はデータ取得を非同期で行なうため、取得したデータの範囲を表す「Content-Range」というResponseヘッダも使用しています。また、Dataスキームで指定するコンテンツタイプの為に「Content-Type」というヘッダも利用しています。
さて、長々とHTTPヘッダについて書きましたが、どのように指定するのでしょうか。通常のアクセスであれば指定はできない(はず)のですが、XMLHttpRequestの場合は可能です。Greasemonkeyの場合は、以下のように指定できます。
GM_xmlhttpRequest(
    {
        "method":"GET",
        "url":url,
        "onload":callbackForSuccess,
        "onerror":callbackForError,
        "headers":{
            "If-Unmodified-Since":lastModified,
            "Range":"bytes="+from+"-"+To
        }
    }
);
また、Responseヘッダはcallback関数に渡されるGM_xmlhttpRequestのインスタンスのプロパティ「responseHeaders」から文字列として取得できます。
処理は、まずHEADメソッドで「Content-Length」と「Last-Modified」を取得します。そして、文字化けが発生しないように、「Range」を1Byteずつ変更しながら、「Content-Length」回実行しています。つまり、1KBytesのデータを取得するためには1,024回、1MBytesのデータであれば1,048,576回もHTTPのリクエストを発生させます。これが、はじめにDoS攻撃になりかねないと書いた理由です。以上のことを実装したものを最後にはりますので興味のある方はどうぞ。
では、どうして失敗で終わったのでしょうか。それは、Rangeを指定しデータを取得しても「REPLACEMENT CHARACTER」を返してしまうことがあるからです。理由はわかりませんが、文字コードやContent Typeが関係しているのではないかと思っています。Requestヘッダに「Accept-Charset」や「Content-Type」を指定し取得するデータの種類を強制的に指定してやれば可能なのではないかとにらんでいます。しかし、これらの組み合わせは多岐に及びます。この辺りの知識を私は持ち合わせていません。誰かが解決してくれるのではないかと思いながらここで終わることにします。
// ==UserScript==
// @name        getFileByDataScheme
// @namespace   http://www.kanasansoft.com/
// @include     *
// ==/UserScript==

(
    function(){

        var base64=function(original){
            var table=(
                function(s){
                    var len=s.length;
                    var hash={};
                    for(var i=0;i<len;i++){
                        var bit=i.toString(2);
                        var bit="000000".slice(0,-bit.length%6)+bit;
                        hash[bit]=s.charAt(i);
                    }
                    return hash;
                }
            )("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/");
            var len=original.length;
            var bits8=[];
            if(original instanceof Array){
                for(var i=0;i<len;i++){
                    var bit=original[i].charCodeAt(0).toString(2);
                    bit="00000000".slice(0,-bit.length%8)+bit;
                    Array.prototype.push.apply(bits8,bit.match(/.{8}/g));
                }
            }else{
                for(var i=0;i<len;i++){
                    var bit=original.charCodeAt(i).toString(2);
                    bit="00000000".slice(0,-bit.length%8)+bit;
                    Array.prototype.push.apply(bits8,bit.match(/.{8}/g));
                }
            }
            var len=bits8.length;
            var bits6=[];
            for(var i=0;i<len;i+=3){
                var bit=bits8.slice(i,i+3).join("");
                bit+="000000".slice(0,-bit.length%6);
                Array.prototype.push.apply(bits6,bit.match(/.{6}/g));
            }
            var len=bits6.length;
            var base64=[];
            for(var i=0;i<len;i++){
                base64.push(table[bits6[i]]);
            }
            Array.prototype.push.apply(base64,["=","=","=","="].slice(0,-base64.length%4));
            return base64.join("");
        }

        var parseHTTPHeader=function(responseHeader){
            var headers=responseHeader.split("\n");
            var len=headers.length;
            var parsing=[];
            for(var i=0;i<len;i++){
                if(/^$/.test(headers[i])){
                }else if(/^[\x09\x20]/.test(headers[i])){
                    if(parsing.length==0){
                        throw "SyntaxError:HTTPHeader (first line) "+headers[i];
                    }
                    parsing[parsing.length-1]+="\n"+headers[i];
                }else{
                    parsing.push(headers[i]);
                }
            }
            var len=parsing.length;
            var parsed={};
            for(var i=0;i<len;i++){
                var pair=parsing[i].split(": ",2);
                if(pair.length!=2){
                    throw "SyntaxError:HTTPHeader (format) "+parsing[i];
                }
                if(pair[0] in parsed){
                    throw "SyntaxError:HTTPHeader (repetition) "+pair[0];
                }
                parsed[pair[0]]=pair[1];
            }
            return parsed;
        }

        var getDataMethod=function(res){
            if(data.errorFlag){
                return;
            }
            if(res.status!=206){
                data.errorFlag=true;
                throw "HTTPStatusError (The status is not 206.)"+
                    ":status="+res.status+
                    ":statusText="+res.statusText+
                    ":responseHeaders="+res.responseHeaders;
            }
            var headers=parseHTTPHeader(res.responseHeaders);
            var contentRange=headers["Content-Range"];
            if(!contentRange){
                data.errorFlag=true;
                throw "Error:Sorry. The server do not support HTTP header of \"Content-Range\".";
            }
            var range=contentRange.match(/^bytes (\d+)-\d+\/\d+$/)[1];
            data.gottenData[parseInt(range,10)]=res.responseText;
            data.restData=data.restData.filter(
                (function(range){
                     return function(val){return range!=val;}
                 })(range)
            );
            if(data.restData.length==0){
                var contentType=("Content-Type" in headers)?
                    headers["Content-Type"]:
                    "application/octet−stream";
                var dataScheme="data:"+contentType+
                    ";base64,"+base64(data.gottenData);
                data.callback(dataScheme);
            }
        }

        var getDataByRangeHandler=function(range,callback){
            return function(){
                if(data.errorFlag){
                    return;
                }
                GM_xmlhttpRequest(
                    {
                        "method":"GET",
                        "url":data.url,
                        "onload":callback,
                        "onerror":callback,
                        "headers":{
                            "If-Unmodified-Since":data.lastModified,
                            "Range":"bytes="+range+"-"+range
                        }
                    }
                );
            }
        }

        var startGetData=function(){
            var len=data.restData.length;
            for(var i=0;i<len;i++){
                var getDataByRange=
                    getDataByRangeHandler(data.restData[i],getDataMethod);
                setTimeout(getDataByRange,10*i);
            }
        }

        var loadHeaderMethod=function(res){
            var headers=parseHTTPHeader(res.responseHeaders);
            if(res.status!=200){
                throw "RequestError"+
                    ":status="+res.status+
                    ":statusText="+res.statusText+
                    ":responseHeaders="+res.responseHeaders;
            }
            var headers=parseHTTPHeader(res.responseHeaders);
            var lastModified=headers["Last-Modified"];
            if(!lastModified){
                throw "Error:Sorry. The server do not support HTTP header of \"Last-Modified\".";
            }
            var contentLength=headers["Content-Length"];
            if(!contentLength){
                throw "Error:Sorry. The server do not support HTTP header of \"Content-Length\".";
            }
            contentLength=parseInt(contentLength,10);
            var restData=[];
            for(var i=0;i<contentLength;i++){
                restData.push(i);
            }
            data.lastModified=lastModified;
            data.size=contentLength;
            data.restData=restData;
            startGetData();
        }

        var getHeader=function(url){
            data.url=url;
            GM_xmlhttpRequest(
                {
                    "method":"HEAD",
                    "url":data.url,
                    "onload":loadHeaderMethod,
                    "onerror":loadHeaderMethod
                }
            );
        }

        var data={"errorFlag":false,"gottenData":[]};

        var getFileByDataScheme=function(url,callback){
            data.callback=callback;
            getHeader(url);
        }

        if(window.self==window.top){
            var url=prompt("image url","");
            if(url==null||url==""){
                return;
            }
            var handler=function(url,callback){
                return function(){
                    getFileByDataScheme(url,callback);
                }
            }
            var callback=function(dataScheme){
                var elem=document.createElement("img");
                elem.src=dataScheme;
                document.body.appendChild(elem);
            }
            window.addEventListener(
                "load",
                handler(url,callback),
                false
            );
        }

    }
)();
続きます。

トラックバック

このエントリーのトラックバックURL:
http://www.kanasansoft.com/cgi/mt/mt-tb.cgi/215

コメント (1)

Constellation [TypeKey Profile Page]:

https://developer.mozilla.org/En/Using_XMLHttpRequest

Receiving binary data
のところを参照するとおいしいかも.

コメントを投稿

(いままで、ここでコメントしたことがないときは、コメントを表示する前にこのブログのオーナーの承認が必要になることがあります。承認されるまではコメントは表示されません。そのときはしばらく待ってください。)

Google