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

バトンタッチ

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

ASP.NET の customErrors、IISの httpErrors #aspnetjp

この記事はASP.NET Advent Calendar 2014 - Qiitaに参加しています。10日目の担当です。空いていたので登録しました。明日以降もまだ空いてますよ!
前日は@tanaka_733さんでした。

ASP.NET の customErrors、IISの httpErrors

ハマった & 毎回ぐぐるのでメモ。
ASP.NET と IIS のエラーについては@shibayanさんの記事を毎回ぐぐるので、先に紹介します。

9割は記事の紹介で済んでしまっているんですが、いろんな要素がミルフィーユのように重なっている部分なので、一つポカミスをやらかしていると途端に迷走します。軽くおさらいしながらポカミスの回避を紹介します。

ASP.NET

ASP.NET のエラーは customErrors でカスタマイズできます。

<system.web>
  <customErrors mode="RemoteOnly">
    <error statusCode="404" redirect="~/Error?source=MVC&amp;statusCode=404"/>
    <error statusCode="500" redirect="~/Error?source=MVC&amp;statusCode=500"/>
  </customErrors>
</system.web>

HandleErrorAttribute の確認

この時、グローバルフィルターで HandleErrorAttribute が設定されていないか確認してください。HandleErrorAttribute が設定されていると例外発生時にハンドルされ Error という View が返されてしまいます。

IIS

IIS のエラーは httpErrors でカスタマイズできます。

<system.webServer>
  <httpErrors errorMode="Custom">
    <remove statusCode="404"/>
    <remove statusCode="500"/>
    <error statusCode="404" path="https://localhost:xxxxx/Error?source=IIS&amp;statusCode=404" responseMode="Redirect"/>
    <error statusCode="500" path="https://localhost:xxxxx/Error?source=IIS&amp;statusCode=500" responseMode="Redirect"/>
  </httpErrors>
</system.webServer>

(2015/05/23 追記)上記サンプルにて"../Error……"としていましたが、この場合、パス階層を変えて404を出すと無限にパスを探しに行って最終的にIISの別の例外が飛んできます。設置環境に合わせてURLを指定するのが正解です。config切り替えなどで実現するといいですね。
パスの指定の仕方については次の「パスの確認」の項を確認してください。

パスの確認

この時、path と responseMode に注目してください。responseMode によって path に指定する値が相対パスなのか絶対パスなのか変わります。

デバッグでも本番環境に合わせて仮想ディレクトリを設定しておくとデプロイ後の不具合がないかと思います。

IIS のセットアップの確認

さらに、ローカルの IIS で確認している場合、最大の罠(ただのポカミス)がありました。IIS でカスタムエラーを表示するには [Windows の機能] から [インターネット インフォメーションサービス] > [World Wide Web サービス] > [HTTP 共通機能] > [HTTP エラー] を有効にする必要があります。
f:id:KatsuYuzu:20141210024229p:plain
これが有効になっていない場合、うんともすんとも言わずに IIS の標準のエラーが表示されます。私はこれが有効になっておらず、何か誤解しているのではないかと環境を疑わずにソース修正とデバッグをしていてかなりハマりました……。

デバッグ環境の構築

以前にこんなポカミス(SSL で挙動が変わる部分を非 SSL でデバッグしていた)もしていたわけですが、デバッグ環境は必ず本番環境に合わせてください。

IIS Express

IIS Express で SSL と仮想ディレクトリを使う方法を紹介しておきます。
まず、プロジェクトのプロパティウィンドウで [SSL 有効] を設定します。[SSL URL] をコピーしておいてください。
f:id:KatsuYuzu:20141210031027j:plain
次に、プロジェクトのプロパティ画面で先ほどコピーしておいた URL を張り付けて、ディレクトリの URL を追加しておきます。
f:id:KatsuYuzu:20141210031445j:plain
こうすることで、IIS Express でも SSL や仮想ディレクトリを使ったデバッグができるようになります。

Azure WebSites の利用

Azure が使える状況であれば利用しましょう。
[サーバー エクスプローラー] から WebSites を作成して、すぐに発行できます。
f:id:KatsuYuzu:20141210030947j:plain
f:id:KatsuYuzu:20141210030954j:plain
仮想ディレクトリの設定は、Visual Studio では行えないのでポータルへ移動してください。

ところで、Visual Studio の Azure WebSites の設定画面がちゃんと作られていてちょっと驚きました。
f:id:KatsuYuzu:20141210035239j:plain

サンプル

エラーを発生させるボタンを配置した、カスタムエラーを確認できるサンプルです。KatsuYuzu/aspnet-CustomErrorsSample · GitHub

バトンタッチ

11日目空いてますよ!

Tile Logo Maker for Windows App を作りました #win8dev_jp #wpdev_jp

この記事はXAML Advent Calendar 2014 - Qiitaに参加しています。2日目の担当です。前日は@Grabacr07さんでした。

ツール作りました

f:id:KatsuYuzu:20141202041105p:plain

MSCCの勇者御一行やアプリ作るよりロゴ作る方が大変だと感じる僕のためにロゴ作成ツールを作りました。大きな特徴として xaml ファイルを読み込めます!……というか、まだ xaml にしか対応していません!漢は黙って xaml。

このツールの何がいいの?

  • xaml 対応!
  • タイルに対してのロゴのスケール設定
  • 中心位置に対してのオフセット設定

単純に画像のサイズを変換するだけだとこの辺にモヤモヤしていました。タイルサイズに合わせてロゴのスケールが違ったり、ちょっと上付きだったり。
f:id:KatsuYuzu:20141202041504p:plain
当然あるであろう下記の機能や個人的に欲しい機能はこれから……(

  • 設定の自由変更、追加
  • 画像ファイルの読み込み
  • マルチ解像度icoの出力

設定の自由変更、追加に対応すると、設定ファイルの配布、加工だけで複数プラットフォームいける!あと地味に面倒なicoもやっておきたい。

使い方

まず、Expression Designでアイコンを作ります。この辺に作り方が書いてあります(透過周りの話は情報が古い気がしています。今だと全部透過でいいかも)。

作成したオブジェクトを選択して余白無しでエクスポートします。形式はもちろん xaml で!
f:id:KatsuYuzu:20141202042948p:plain
あとは TileLogoMaker.exe で素材として開いて、ロゴを保存するだけです!同じフォルダーに素材名のフォルダーを作成して出力されます。
f:id:KatsuYuzu:20141202043252p:plain

実装のお話

選ばれた xaml を単純に読み込んでいます。

private UIElement Load(string path)
{
    using (var reader = new XamlXmlReader(path))
    using (var writer = new XamlObjectWriter(reader.SchemaContext))
    {
        while (reader.Read())
        {
            writer.WriteNode(reader);
        }
        return (UIElement)writer.Result;
    }
}

保存時はユーティリティライクな関数に対して、ロゴの数だけコールしています。読み込んだ UIElement を VisualBrush にして、DrawingVisual に書き込んでいます。その時に設定として持っていたオフセットで TranslateTransform を与えたり。

// TODO: この辺を設定化
new LogoInfo
{
    SubDirectoryName = "Windows 8.1", 
    Name = "Logo", 
    Width = 150, 
    Height = 150,
    OffsetY = -10,
    LogoScale = 0.45,
    SupportedTileScales = TileScales.All
}
var drawingVisual = new DrawingVisual();

using (var drawingContext = drawingVisual.RenderOpen())
{
    // 一辺
    var side = (width < height) ? width : height;
    var scaledSide = side * logoScale;

    // 移動
    drawingContext.PushTransform(
        new TranslateTransform(
            width / 2 - scaledSide / 2,
            height / 2 - scaledSide / 2 + offsetY * tileScale));

    // 描画
    drawingContext.DrawRectangle(
        visualBrush,
        null,
        new Rect(new Size(scaledSide, scaledSide)));
}

return drawingVisual;

最後に RenderTargetBitmap に描画して PngBitmapEncoder で保存しています。全体は Github を参照してください。

ダウンロード

とりあえず動くというレベルの機能があるだけなのでアルファ版です。かゆいところ、へぼいところ、是非、フィードバック頂けると嬉しいです。

Releases · KatsuYuzu/TileLogoMaker · GitHub
アイコン作るツールなのにアイコンが初期です(チラ

終わりに

カレンダーありきでやっつけたソースなのでこれから修正 & 育てていきます。ちなみにMSCCはソロ勇者としてアリアハンで眠ったままです。女賢者が見つかったら本気だす。

バトンタッチ!

3日目は@kaorunさんです!

ちなみに最初に参加しようとしたWindows Phoneの方は11月前半くらいに25人埋まってた印象。おかc
Windows Phone Advent Calendar 2014 - Adventar