WebSurfer's Home

トップ > Blog 1   |   Login
Filter by APML

ファイルアップロード時の検証 (CORE)

by WebSurfer 26. January 2021 22:32

ASP.NET Core. 3.1 MVC アプリでファイルをアップロードする際に、カスタム検証属性を利用してファイルのサイズとタイプをクライアント側とサーバー側の両方で検証し、検証結果 NG の場合はエラーメッセージを表示する方法について書きます。

ファイルアップロード時の検証

基本的には先の記事「ASP.NET Core MVC 検証属性の自作」の応用です。先の記事ではパラメータが 1 つだけでしたが、この記事ではファイルのサイズとタイプという 2 つを検証するところが大きな違いです。

クライアント側での検証は、Web Forms アプリの記事「FileUpload と CustomValidator」と同様に、ブラウザの HTML5 File API を利用して JavaScript でファイルのサイズとタイプ情報を取得し、外部から入力されるパラメータ値と比較して検証します。

そのパラメータ値はカスタム検証属性(下のコード例では FileValidationAttribute)をモデルのプロパティに設定する際に引数(下のコード例では 50000 と "image/jpeg")として渡すようにしています。

サーバー側では引数に渡されたパラメータ値(ファイルサイズ / タイプ)を容易に取得でき、検証属性クラスの IsValid メソッドに引数として渡される IFromFile オブジェクトから取得できるファイルサイズとタイプを比較して検証できます。問題はクライアント側での検証用 JavaScript / jQuery にそのパラメータ値をどのように渡すかです。

パラメータが 1 つだけなら先の記事「ASP.NET Core MVC 検証属性の自作」で書いた View の中のスクリプトのように addSingleVal メソッドを利用できます。しかし、2 つあると addSingleVal メソッドでは何ともならず、add メソッドを利用せざるを得ないようです。

add メソッドの使い方は、先の記事を書くときに調べた Unobtrusive Client Validation in ASP.NET MVC 3 という記事に記述がありましたが、具体的にどういうコードを書けばいいのかが分かりません。

やむを得ず検証用の控えめな JavaScript のソースコード jquery.validate.unobtrusive.js を調べると、addBool, addSingleVal, addMinMax メソッドは add メソッドをラップしたものだということが分かりました。その中の addMinMax メソッドが min, max というパラメータを 2 つ渡していますので、それを見よう見まねで書いてみたのが下の View の中のコードです。

Edge, Chrome のデバッガでスクリプトをステップ実行するなどして調べましたが一応は期待通り動いていることを確認しました。具体的にどのような仕組みで動いているかまでは分かっていませんが。(汗)

いかにサンプルコードをアップしておきます。上の画像を表示したものです。要点はコメントに書いたつもりですのでそれを見てください。(手抜きでスミマセン)

Model とカスタム検証属性

using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using System.ComponentModel.DataAnnotations;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using System.Globalization;
using Microsoft.AspNetCore.Mvc.ModelBinding.Validation;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Rendering;

namespace MvcCoreApp.Models
{
    // Model
    public class FileUpload
    {
        [Required(ErrorMessage = "{0} is required.")]
        public string Name { get; set; }

        [Display(Name = "Upload file")]
        [Required(ErrorMessage = "{0} is required.")]
        [FileValidation(50000, "image/jpeg")]
        public IFormFile PostedFile { get; set; }
    }

    // カスタム検証属性
    [AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
    public class FileValidationAttribute : ValidationAttribute, 
                                           IClientModelValidator
    {
        private readonly long size;
        private readonly string type;

        public FileValidationAttribute(long size, string type)
        {
            this.size = size;
            this.type = type;
            this.ErrorMessage = "{0} must be less than {1} byte, {2}";
        }

        public override string FormatErrorMessage(string displayName)
        {
            return String.Format(CultureInfo.CurrentCulture, 
                                 ErrorMessageString, displayName, 
                                 this.size, this.type);
        }

        public override bool IsValid(object value)
        {
            var postedFile = value as IFormFile;

            if (postedFile == null || postedFile.Length == 0)
            {
                return false;
            }

            if (postedFile.Length <= this.size && 
                postedFile.ContentType == this.type)
            {
                return true;
            }

            return false;
        }
        // IClientModelValidator が実装するメソッド。検証対象の input
        // 要素に控えめな JavaScript (jquery.validate.unobtrusive.js)
        // による検証のための属性と値を追加
        public void AddValidation(ClientModelValidationContext context)
        {
            MergeAttribute(context.Attributes, "data-val", "true");
            var errorMessage = FormatErrorMessage(
                context.ModelMetadata.GetDisplayName());
            MergeAttribute(context.Attributes, 
                "data-val-filevalidation", errorMessage);
            MergeAttribute(context.Attributes, 
                "data-val-filevalidation-size", this.size.ToString());
            MergeAttribute(context.Attributes, 
                "data-val-filevalidation-type", this.type);
        }

        // 上の AddValidation メソッドで使うヘルパーメソッド
        private bool MergeAttribute(IDictionary<string, string> attributes, 
                                    string key, string value)
        {
            if (attributes.ContainsKey(key))
            {
                return false;
            }
            attributes.Add(key, value);
            return true;
        }
    }
}

View

@model MvcCoreApp.Models.FileUpload

@{
    ViewData["Title"] = "Upload";
}

<h1>Upload</h1>
<hr />
<div class="row">
    <div class="col-md-4">
        <form asp-action="Upload" enctype="multipart/form-data">
            <div asp-validation-summary="ModelOnly" class="text-danger">
            </div>
            <div class="form-group">
                <label asp-for="Name" class="control-label"></label>
                <input asp-for="Name" class="form-control" />
                <span asp-validation-for="Name" class="text-danger"></span>
            </div>

            <div class="form-group">
                <label asp-for="PostedFile" class="control-label"></label>
                <input type="file" asp-for="PostedFile" />
                <br />
                <span asp-validation-for="PostedFile" class="text-danger">
                </span>
            </div>

            <div class="form-group">
                <input type="submit" value="Create" class="btn btn-primary" />
            </div>
        </form>
    </div>
</div>

@section Scripts {
    @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}


<script type="text/javascript">
    //<![CDATA[
    $.validator.addMethod("filevalidation",
        function (value, element, parameters){
            // HTML5 File API の File, FileList がサポートされている
            // 場合のみ検証する。サポートされてないとスクリプトエラ
            // ーになるので下の if 文は必要
            if (window.File && window.FileList) {

                // ファイルが選択されてないと fileUpload.files[0] は
                // undefined になる。element.files[0] == null は念のため
                if (element.files[0] == undefined ||
                    element.files[0] == null) {
                    // ファイル未選択は RequiredAttribute で検証する
                    // こととし、ここでは true を返すことにした(検証
                    // とエラーメッセージがダブらないように)
                    return true;
                }

                if (element.files[0].type != parameters.type) {                    
                    return false;
                }

                if (element.files[0].size > Number(parameters.size)) {
                    return false;
                }

                return ture;
            } else {
                // HTML5 File API の File, FileList がサポートされて
                // いない場合はクライアント側では検証しない
                return ture;
            }
        });

    // パラメータを 2 つ渡す方法が問題。add メソッドを使わざるを得ない
    $.validator.unobtrusive.adapters.
        add("filevalidation", ["type", "size"], function (options) {

            // 上の検証スクリプトで parameters.type, parameters.size と
            // という形でパラメータを取得するには以下のように JavaScript
            // オブジェクトとして setValidationValues の第 3 引数に渡す。
            // 配列 [options.params.size,options.params.type] としても渡
            // せるが、取得するとき parameters[0] のようにする必要がある
            var value = {
                size: options.params.size,
                type: options.params.type
            };

            setValidationValues(options, "filevalidation", value);
        });

    // jquery.validate.unobtrusive.js からコピーしたヘルパーメソッド
    function setValidationValues(options, ruleName, value) {
        options.rules[ruleName] = value;
        if (options.message) {
            options.messages[ruleName] = options.message;
        }
    }
    //]]>
</script>
}

Comtroller / Action Method

using System;
using System.Collections.Generic;
using Microsoft.AspNetCore.Mvc;
using MvcCoreApp.Models;
using Microsoft.AspNetCore.Http;
using System.IO;
using Microsoft.AspNetCore.Mvc.Rendering;

namespace MvcCoreApp.Controllers
{
    public class ValidationController : Controller
    {
        public IActionResult Upload()
        {
            return View();
        }

        [HttpPost]
        [ValidateAntiForgeryToken]
        public IActionResult Upload(FileUpload model)
        {
            string result = "";
            IFormFile postedFile = model.PostedFile;

            if (ModelState.IsValid)            
            {
                string filename = Path.GetFileName(postedFile.FileName);
                
                result = filename + " (" + postedFile.ContentType + ") - " +
                        postedFile.Length + " bytes アップロード完了";
            }
            else
            {
                result = "ファイルアップロードに失敗しました";
            }

            ViewBag.Result = result;
            return View();
        }
    }
}

Tags: , , , ,

Upload Download

Web API でファイルアップ/ダウンロード (CORE)

by WebSurfer 17. January 2021 17:41

ファイルをアップロード/ダウンロードする相手が ASP.NET Core 3.1 Web API の場合はどのようにすれば良いかについて書きます。

Web API へファイルアップロード

コードを書いてみましたが、先の記事「ASP.NET Core 3.1 Web API」に書いた、(1) Controller は ControllerBase クラスを継承、(2) ApiControllerAttribute 属性を付与、(3) ルーティングは RouteAttibute 属性を付与して設定、アクションメソッドに [HttpGet], [HttpPost] 属性を付与する以外は MVC の場合とほとんど変わりませんでした。

(MVC の場合は、先の記事「ASP.NET Core MVC でファイルアップロード」と「ASP.NET Core MVC でファイルダウンロード」を見てください)

それでこの記事の話は終わってしまうのですが、それではちょっとブログの記事としては寂しいし、今後の参考になるかもしれないので検証に使ったコードを下にアップしておきます。

Web API コントローラー/アクションメソッド

using Microsoft.AspNetCore.Mvc;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using System.IO;

namespace WebAPI.Controllers
{
    [ApiController]
    [Route("[controller]")]
    public class FileUpDownloadController : ControllerBase
    {
        // 物理パスの取得用
        private readonly IWebHostEnvironment _hostingEnvironment;

        public FileUpDownloadController(IWebHostEnvironment hostingEnvironment)
        {
            this._hostingEnvironment = hostingEnvironment;
        }

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

                // アプリケーションルートの物理パスを取得
                // wwwroot の物理パスは WebRootPath プロパティを使う
                string contentRootPath = _hostingEnvironment.ContentRootPath;
                string filePath = contentRootPath + "\\" + 
                                  "UploadedFiles\\" + filename;

                using (var stream = new FileStream(filePath, FileMode.Create))
                {
                    await postedFile.CopyToAsync(stream);
                }

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

            return Content(result);
        }

        [HttpGet]
        [ResponseCache(Duration = 0, 
            Location = ResponseCacheLocation.None, NoStore = true)]
        public IActionResult GetFile(string filename = "sample1.jpg")
        {
            if (string.IsNullOrEmpty(filename))
            {
                return NotFound("引数が null または空");
            }

            // アプリケーションルートの物理パスを取得
            string contentRootPath = _hostingEnvironment.ContentRootPath;

            // ダウンロードするファイルの物理パス
            string physicalPath = contentRootPath + "\\" + 
                                  "Files\\" + filename;

            if (!System.IO.File.Exists(physicalPath))
            {
                return NotFound("指定されたパスにファイルが無い");
            }

            // Content-Disposition ヘッダを設定(RFC 6266 対応してない)
            Response.Headers.Append("Content-Disposition",
                "attachment;filename="+filename);
            
            return new PhysicalFileResult(physicalPath, "image/jpeg");
        }
    }
}

アップロードの検証に使った View

@{
    ViewData["Title"] = "Upload";
}

<h1>Upload</h1>

<hr />
<div class="row">
    <div class="col-md-4">
        <form method="post" enctype="multipart/form-data">
            <div>
                <div>
                    <p>Upload file using this form:</p>
                    @* name 属性はモデルのクラスのプロパティ名と同じ
                       にしないとサーバー側でモデルバインディングさ
                       れないので注意。大文字小文字は区別しない。*@
                    <input type="file" name="postedfile" />
                </div>
            </div>

        </form>
        <div>
            <div>
                <input type="button" id="ajaxUpload" value="Upload" />
                <div id="result"></div>
            </div>
        </div>
    </div>
</div>

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

            $.ajax({
                url: '/FileUpDownload',
                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>

ダウンロードの検証はブラウザのアドレスバーにコントローラーの URL を入力して FileUpDownload を GET 要求すれば可能です。ファイル名はデフォルトで "sample1.jpg" となっていますが、クエリ文字列で別のファイルを指定できます。

Tags: , , ,

Upload Download

異なるフォルダのファイルをアップロード

by WebSurfer 9. July 2020 21:33

ブラウザを使って自分の PC の複数のフォルダから複数のファイルを一度にアップロードするにはどうしたらよいかという話を書きます。

複数ファイルをアップロード

html の input type="file" 要素を使うことが前提です。ブラウザがサポートしていれば input type="file" 要素に multiple="multiple" を追加すれば、ユーザーは複数のファイルを選択して一度にアップロードすることができます。

ファイルを選択する際、上の画像の[ファイル選択]ボタン(画像は Chrome の例です。ブラウザによって異なります)をクリックすると下の画像のダイアログが表示されますので、Shift キーや Ctrl キーを使って複数のファイルを選択できます。

送信するファイルの選択

その後[開く(O)]ボタンをクリックすると送信準備完了となり、form を method="post" で submit してやればサーバーに選択された複数のファイルが送信されます。

ただし、上の操作で送信するファイルを選択できるのは一回だけです。この後、もう一度同じ操作を行って別のファイルを追加することはできません。それをすると、前の操作で選択したファイルは送信対象には含まれなくなり、後の操作で選択したファイルのみが送信されるようになります。それは同じフォルダで繰り返し行う場合も異なるフォルダに移動して行う場合も同じです。

ということは、フォルダが異なるファイルを選択するには各フォルダに移動して選択操作を行わざるを得ませんが、送信できるのは最後の操作で選択したファイルのみになります。なので、複数のフォルダにある複数のファイルを一度にアップロードするのは普通のやり方(form を submit する方法)ではできないようです。

代案は HTML5 File APIFormData を利用し Ajax で送信することです。

以下に .NET Framework 版の ASP.NET MVC5 アプリの例を書いておきます。これは一番上の画像を表示したものです。詳しい説明はコードの中のコメントに書きましたのでそれを見てください。手抜きでスミマセン。

Model

MultipleUploadModels クラスをアクションメソッドの引数に設定すれば、送信されてきた複数ファイルは PostedFiles プロパティにモデルバインドされます。CustomField プロパティはファイル以外の追加情報を一緒に送信するためのものです。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;

namespace Mvc5App.Models
{
    public class MultipleUploadModels
    {
        public string CustomField { get; set; }
        public IList<HttpPostedFileBase> PostedFiles { get; set; }
    }
}

View

input type="file" 要素と input type="button" 要素(ボタン)を配置します。form 要素を生成するための html ヘルパー Html.BeginForm の引数(特に enctype = "multipart/form-data" とすること)に注意してください。

@section Scripts { ... } 内の JavaScript / jQuery のコードが HTML5 File API と FormData を利用し Ajax で複数フォルダ内の複数ファイルを送信するものです。このスクリプトがこの記事のキモです

@model Mvc5App.Models.MultipleUploadModels

@{
    ViewBag.Title = "MultipleUpload2";
}

<h2>MultipleUpload2</h2>

@using (Html.BeginForm("MultipleUpload2", "File", 
    FormMethod.Post, new { enctype = "multipart/form-data" }))
{
    @Html.AntiForgeryToken()

    <div class="form-horizontal">
        <h4>MultipleUploadModels</h4>
        <hr />
        @Html.ValidationSummary(true, "", new { @class = "text-danger" })

        <div class="form-group">
            @Html.LabelFor(model => model.PostedFiles, 
                htmlAttributes: new { @class = "control-label col-md-2" })
            <div class="col-md-10">
                <input id="mutiplefileupload" type="file" 
                       name="postedfiles" multiple="multiple" />
            </div>
        </div>

        <div class="form-group">
            <div class="col-md-offset-2 col-md-10">
                <input type="button" id="ajaxUpload" value="Ajax Upload"
                       class="btn btn-default" />
                <br />
                <div id="result"></div>
            </div>
        </div>
        
    </div>
}

<div>
    @Html.ActionLink("Back to List", "Index")
</div>

@section Scripts {
    @Scripts.Render("~/bundles/jqueryval")

    <script type="text/javascript">
        //<![CDATA[

        $(function () {
            // 複数回送信する場合、送信し終わったら fd をクリアして最初から
            // 始めないと、次に送信するとき前回選んだファイルも送信されダブ
            // ってしまう。このコードはそこは未対応なので注意。

            // ブラウザの HTML5 File API サポートを確認
            if (window.File && window.FileReader && window.FileList) {

                // CSRF 用のトークンを含んだ FormData オブジェクトを取得
                var fd = new FormData(document.querySelector("form"));

                // input type="file" 要素のオブジェクトを取得
                var fileUpload = document.getElementById("mutiplefileupload");

                // input type="file" 要素でダイアログを開いてファイルを選択
                // し[開く]ボタンをクリックすると、その都度 change イベン
                // トが発生する。それにリスナ(下のコードの function )を
                // アタッチして FormData オブジェクトを操作する
                fileUpload.addEventListener('change', function (e) {
                    // files プロパティで FileList オブジェクトを取得
                    var filelist = fileUpload.files;

                    // 一回の操作で複数のファイルを選択できるので、以下
                    // のようにループを回して
                    for (let i = 0; i < filelist.length; i++) {
                        // File オブジェクトを FormData に追加していく
                        fd.append("postedfiles", filelist[i]);
                    }
                });

                // [Ajax Upload] ボタンクリックの処置
                $('#ajaxUpload').on('click', function (e) {

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

                    $.ajax({
                        url: '/file/multipleupload2',
                        method: 'post',
                        data: fd,
                        processData: false, // jQuery にデータを処理させない
                        contentType: false  // contentType を設定させない
                    }).done(function (response) {
                        $("#result").empty;
                        $("#result").html(response);
                    }).fail(function (jqXHR, textStatus, errorThrown) {
                        $("#result").empty;
                        $("#result").text('textStatus: ' + textStatus +
                            ', errorThrown: ' + errorThrown);
                    });
                });
            } else {
                $("#result").empty;
                $("#result").text('File API がサポートされてません。');
            }
        });
        //]]>
    </script>
}

Controller / Action Method

Ajax 呼び出しのみ可能としていますが、普通に input type="submit" ボタンをクリックして post した場合でもファイルのアップロードはできます。ただしその場合は最後のファイル選択操作で選んだファイルのみが送信されます。

using System.Web;
using System.Web.Mvc;
using Mvc5App.Models;
using System.IO;
using System.Collections.Generic;

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

        [HttpPost]
        [ValidateAntiForgeryToken]
        public ActionResult MultipleUpload2(MultipleUploadModels model)
        {
            if (!Request.IsAjaxRequest())
            {
                return Content("Ajax 呼び出しのみ可能");
            }

            string result = "";
            string customFiled = model.CustomField;
            IList<HttpPostedFileBase> postedFiles = model.PostedFiles;

            // ファイルが選択されてない場合でも postedFiles は null にならないし、
            // postedFiles.Count は 0 にならない(1 になる)。従い、以下のコード
            // では制御が else に飛ぶことはないので注意
            if (postedFiles != null && postedFiles.Count > 0)
            {
                foreach (HttpPostedFileBase postedFile in postedFiles)
                {
                    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 アップロード完了<br />";
                    }
                }
                result += "CustomField の文字列: " + customFiled;
            }
            else
            {
                result = "ファイルアップロードに失敗しました";
            }

            return Content(result);
        }
    }
}

注意した方がよさそうと思う点を以下に書いておきます。

  1. 複数回送信する場合、送信し終わったら View の JavaScript のコードにある fd をクリアして最初から始めないと、次に送信するとき前回選んだファイルも送信されダブってしまいます。上のコードはそこは未対応なので注意してください。
  2. ファイルを選択しないで送信した場合でも、上の Controller / Action Method のコードにある postedFiles は null にならないし、postedFiles.Count は 0 になりません(1 になる)。従い、上のコードでは "ファイルアップロードに失敗しました" というエラーメッセージは出ません。  
  3. View のコードにある fd.append("CustomField", "This is some extra data"); でデータを送信して Model の CustomField プロパティにバインドできます。テキストボックスを配置してユーザーに入力してもらい送信してモデルバインドすることも考えましたが、送信前に FormData オブジェクトに append するのが難しく、それは今後の検討課題です。
  4. 上の 2 に書いたファイルを選択しないで送信した場合でも postedFile が null にならず、postedFiles.Count は 1 になる理由は、内容が空のパート(下の Fiddler による要求のキャプチャ画像の 2 つ目のパート)が送信されてくるからです。普通にファイルを選択して input type="submit" ボタンクリックで送信すれば空のパートはなくなりますが、この記事のように File API と FormData を JavaScript で細工するとそれが残ってしまいます。

    想像ですが、var fd = new FormData(document.querySelector("form")); で下の画像の 2 つ目までのパートが取得され、その後の操作で 3 つ目以降が追加されていくからであろうと思います。空のパートを削除する方法は分かりませんでした。

要求ヘッダとコンテンツ

Tags: , , , ,

Upload Download

About this blog

2010年5月にこのブログを立ち上げました。その後 ブログ2 を追加し、ここは ASP.NET 関係のトピックス、ブログ2はそれ以外のトピックスに分けました。

Calendar

<<  May 2021  >>
MoTuWeThFrSaSu
262728293012
3456789
10111213141516
17181920212223
24252627282930
31123456

View posts in large calendar