AJAXでファイルをダウンロードする

環境

HTML5に対応したブラウザ

やり方

汎用的にfunctionを作ってみた。

function downloadFile(ajaxUrl, ajaxData, fileName) {
    $.ajax({
        type: "post",
        url: ajaxUrl,
        data: ajaxData,
        xhrFields: { responseType: 'blob' }
    }).done(function (response, _textStatus, _jqXHR) {
        if (window.navigator.msSaveBlob) {
            // IE
            window.navigator.msSaveBlob(response, fileName);
        } else {
            // IE以外
            $('<a>', {
                href: URL.createObjectURL(new Blob([response], { type: response.type })),
                download: fileName
            }).appendTo(document.body)[0].click();
        }
    });
}

// 使い方
downloadFile("/download", $('form').serialize(), "download.zip");

説明

xhrFields: { responseType: 'blob' }$.ajaxでバイナリデータを受け取るために必要なパラメーター。

参考

qiita.com

ASP.NET + Sustainsys.Saml2でシングルサインオンを実装する

環境

Sustainsys.Saml2とは

ASP.NET用のSAML2認証ライブラリー saml2.sustainsys.com

サンプルコード

githubからサンプルコードをダウンロードできるので、それをベースに作るのがおすすめ。 github.com SamplesフォルダーにMVC用、Web Forms用、Owin用などが入っている。ライブラリーがプロジェクト参照になっているので、開発する前にNuGetパッケージを参照するように変更したほうがいいと思う。

開発用スタブIdP

公式がWEBで公開している他、前述のサンプルコードにも入っている。

実装

以下はMVC用のサンプルをベースにした例。

やりたいこと

  • IdPは固定で1つだけ。Discovery Serviceは使わない。
  • リクエスト・・・HTTP Redirect Binding
  • レスポンス・・・HTTP POST Binding
  • リクエスト・レスポンス共に署名する。
  • 認証後はユーザー情報をセッションに格納しておく。

設定

Web.configのsustainsys.saml2要素を以下のように変更。

<sustainsys.saml2 entityId="http://localhost:2181/Saml2"
                  returnUrl="http://localhost:2181/Home/Index"
                  authenticateRequestSigningBehavior="Always">
  <identityProviders>
    <add entityId="https://stubidp.sustainsys.com/Metadata"
         signOnUrl="https://stubidp.sustainsys.com/"
         allowUnsolicitedAuthnResponse="true"
         binding="HttpRedirect" wantAuthnRequestsSigned="true">
      <signingCertificate fileName="~/App_Data/stubidp.sustainsys.com.cer"/>
    </add>
  </identityProviders>
  <serviceCertificates>
    <add fileName="~/App_Data/Sustainsys.Saml2.Tests.pfx"/>
  </serviceCertificates>
</sustainsys.saml2>

設定はIdP次第なのであくまで一例として。サンプルとの相違点は以下のとおり。

  • Discovery Serviceを使わないのでdiscoveryServiceUrl属性とfederations要素を削除
  • リクエストに署名するためauthenticateRequestSigningBehavior="Always"追加
  • identityProviderの属性にwantAuthnRequestsSigned="true"追加

ちなみに署名をファイルで指定しているが、本番リリースの際は証明書ストアを使うべき、とのこと。

認証後処理の追加

Global.asax.csを以下のように変更。

using SampleMvcApplication.Models;
using Sustainsys.Saml2.Mvc;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Web;
using System.Web.Mvc;
using System.Web.Optimization;
using System.Web.Routing;

namespace SampleMvcApplication
{
    public class MvcApplication : System.Web.HttpApplication
    {
        protected void Application_Start()
        {
            AreaRegistration.RegisterAllAreas();
            FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
            RouteConfig.RegisterRoutes(RouteTable.Routes);
            BundleConfig.RegisterBundles(BundleTable.Bundles);

            Saml2Controller.Options.Notifications.AcsCommandResultCreated = (cr, r) =>
            {
                // クレームからIDを取得
                string userId = cr.Principal.Claims.Single(claim => claim.Type == ClaimTypes.NameIdentifier)?.Value;

                // なんらかの方法でユーザー情報を取得
                UserModel userInfo = GetUserInfo(userId);

                // セッションにユーザー情報を格納
                HttpContext.Current.Session["USER_INFO"] = userInfo;
            };
        }
    }
}

Saml2Controller.Options.Notifications.[Notification名]にコールバック関数を設定することで、ライブラリーの中の様々なポイントに処理を追加したりカスタマイズしたりできる。AcsCommandResultCreatedは一連の認証プロセスの最後に発生するので、ユーザー情報の取得とセッションへの格納はここに追加する。

認証時に発生するNotificationの例をいくつか挙げると、

Notification名 発生タイミング・用途
AuthenticationRequestCreated 認証リクエスト作成後に発生。リクエストをカスタマイズしたい場合はここで。
SignInCommandResultCreated IdPへのリダイレクトを返す前に発生
AcsCommandResultCreated ACSの処理が終わった後に発生
ValidateAbsoluteReturnUrl ReturnUrlに絶対パスを指定されたとき発生。パスのOK・NGを判定する関数を設定する。デフォルトだと絶対パスは常にNG。
SelectIdentityProvider 認証に使用するIdPを決定する際に発生。IdPオブジェクトを返す関数を設定すると既定のロジックの代わりにこちらで決定される。

などがある。他にも色々あるので詳細はSaml2Notificationsクラスのソースを参照。

Saml2/Saml2Notifications.cs at master · Sustainsys/Saml2 · GitHub

あとがき

充実したサンプルにスタブIdPとまさに至れり尽くせりなライブラリー。まあ欲を言えば、Notificationの使い方はドキュメントに書いておいてほしかったけど。

C#で共有フォルダーを作成する

やり方

net shareコマンドを使う方法とWin32 Apiを使う方法がある。以下、MyNewShareという名前でC:\Shareを共有し、EveryoneにFull Controlを付与するサンプル。

なお、どちらの方法でも管理者権限が必要となる。

net shareコマンドを使う方法

ProcessStartInfo info = new ProcessStartInfo("net", @"share MyNewShare=C:\Share /grant:everyone,full");
info.CreateNoWindow = true;
Process.Start(info);

Win32 Apiを使う方法

// フォルダーの共有
ManagementClass share = new ManagementClass("Win32_Share");

ManagementBaseObject inParams = share.GetMethodParameters("Create");
inParams["Name"] = "MyNewShare";
inParams["Path"] = @"C:\Share";
inParams["Type"] = 0x0; // Disk Drive

ManagementBaseObject outParams = share.InvokeMethod("Create", inParams, null);
if ((uint)(outParams.Properties["ReturnValue"].Value) != 0)
{
    throw new Exception("フォルダーの共有に失敗しました。");
}

// 権限の設定
// Everyoneを選択
NTAccount ntAccount = new NTAccount("Everyone");

// SID取得
SecurityIdentifier userSID = (SecurityIdentifier)ntAccount.Translate(typeof(SecurityIdentifier));
byte[] utenteSIDArray = new byte[userSID.BinaryLength];
userSID.GetBinaryForm(utenteSIDArray, 0);

// Trustee
ManagementClass userTrustee = new ManagementClass("Win32_Trustee");
userTrustee["Name"] = "Everyone";
userTrustee["SID"] = utenteSIDArray;

// ACE
ManagementClass userACE = new ManagementClass("Win32_Ace");
userACE["AccessMask"] = 2032127; // Full access
userACE["AceFlags"] = AceFlags.ObjectInherit | AceFlags.ContainerInherit;
userACE["AceType"] = AceType.AccessAllowed;
userACE["Trustee"] = userTrustee;

// SecurityDescriptor
ManagementClass userSecurityDescriptor = new ManagementClass("Win32_SecurityDescriptor");
userSecurityDescriptor["ControlFlags"] = 4; // SE_DACL_PRESENT 
userSecurityDescriptor["DACL"] = new object[] { userACE };

// 共有フォルダーに権限をセット
ManagementObject myNewShare = new ManagementObject(share.Path + ".Name='MyNewShare'");
object result = myNewShare.InvokeMethod("SetShareInfo", new object[] { Int32.MaxValue, null, userSecurityDescriptor });
if ((uint)result != 0)
{
    throw new Exception("権限の設定に失敗しました。");
}

解説

net shareコマンドを使おう。

と言いたいところだが、もっと汎用的な作りにしたいのなら、引数を文字列で組み立てなければならないnet shareよりWin32 Apiのほうがいいかもしれない。また、権限の設定を省略するとEveryoneにRead権限のみが付与されるのだが、それでよければWin32 Apiでもかなりコードを短縮*1できる。

参考

social.msdn.microsoft.com

*1:"権限の設定"というコメント以降を省略できる

Datatables小ネタ集

Datatablesとは

DataTables | Table plug-in for jQuery

行選択イベント

行選択時のイベントを捕まえる方法は二通りある。

select/deselectイベント

  • ユーザーによる選択でもコードによる選択でも発火する。
  • タイミングは選択された「後」。
  • singleモードだとselectより先に前に選択されていた行のdeselectが発火する。

user-selectイベント

  • ユーザーによる選択のみで発火する。
  • タイミングは選択される「前」、なのでキャンセルが可能。
  • selectでもdeselectでも発火する。クリックされたのが選択行か、未選択行かで判別できる。

AJAXリクエストを中止する

table.settings()[0].jqXHR.abort();

リクエスト開始前だとtable.settings()[0].jqXHRがnullなので注意。

FixedColumnsとinput

FixedColumnsで作成した固定列にinputを配置するとpostのときに同じ名前の値が2つ飛ぶ。これは固定列が実はクローンであり、裏側にオリジナルが隠れているため。クライアント側、またはサーバー側でなんらかの対応が必要。

ちなみに縦スクロールを有効にした場合のヘッダー・フッターもクローンなのでinputを配置すると同じ問題が発生する。というわけでinputを固定列やヘッダー・フッターに配置するのはなるべく避けたい。

セルの結合

tbodyでのrowspanはできない。

inputを配置した列のソート

通常、ソートには内部のキャッシュが利用されるためロード後に入力された値はソートに反映されない。入力された値でソートしたい場合は下記のリンクを参照。

columns.orderDataType

DataTables example - Live DOM ordering

inputを配置した列のファイル出力

こちらも同様、キャッシュを利用しているためロード後に入力された値は出力に反映されない。以下は列のrenderオプションとbuttonsのexportOptionsを利用して入力された値を出力するサンプル。

$('#table1').DataTable({
    dom: 'Bfrtip',
    columns: [
        {
            // inputの値
            render: function (data, type, row, meta) {
                if (type === 'export') {
                    var input = $('<div>' + data + '</div>').find('input');
                    data = $('[name="' + input.attr("name") + '"]').val();
                }

                return data;
            }
        },
        {
            // selectのテキスト
            render: function (data, type, row, meta) {
                if (type === 'export') {
                    var input = $('<div>' + data + '</div>').find('select');
                    data = $('[name="' + input.attr("name") + '"] option:selected').text();
                }

                return data;
            }
        },
        {
            // チェックボックス・ラジオボタン
            render: function (data, type, row, meta) {
                if (type === 'export') {
                    var input = $('<div>' + data + '</div>').find('input');
                    data = $('[name="' + input.attr("name") + '"][value="' + input.attr("value") + '"]').prop('checked').toString();
                }

                return data;
            }
        }
    ],
    buttons: [{
        bom: true, // エクセルで文字化けしないようにbom付き
        extend: 'csv',
        exportOptions: {
            orthogonal: 'export'
        }
    }]
});

renderの引数、dataにはセルデータが入っているけど、これは前述の通り、ロード時にキャッシュされた古いデータ。なので取り出したinputのname属性をキーにして最新のinputを取得しなおす。

AJAX処理を順番に実行する

やりたいこと

複数のAJAX処理を一つずつ順番に、前の処理が終わるのを待って実行したい。

環境

jQuery 1.8 以降

やり方

jQueryのDeferredオブジェクトを利用してAJAX実行Queueを作成する。途中でエラーが発生した際にそのまま続行するQueueと以降の処理をキャンセルするQueue、2通り作ってみた。

エラー後も続行するQueue

// AJAX実行Queue
var ajaxQueue = function () {
    var previous = new $.Deferred().resolve();

    return function (fn) {
        // then()の第1と第2引数に同じ関数を渡す
        return previous = previous.then(fn, fn);
    };
}();

// キューが空なので即実行される
ajaxQueue(function () {
    return $.get('/first');
});

// 1つ目の処理が終わったら実行される
ajaxQueue(function() {
    return $.get('/second');
});

エラー後はキャンセルするQueue

この場合、一度エラーが発生すると以降常にキャンセルされ続けてしまうため、再び処理を開始するための初期化関数も用意した。

// AJAX実行Queue
var ajaxQueue = function () {
    var previous = new $.Deferred().resolve();

    return {
        put: function (fn) {
            // then()の第1引数のみ渡す
            return previous = previous.then(fn);
        },
        init: function () {
            // Deferredオブジェクトの初期化
            previous = new $.Deferred().resolve();
        }
    };
}();

// キューが空なので即実行される
ajaxQueue.put(function () {
    return $.get('/first');
});

// 1つ目の処理でエラーが発生していなければ実行される
ajaxQueue.put(function() {
    return $.get('/second');
});

// 初期化
ajaxQueue.init();

補足

AJAX処理に.done().fail()が登録されている場合、それらの実行まで完了してから次の処理が開始される。

おまけ

前の処理が終わっていないときは次の処理を阻止したい、という場合。

var ajaxQueue = function () {
    var previous = new $.Deferred().resolve();

    return function (fn) {
        if (previous.state() == 'pending') {
            alert('通信中です。しばらくお待ち下さい。');
            return;
        }
        
        return previous = previous.then(fn, fn);
    };
}();

参考

stackoverflow.com

ファイルダウンロード用のViewを自作する

Springでファイルダウンロードを実装する方法はいろいろあるが、今回はViewを自作する方向でやってみたい。

利点

  • レスポンスヘッダーの設定など、冗長になりがちなコードをコントローラーに書かなくて済む。
  • 戻り値の型を文字列で統一できるので、条件によってダウンロードか画面表示かに分岐するようなコントローラーが作れる。

環境

Spring Boot 1.4

やり方

ファイルの種類ごとにViewを作成する。以下はPDFの例。

View

ファイルダウンロードの体裁を整えるクラス。コントローラーとのデータの受け渡しはModelを通して行う。ここではファイルデータをcontents、ファイル名をfileNameという名前で受け取ることにしている。

import java.util.Map;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.util.FileCopyUtils;
import org.springframework.web.servlet.view.AbstractView;

public class PdfView extends AbstractView {
    @Override
    protected boolean generatesDownloadContent() {
        return true;
    }
    
    @Override
    protected void renderMergedOutputModel(Map<String, Object> model, HttpServletRequest request, HttpServletResponse response)
            throws Exception {
        byte[] pdf = (byte[]) model.get("contents");
        String fileName = (String) model.get("fileName");

        response.setHeader("Content-Disposition", "attachment; filename*=utf-8''"
                + java.net.URLEncoder.encode(fileName, "UTF-8"));
        response.setContentLength(pdf.length);
        response.setContentType("application/pdf");
        FileCopyUtils.copy(pdf, response.getOutputStream());
    }
}

設定

自作したViewとBeanNameViewResolverをBean定義する。

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;
import org.springframework.web.servlet.view.BeanNameViewResolver;

@Configuration
public class WebMvcConfig extends WebMvcConfigurerAdapter {
    @Bean
    public BeanNameViewResolver fileDownloadViewResolver() {
        BeanNameViewResolver resolver = new BeanNameViewResolver();
        resolver.setOrder(0);
        return resolver;
    }

    @Bean
    public PdfView pdfView() {
        return new PdfView();
    }
}

Controller

先に書いた通り、ファイルデータをcontents、ファイル名をfileNameという名前でModelにセットした後、自作したViewのBean名、pdfViewを戻り値として返す。

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class RootController {
    @GetMapping("pdf")
    public String pdf(Model model) {
        // PDFファイルの取得
        byte[] contents = getPdf();
        
        model.addAttribute("contents", contents);
        model.addAttribute("fileName", "テスト.pdf");
        return "pdfView";
    }
}

動的に追加される要素にイベントハンドラーをアタッチしたい

例えばテーブルの各行にクリックイベントをアタッチしたいとき、それがAJAXで非同期にデータをロードするテーブルならばロードのたびにアタッチし直さなければならないし、ユーザーが自由に行追加できるテーブルならばその都度追加行にアタッチしなければならない。

動的にイベントをアタッチするようなコードは、同じイベントを二重にアタッチしてしまう、といった事故を起こしがち。できればイベントのアタッチはページロード時に一発で済ませたい。そんなときはdelegatedなイベントハンドラーを利用するとよい。

環境

jQuery 1.7以降

delegatedなイベントハンドラーとは

イベントの発生元に直接ハンドラーをアタッチするのではなく、親要素にアタッチして子から伝播してきたところを捕まえよう、というコンセプトのハンドラー。

やり方

冒頭に書いたテーブルの例だと以下の通り。

$( "#dataTable tbody" ).on( "click", "tr", function() {
    console.log( $( this ).text() );
});

説明

trではなくその親要素、tbodyにイベントをアタッチする。イベントの発生元が第2引数のセレクタtrにマッチしない限りイベントは発火しない。イベントを捕まえるのはtbodyなので、子要素のtrが増えたり書き換わったりしても影響を受けない。

注意

Attaching many delegated event handlers near the top of the document tree can degrade performance.(中略)For best performance, attach delegated events at a document location as close as possible to the target elements. Avoid excessive use of document or document.body for delegated events on large documents.
.on() | jQuery API Documentation

ドキュメントツリーのトップに近い要素にdelegatedなイベントハンドラーを多数アタッチするとパフォーマンスが落ちる可能性がある。documentとかdocument.bodyとかにやたらとアタッチするのは避け、できるだけターゲットに近い要素にアタッチしましょう、とのこと。