WebSurfer's Home

Filter by APML

コンマ区切りスクリプトと検証の整合

by WebSurfer 28. November 2019 15:41

数字を 3 桁でコンマ区切りする JavaScript と ASP.NET MVC5 のクライアント側での検証の話です。

コンマ区切りスクリプトと検証の整合

元の話は Teratail のスレッド「3桁コンマ区切り数字をコンマ無しでFrom送信したい」です。

コンマ区切り用 JavaScript のコードの動作は、初期画面では数字を 3 桁でコンマ区切りし、ユーザーが編集するときはコンマを除去し、編集完了後は再び 3 桁でコンマ区切りするというものです。コードは Teratail のスレッドの私 SurferOnWww の回答にありますので見てください。

そのコンマ区切り用 JavaScript の動作と、以下のようにモデルにアノテーション属性を付与するとデフォルトで有効になる控えめな JavaScript による検証が、かなり微妙ながら基本的なところでは整合を取って動きましたので、備忘録として書いておくことにしました。(実は、バッティングして動かないと思い込んでました(汗))

public class PurchaseRecord
{
  // ・・・中略・・・

  [Display(Name = "価格")]
  [Required(ErrorMessage = "{0} は必須")]
  [RegularExpression(@"^\d{1,6}$", 
    ErrorMessage = "数字 1 ~ 6 文字")]
  [Range(100, 10000, 
    ErrorMessage = "{0}は{1}~{2}の間で入力してください。")]
  [DisplayFormat(DataFormatString = "{0:N0}", 
      ApplyFormatInEditMode = true)]
  public decimal Price { get; set; }
}

どのような動きになるかと言うと以下の通りです:

  1. テキストボックスの初期表示は 1,234
  2. ユーザーが編集動作に入る時 focus イベントが発生しスクリプトで 1234 に書き換わる
  3. ユーザーが例えば 3210 というように編集
  4. 次の作業に移るためフォーカスを外す
  5. change イベントが発生し 3210 に対し検証がかかる
  6. blur イベントが発生しスクリプトで 3,210 に書き換える
  7. ユーザーが送信ボタンをクリック
  8. submit イベントが発生しスクリプトで 3,210 を 3210 に書き換える
  9. サーバーで 3210 を受信、サーバー側での検証は OK となる。

・・・という順序になってうまくいきます。

以上、基本的な動きは OK ではあるものの、かなり微妙なところで動いていますので、実際に運用に使う場合は十分な検証が必要だと思います。思いつくのは:

(1) change ⇒ blur の順序でイベントが発生しなければならないが、全てのブラウザでそうかは不明。(メジャーなブラウザは大丈夫のようですが、昔の Forefox は反対だったという話があります)

(2) 上のステップ 3 でユーザーが数字だけ入力してくれると期待するのは無理がある。(上のサンプルでは RegularExpression 属性を追加してチェックするようにしてますが、それで十分か?)

(3) ユーザーがブラウザの JavaScript を無効にした場合はサーバー側だけで検証することになる。

・・・などです。

特に、不特定多数のユーザーが不特定多種のブラウザでアクセスしてくるインターネットに公開するような場合は別の方法(サーバー側だけで検証するとか、String 型にするとか)を考えた方が良いかもしれません。

今のところ気が付いた問題点は以下の通りです:

問題 1: ステップ 3 でユーザーが 3,210 とカンマを入れて入力すると、ステップ 5 の時点ではカンマ入りなので正規表現での検証で引っかかるという問題があります。

問題 2: コンマ区切り用 JavaScript のコードには全角 ⇒ 半角変換の機能が実装されていますが、タイミングの問題で検証に引っかかってしまいます。どういうことかと言うと、全角数字を入力するとステップ 6 の時点で半角に変換しますが、検証がかかるステップ 5 の時点ではまだ全角なので正規表現による検証で NG となります。

その後、ステップ 6 の時点で半角に変換されるので、見かけは正しく半角なのにエラーが出て混乱を招くと思います。なので、全角 ⇒ 半角変換のコードは削除した方がよさそうです。

最後にオマケを二つ書いておきます。

その 1: 上のステップ 2 で編集操作に入った時、キャレットが末尾にあるのが自然と思いますが、そうしたい場合は以下のように 2 行追加してください。

elm.addEventListener('focus',
  function () {
    this.value = delFigure(this.value);

    // キャレットを文字列の末尾に持ってくる
    // ため以下の 2 行を追加
    var len = this.value.length;
    this.setSelectionRange(len, len);
  }, false);

その 2: クライアント側での検証を無効にすると「価格」として有効でない文字列、例えば 123x とかでもサーバーに送信されてしまいます。その場合、モデルバインディングできないのでアノテーション属性に設定した検証がかかる以前にエラーとなります。

そのエラーメッセージが気に入らないので自分で設定したいという場合は Controller にコードを追加して書き換えることができます。詳しくは別の記事「int 型プロパティの検証、エラーメッセージ」を見てください。

Tags: , , ,

Validation

SimpleInjector を ASP.NET MVC & Web API で利用

by WebSurfer 19. October 2019 15:40

以下の画像の通り、Visual Studio Community 2015 の .NET 4.6.1 テンプレートでプロジェクトを作って、ASP.NET MVC と Web API の両方が実装された Web アプリケーションに SimpleInjector を実装してみました。

プロジェクト作成

その話を備忘録として書いておきます。元の話は Teratail のスレッド「SimpleInjectorを使って、ASP.net MVC | WebAPI の両方のコントローラーを持つWebアプリケーションを動作させたい。」です。

上にも書きましたが、プロジェクトは MVC と Web API の両方を実装しており、それらの両方の Controller に Dependency Injection (DI) を行う必要があります。さらに、ユーザー認証は「個別のユーザーアカウント」すなわち ASP.NET Identity を利用しており、自動生成される AccountController.cs の Controller にも DI を行うように設定する必要があります。

以下に、Web API, MVC, AccountControlle の順に実装方法を書きます。

(1) Web API の Controller

ネットの記事「【連載】ASP.NET Web API を使おう:第4回 Simple Injector(DIコンテナ)を適用する」を参考に Web API 用の SimpleInjector の NuGet パッケージ「SimpleInjector.Integration.WebApi.WebHost.QuickStart」を追加します。

SimpleInjector の DI コンテナは Controller のコンストラクタが 1 つだけでないと Controller を初期化できないようで、NuGet の適用で自動生成される SimpleInjectorWebApiInitializer クラスの container.Verify() メソッドでそれをチェックしています。

実際、上のテンプレートで自動生成される AccountController には 2 つコンストラクタが定義されているので、そのままでは container.Verify() でエラーになります。

とりあえずは認証は使わなくても Web API の Controller への DI の実装はできますので、AccountController の引数のある方のコンストラクタをコメントアウトすると container.Verify() は通りますのでそうしておきます。

上に紹介したネットの記事のように実装すると、Web API の Controller の初期化の際、DI コンテナが EmployeeRepository クラスを初期化して、Controller のコンストラクタに設定した IEmployeeRepository 型の引数を通じて渡します。すなわち、Inject する Dependency というのは EmployeeRepository クラスのインスタンスということになります。(DI の Dependency を「依存性」と訳すのは誤訳かも)

以上の設定で、ブラウザから jQuery ajax などで API を呼び出せば、期待した応答が返ってきます。

ここまでは、Web API に SimpleInjector を追加しただけです。MVC に追加するにはどうするかというのを次に書きます。

(2) MVC の Controller

MVC の場合も、上記の Web API と同様、Controller の初期化の際、DI コンテナが EmployeeRepository クラスを初期化して、Controller のコンストラクタに設定した IEmployeeRepository 型の引数を通じて渡せるようにしてみます。

まず、SimpleInjector の設定ですが、それについては stackoverflow の記事「Simple Injector initialize for both MVC and Web API controllers」に以下の記述がありました。

"we should use both .RegisterMvcControllers() and RegisterWebApiControlers() as well as both System.Web.Mvc.DependencyResolver(new SimpleInjectorDependencyResolver(container)) and GlobalConfiguration.Configuration.DependencyResolver(new SimpleInjectorWebApiDependencyResolver(container))"

具体的にどのようにしたかと言うと:

  1. まず、NuGet パッケージの SimpleInjector.Integration.Web.Mvc を追加インストール。
  2. 上の stacloverflow の記事を参考に、Web API 用に自動生成済みの SimpleInjectorWebApiInitializer に MVC 用の container.RegisterMvcControllers(...) と DependencyResolver.SetResolver(...) を追加。
  3. container.Options の DefaultScopedLifestyle の設定を AsyncScopedLifestyle() から WebRequestLifestyle() に変更。

    (SimpleInjector サイトの記事「Async Scoped lifestyle vs. Web Request lifestyle」に書いてある通り、MVC と Web API 混合プロジェクトでは WebRequestLifestyle を使うのが正解らしい)
  4. MVC に Controller を追加し、Web API の Controller と同様に、IEmployeeRepository 型の引数を持つコンストラクタを一つだけ実装。

以上で MVC でも Web API でも SimpleInjector は動くようになりました。次は AccountController です。

(3) AccountController.cs の Controller

これの対応が問題で、一筋縄ではいきませんでした。(笑)

まず、MVC 5 template (ASP.NET Identity 2.0) combined with Simple InjectorSimple Injector with ASP.NET MVC 5 Identity 2.0 を参考に手を加えてみました。

後者の記事によると前者の記事のコードは "it’s based on an older version of Simple Injector, so some syntax changes were required." とのこと故、SimpleInjectorInitializer クラスは主に後者の記事のコードを参考にして実装しました。

しかし、上記の対応だけでは動かなくて、AccountController のコンストラクタにある ISecureDataFormat<AuthenticationTicket> 型の引数が登録されてないというエラーが出ます。

それについては、stackoverflow の記事 Simple Injector and default AccountContoller dependency issue の回答を参考に 4 行追加して対処しました。

それで 100% OK かは分かりませんが、登録・ログイン・ログアウトは問題なくできることだけは確認しました。それ以外の External Login などがどうかは未検証・未確認です。

登録・ログイン画面

上の画像の確認用の画面は、Microsoft のドキュメント「個々のアカウントと ASP.NET Web API 2.2 でのローカル ログインを使用して Web API をセキュリティで保護します」のサンプルコードから借用したものです。AcountController の API に対して jQuery ajax を利用して要求を送信し、登録・ログイン・ログアウトの操作を行います。

最後に、上記の対応済みの SimpleInjectorWebApiInitializer のコードを下に貼っておきます。

手を加える必要があるのは SimpleInjectorWebApiInitializer のコードだけではなく、MVC 5 template (ASP.NET Identity 2.0) combined with Simple Injector に書いてある通り他に何か所もありますので注意してください。

// Startup.cs で初期化するので以下の属性はコメントアウト
//[assembly: WebActivator.PostApplicationStartMethod(
//  typeof(WebApi2.App_Start.SimpleInjectorWebApiInitializer), 
//  "Initialize")]

namespace WebApi2.App_Start
{
  using System.Web.Http;
  using SimpleInjector;
  using SimpleInjector.Integration.WebApi;
  using SimpleInjector.Lifestyles;
  using WebApi2.Models;

  // MVC 用に追加
  using System.Web.Mvc;
  using SimpleInjector.Integration.Web;
  using SimpleInjector.Integration.Web.Mvc;
  using System.Reflection;

  // AccountController に Inject できるように追加
  using Microsoft.AspNet.Identity;
  using Microsoft.AspNet.Identity.EntityFramework;
  using Microsoft.AspNet.Identity.Owin;
  using Microsoft.Owin.Security.DataProtection;
  using Owin;
  using Microsoft.Owin;
  using System.Collections.Generic;
  using System.Web;
  using Microsoft.Owin.Security;
  using Microsoft.Owin.Security.DataHandler;
  using Microsoft.Owin.Security.DataHandler.Serializer;
  using Microsoft.Owin.Security.DataHandler.Encoder;

  public static class SimpleInjectorWebApiInitializer
  {
    // 自動生成された public static void Initialize() { ... }
    // は以下に差し替え。
    public static Container Initialize(IAppBuilder app)
    {
      var container = GetInitializeContainer(app);

      container.Verify();

      // Web API 用
      GlobalConfiguration.Configuration.DependencyResolver = 
        new SimpleInjectorWebApiDependencyResolver(container);

      // MVC 用
      DependencyResolver.SetResolver(
          new SimpleInjectorDependencyResolver(container));

      return container;
    }

    public static Container GetInitializeContainer(
                                             IAppBuilder app)
    {
      var container = new Container();

      container.Options.DefaultScopedLifestyle = 
          new WebRequestLifestyle();

      container.RegisterInstance(app);

      container.Register<ApplicationUserManager>(
                                            Lifestyle.Scoped);

      // 接続文字列名は "DefaultConnection" で固定したので不要
      container.Register(
          () => new ApplicationDbContext(), Lifestyle.Scoped);

      container.Register<IUserStore<ApplicationUser>>(
          () => new UserStore<ApplicationUser>(
              container.GetInstance<ApplicationDbContext>()), 
          Lifestyle.Scoped);

      container.RegisterInitializer<ApplicationUserManager>(
          manager => InitializeUserManager(manager, app));

      // SignInManager は使ってないのでコメントアウト
      //container.Register<SignInManager<ApplicationUser,string>, 
      //    ApplicationSignInManager>(Lifestyle.Scoped);

      container.Register(
          () => container.IsVerifying ? 
              new OwinContext(new Dictionary<string, object>()).
                        Authentication
              : 
              HttpContext.Current.GetOwinContext().
                  Authentication, 
          Lifestyle.Scoped);

      InitializeContainer(container);

      // Web API 用
      container.RegisterWebApiControllers(
                GlobalConfiguration.Configuration);

      // MVC 用
      container.RegisterMvcControllers(
                Assembly.GetExecutingAssembly());

      return container;
    }

    private static void InitializeUserManager(
            ApplicationUserManager manager, IAppBuilder app)
    {
      manager.UserValidator = 
          new UserValidator<ApplicationUser>(manager)
      {
        AllowOnlyAlphanumericUserNames = false,
        RequireUniqueEmail = true
      };

      // Configure validation logic for passwords
      manager.PasswordValidator = new PasswordValidator
      {
        RequiredLength = 6,
        RequireNonLetterOrDigit = true,
        RequireDigit = true,
        RequireLowercase = true,
        RequireUppercase = true,
      };

      var dataProtectionProvider = 
                app.GetDataProtectionProvider();

      if (dataProtectionProvider != null)
      {
        manager.UserTokenProvider = 
            new DataProtectorTokenProvider<ApplicationUser>(
                dataProtectionProvider.Create(
                    "ASP.NET Identity"));
      }
    }

    private static void InitializeContainer(Container container)
    {
      // AccountController のコンストラクタの引数の
      // ISecureDataFormat<AuthenticationTicket> 型の登録

      container.Register<ISecureDataFormat<AuthenticationTicket>, 
        SecureDataFormat<AuthenticationTicket>>(Lifestyle.Scoped);

      container.Register<IDataSerializer<AuthenticationTicket>,
          TicketSerializer>(Lifestyle.Scoped);

      container.Register<ITextEncoder, Base64UrlTextEncoder>(
                                               Lifestyle.Scoped);

      container.Register<IDataProtector>(() =>
          new DpapiDataProtectionProvider().
              Create("ASP.NET Identity"), Lifestyle.Scoped);

      // 以下は自分でコーディングした MVC と Web API の
      // Controller に注入するためのもの
      container.Register<IEmployeeRepository, EmployeeRepository>
          (Lifestyle.Scoped);            
    }
  }
}

Tags: , , ,

Web API

MVC でファイルのアップロード

by WebSurfer 3. August 2019 15:58

.NET Framework 版の MVC5 アプリでファイルをアップロードする方法について書きます。(Core 3.1 版は別の記事「ASP.NET Core MVC でファイルアップロード」に書きましたのでそちらを見てください。)

MVC でファイルのアップロード

普通に form を submit して POST 送信する場合と、jQuery Ajax を利用して非同期で送信する場合の両方の例を紹介します。ちなみに、上の画像は jQuery Ajax を使ってアップロードした結果です。

本題に入る前に、Microsoft の記事「ASP.NET Core でファイルをアップロードする」の「セキュリティの考慮事項」のセクションを一読することをお勧めします。

この記事では上に紹介した Microsoft の記事に書かれたセキュリティ関する配慮がされていませんので注意してください。例えば「アプリと同じディレクトリツリーに、アップロードしたファイルを保持しないでください」とありますが、この記事ではアプリケーションルート直下の UploadedFiles というフォルダに、アップロードされたファイルをチェックせず、ユーザーによって指定されたファイル名でそのまま保存するコードになっています。

この記事ではセキュリティの話はちょっと置いといて、単純に技術的にどうするかということを書きます。気をつけるべき点は以下の通りです。

  1. View では form 要素の enctype 属性に "multipart/form-data" が設定されるようにする。
  2. Controller のアクションメソッドでは、アップロードされたファイルがバインドされるパラメータまたはクラスのプロパティは HttpPostedFileBase 型であること。  
  3. 上で述べたバインドされるパラメータまたはクラスのプロパティの名前は、html ソースの <input type="file" ... /> の name 属性と一致させる。
  4. Internet Explorer (IE) でファイルをアップロードすると、クライ���ント PC でのフルパスがファイル名として送信されることがある(先の記事「IE でアップロードする際のファイル名」を参照)。その場合、HttpPostedFileBase.FieName でファイル名を取得するとクライアント PC でのフルパスになるので、必ず Path.GetFileName を使うこと。  
  5. ワーカープロセスがアップロードするホルダに対する「書き込み」権限を持っていること。
  6. ASP.NET では、デフォルト設定では 4MB を超えるリクエストは送信できないので注意。4MB を超える場合は、web.config の <httpRuntime> セクションの maxLengthRequest の設定で調整できる。

jQuery Ajax を使ってファイルをアップロードする場合は、上記に加えて以下の点に要注目です。

  1. XMLHttpRequest を使用して送信するためのキーと値のペアのセットを取得するために FormData オブジェクトを利用する。詳しくは MDN の記事「FormData オブジェクトの利用」にありますのでそちらを参照してください。
  2. ASP.NET MVC 組み込みの CSRF 防止機能は Ajax でもそのまま使えますので、View での @Html.AntiForgeryToken() と Controller のアクションメソッドへの [ValidateAntiForgeryToken] を忘れずに設定する。

上の画像を表示するのに使ったコードを以下に書いておきます。

Model

public class UploadModels
{
    public string CustomField { get; set; }
    public HttpPostedFileBase PostedFile { get; set; }
}

View

@model Mvc5App.Controllers.UploadModels

@{
    ViewBag.Title = "Upload";
    Layout = "~/Views/Shared/_Layout.cshtml";
}

<h2>Upload</h2>

@using (Html.BeginForm("Upload", "Home", FormMethod.Post,
                new { enctype = "multipart/form-data" }))
{
    // form 内の隠しフィールドは Ajax でも送信される。
    // なので以下に設定したトークンは送信される。もちろん
    // クッキーのトークンも送信されるので、アクションメソ
    // ッドに [ValidateAntiForgeryToken] を付与すれば
    // CSRF の検証はできる
    @Html.AntiForgeryToken()

    // name 属性はモデルのクラスのプロパティ名と同じにしない
    // とサーバー側でモデルバインディングされないので注意。
    // 大文字小文字は区別しない。
    <input type="file" name="postedfile" />
    <button type="submit">Upload by Submit</button>
    <br />
    @ViewBag.Result
}
<br />
<input type="button" id="ajaxUpload" value="Ajax Upload" />
<br />

<div id="result"></div>


@section Scripts {
  <script type="text/javascript">
    //<![CDATA[
    $(function () {
      $('#ajaxUpload').on('click', function (e) {
        // FormData オブジェクトの利用
        var fd = new FormData(document.querySelector("form"));

        // 追加データを以下のようにして送信できる。フォーム
        // データの一番最後に追加されて送信される
        fd.append("CustomField", "This is some extra data");

        $.ajax({
          url: '/home/upload',
          method: 'post',
          data: fd,
          processData: false, // jQuery にデータを処理させない
          contentType: false  // contentType を設定させない
          }).done(function(response) {
            $("#result").empty;
            $("#result").text(response);
          }).fail(function( jqXHR, textStatus, errorThrown ) {
            $("#result").empty;
            $("#result").text('textStatus: ' + textStatus +
                            ', errorThrown: ' + errorThrown);
          });
      });
    });
    //]]>
  </script>
}

Controler / Action Method

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
using System.ComponentModel.DataAnnotations;
using Mvc5App.Models;
using System.IO;

namespace Mvc5App.Controllers
{
  public class HomeController : Controller
  {
    public ActionResult Upload()
    {
      return View();
    }

    [HttpPost]
    [ValidateAntiForgeryToken]
    public ActionResult Upload(UploadModels model)
    {
      string result = "";
      HttpPostedFileBase postedFile = model.PostedFile;
      if (postedFile != null && 
          postedFile.ContentLength > 0)
      {
        // アップロードされたファイル名を取得。ブラウザが IE 
        // の場合 postedFile.FileName はクライアント側でのフ
        // ルパスになることがあるので Path.GetFileName を使う
        string filename = 
                  Path.GetFileName(postedFile.FileName);

        //  保存先のホルダの物理パス\ファイル名
        string path = Server.MapPath("~/UploadedFiles") + 
                      "\\" + filename;

        // アップロードされたファイルを保存
        postedFile.SaveAs(path);

        result = filename + 
                 " (" + postedFile.ContentType + ") - " +
                 postedFile.ContentLength.ToString() + 
                 " bytes アップロード完了";
      }
      else
      {
        result = "ファイルアップロードに失敗しました";
      }

      if (Request.IsAjaxRequest())
      {
        return Content(result);
      }
      else
      {
        ViewBag.Result = result;
        return View();
      }
    }
  }
}

上の例は単一ファイルをアップロードする場合のものです。複数のファイルをアップロードする場合は、例えば html ソースで以下のように name 属性の値に連番で index を付与できれば、

<input type="file" name="postedfiles[0]" />
<input type="file" name="postedfiles[1]" />
 ・・・中略・・・
<input type="file" name="postedfiles[n]" />

または、ブラウザが multiple 属性をサポートしていれば以下のようにして、

<input type="file" name="postedfiles" multiple="multiple" />

Model のクラスを以下のようにすれば PostedFiles にモデルバインドできます。

public class UploadModels
{
    public string CustomField { get; set; }
    public IList<HttpPostedFileBase> PostedFiles { get; set; }
}

Tags: , , ,

Upload Download

About this blog

2010年5月にこのブログを立ち上げました。主に ASP.NET Web アプリ関係の記事です。ブログ2はそれ以外の日々の出来事などのトピックスになっています。

Calendar

<<  February 2025  >>
MoTuWeThFrSaSu
272829303112
3456789
10111213141516
17181920212223
242526272812
3456789

View posts in large calendar