KatsuYuzuのブログ

.NET の開発をメインとした日記です。

ASP.NET MVC の ActionFilter でレガシー IE でのファイルダウンロードの文字化け、不具合と戦う #aspnetjp

この記事はASP.NET Advent Calendar 2014 - Qiitaに参加しています。12日目の担当です。空いていたので登録しました。明日以降もまだ空いてますよ!(デジャブ)
前日はid:hagi44さんでした。

ASP.NET MVC の ActionFilter でレガシー IE でのファイルダウンロードの文字化け、不具合と戦う

ちょうど前日の方と同じく ActionFilter のお話です。
レガシー IE(ここでは IE8 以下ということにしておきます)向けにファイルのダウンロードを実装しようとすると実は結構大変です。闇。

この時代にもういいのでは……感はありますが、せやかて、エンタープライズ。文字化けやらダウンロードできないやら。主に Response の Header をごにょごにょすることで戦っていきます。

前置き

今回、modern.IE の仮想マシンでレガシー環境を使用しているのでスクリーンショットが英語です。

すべての IE バージョンと戦う勇者には欠かせないツールですね。

SSL(HTTPS) でダウンロードできない

これは IE8 以下で no-cache のときに一切の保存ができなくなることが原因です。
f:id:KatsuYuzu:20141212124447j:plain

ダウンロードすることができません。

Internet Explorer でこのサイトを開くに失敗しました。要求されたサイトがいずれか使用できないか、または見つからない。後でもう一度やり直してください。

キャッシュ コントロール ヘッダーでは、SSL を使用した Internet Explorer のファイルをダウンロードが機能しません。

サクッと消してしまいましょう。

// ####################
// # Download will fail when the ssl and no-cache.
// ####################
var cacheControl = response.Headers["Cache-Control"];
if (!string.IsNullOrEmpty(cacheControl))
{
    response.Headers["Cache-Control"] = RemoveNoCache(cacheControl);
}

var pragma = response.Headers["Pragma"];
if (!string.IsNullOrEmpty(cacheControl))
{
    response.Headers["Pragma"] = RemoveNoCache(pragma);
}
private static readonly Regex NoCacheRegex = new Regex(@" ?no-cache,?", RegexOptions.Compiled);
private static string RemoveNoCache(string cache)
{
    // no-cache の指定を削除
    return NoCacheRegex.Replace(
        cache,
        _ => string.Empty);
}

ちなみに no-cache はグローバルのフィルターで入れています。

ファイル名が文字化けする

これは IE8 以下で RFC 6266(RFC 2231/RFC 5987)をサポートしていないことが原因です。
どういうことかっていうとファイル名をエンコードして filename*=UTF-8'' 形式で付加できるのが最新仕様なんですが、それを解釈できないので filename= 形式が必要です。それらの属性がない場合は Request を送る時の URL の末尾がファイル名になります。
f:id:KatsuYuzu:20141212125037j:plain
ASP.NET MVC の FileResult では英数字の時は filename、そうではない時はエンコードすると使い分けているようで、この問題にぶち当たります。
こちらもサクッと置換します。

// ####################
// # Not supported RFC 6266 (RFC 2231/RFC 5987).
// ####################
var contentDisposition = response.Headers["Content-Disposition"];
if (!string.IsNullOrEmpty(contentDisposition))
{
    response.Headers["Content-Disposition"] = ReplaceFileName(contentDisposition);
}
private static readonly Regex FileNameRegex = new Regex(@"filename(\*)?=""?(UTF-8'')?([^;""]+)""?;?", RegexOptions.Compiled);
private static string ReplaceFileName(string contentDisposition)
{
    // filename*=UTF-8'' 形式を filename= 形式に置換
    // 空白がエンコードされていないので置換
    return FileNameRegex.Replace(
        contentDisposition,
        x => string.Format(@"filename=""{0}""", x.Groups[3].Value.Replace(" ", "%20")));
}

ActionFilter

これらを愚直に書いていたらロジックの汚染が始まるので ActionFilter で処理しましょう。OnResultExecuted で処理することで FileResult の処理が終わった後をハンドルできます。

public class CompatibleFileResultAttribute : ActionFilterAttribute
{
    public override void OnResultExecuted(ResultExecutedContext filterContext)
    {
        // 先ほどの諸々の処理
    }
}

ActionFilter を使う側はシンプルそのものです。通常と同じ関数が使用できます。

public class HomeController : Controller
{
    [CompatibleFileResultAttribute]
    public ActionResult File(string name)
    {
        var fileName = Server.MapPath(@"~/App_Data/sample.txt");
        return File(fileName, "application/octet-stream", name);
    }
}

結果

f:id:KatsuYuzu:20141212130203j:plain
f:id:KatsuYuzu:20141212125357j:plain
やったね!
レガシー IE 以外にも倒さないといけない相手がいるかもしれませんが、同じアプローチで攻略できますね。

サンプル


KatsuYuzu/aspnet-CompatibleFileResultSample · GitHub

バトンタッチ

まだちょこちょこ空いてますよ!