WebSurfer's Home

トップ > Blog 1   |   ログイン
APMLフィルター

Razor Pages アプリでファイルアップロード

by WebSurfer 2024年5月6日 13:10

ASP.NET Core Razor Pages アプリでファイルをアップロードする方法について書きます。先の記事「ASP.NET Core MVC でファイルアップロード」の Razor Pages 版です。MVC ⇒ Razor Pages にしたこと以外で先の記事と違うのは、ターゲットプラットフォームを .NET 8.0 にしたことと、jQuery ajax に代えて fetch API を使ったことです。

Razor Pages でファイルアップロード

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

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

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

セキュリティの話はちょっと置いといて、この記事では単純にファイルをアップロードするにはどうするかということを書きます。気をつけるべき点は以下の通りです。

  1. cshtml のコードでは form 要素の enctype 属性に "multipart/form-data" を設定する。
  2. cshtml.cs の OnPost メソッドが受け取るモデルの、アップロードされたファイルがバインドされるプロパティは IFormFile 型とする。  
  3. 上に述べたプロパティの名前は、html ソースの <input type="file" ... /> の name 属性と一致させる。
  4. ブラウザによってはクライアント PC でのフルパスがファイル名として送信されることがあるので、Path.GetFileName を使ってパスを除いたファイル名のみを取得する。  
  5. ワーカープロセスがアップロードするホルダに対する「書き込み」権限を持っていること。(IIS でホストする場合です。Kestrel の場合も権限が必要なのは同じだと思いますが詳しいことは未調査で分かりません)
  6. IIS も Kestrel も最大要求本文サイズに 30,000,000 バイトの制限がある。詳しくは上に紹介した Microsoft の記事の「IIS」または「Kestrel の最大要求本文サイズ」のセクションを見てください。変更方法も書いてあります。

fetch API を使ってファイルをアップロードする場合は、上記に加えて以下の点に注意してください。

  1. fetch API を使用して送信するフォームデータを取得するために FormData オブジェクトを利用する。詳しくは MDN の記事「FormData オブジェクトの利用」にありますのでそちらを参照してください。FormData オブジェクトには CSRF 防止のための隠しフィールドのトークンも含まれます。同時にクッキーの CSRF 防止のためのトークンも送信されるので、サーバー側で検証が可能になります。
  2. fetch API を使う場合、デフォルトでは要求ヘッダに X-Requested-With: XMLHttpRequest は含まれない。サーバー側での判定のためなどにそのヘッダが必要な場合、クライアントスクリプトにそのヘッダを追加するコードを書く必要があります。

サンプルコードを以下に載せておきます。この記事の一番上の画像は下のサンプルコードの実行結果です。

Model

namespace RazorPages1.Models
{
    public class UploadModels
    {
        public string? CustomField { get; set; }
        public IFormFile? PostedFile { get; set; }
    }
}

cshtml

@page
@model RazorPages1.Pages.File.UploadModel

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

<style type="text/css">
    /*Bootstrap5 には form-group は無いので 4 と同じものを追加*/
    .form-group {
        margin-bottom: 1rem;
    }
</style>

<h1>Upload</h1>
<hr />
<div class="row">
    <div class="col-md-4">
        <form method="post" enctype="multipart/form-data">
            <div class="form-group">
                <div class="col-md-10">
                    <p>Upload file using this form:</p>
                    @* name 属性はモデルのプロパティ名と同じにする
                    こと。大文字小文字の区別はない*@
                    <input type="file" name="postedfile" />
                </div>
            </div>
            <div class="form-group">
                <div class="col-md-10">
                    <input type="submit" value="Submit Form"
                           class="btn btn-primary" />
                    <div>@Model.Message</div>
                </div>
            </div>
        </form>

        <div class="form-group">
            <div class="col-md-10">
                <input type="button" id="ajaxUpload"
                    value="Use Fetch API" class="btn btn-primary" />
                <div id="result"></div>
            </div>
        </div>
    </div>
</div>

@section Scripts {
    <script type="text/javascript">
        //<![CDATA[

        document.getElementById("ajaxUpload")
            .addEventListener('click', async () => {
                const fd = new FormData(document.querySelector("form"));
                const result = document.getElementById("result");

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

                const param = {
                    method: "POST",
                    body: fd,
                    // jQuery ajax と違って X-Requested-With ヘッダは
                    // 自動的には送られないので以下の設定で対応
                    headers: { 'X-Requested-With': 'XMLHttpRequest' }
                }

                const response = await fetch("/file/upload", param);
                if (response.ok) {
                    const data = await response.text();
                    result.innerText = data;
                } else {
                    result.innerText = "response.ok が false";
                }
            });

        //]]>
    </script>
}

cshtml.cs

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using RazorPages1.Models;

namespace RazorPages1.Pages.File
{
    public class UploadModel : PageModel
    {
        // Core では Server.MapPath が使えないことの対応
        private readonly IWebHostEnvironment _hostingEnvironment;

        public UploadModel(IWebHostEnvironment hostingEnvironment)
        {
            this._hostingEnvironment = hostingEnvironment;
        }
        
        public void OnGet()
        {
        }

        public string? Message { get; set; }

        [BindProperty]
        public UploadModels Model { get; set; } = default!;

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

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

                // アプリケーションルートの UploadedFiles フォルダに
                // ファイルを保存する
                string filePath = $"{rootPath}\\UploadedFiles\\{filename}";
                using (var fs = new FileStream(filePath, FileMode.Create))
                {
                    await postedFile.CopyToAsync(fs);
                }

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

            // クライアントスクリプトによるアップロードか否かを判定。
            // fetch API を使う場合は X-Requested-With: XMLHttpRequest
            // ヘッダを送るようクライアントスクリプト側でコーディング
            // が必要
            if (Request.Headers.XRequestedWith == "XMLHttpRequest")
            {
                return Content(result);
            }
            else
            {
                Message = result;
                return Page();
            }
        }
    }
}

Tags: , , ,

Upload Download

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

by WebSurfer 2023年3月9日 10:23

.NET Framework 版の ASP.NET Web API はファイルアップロードの受信に対応してません。正確に言うと multipart/form-data 形式で送信されてきたデータのフォーマッターが実装されていません。

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

なので、アクションメソッドの引数を Post(HttpPostedFileBase file) というようして、それに multipart/form-data 形式でファイルを送信すると UnsupportedMediaTypeException がスローされ、

"メディアの型 'multipart/form-data' のコンテンツから型 'HttpPostedFileBase' のオブジェクトを読み取るために使用可能な MediaTypeFormatter がありません。"

・・・というエラーになります。

その対処方法を書きます。基本的には Microsoft のドキュメント「ASP.NET Web APIでの HTML フォーム データの送信: ファイルのアップロードとマルチパート MIME」に書いてあった方法です。

例えば以下のような View で HTML5 fetch API を使ってファイルをアップロードするとします。

<form id="form1" method="post" enctype="multipart/form-data">
    <input name="caption" type="text" value="Summer Vacation" />

    <input type="file" name="file" />
</form>

<button type="button" id="button1" class="btn btn-primary">
    アップロード
</button>

<div id="result1"></div>

@section Scripts {
    <script type="text/javascript">
        let resultDiv, uploadButton;

        window.addEventListener('DOMContentLoaded', () => {
            uploadButton = document.getElementById("button1");
            resultDiv = document.getElementById("result1");
            uploadButton.addEventListener('click', uploadFile);
        });

        const uploadFile = async () => {
            let fd = new FormData(document.getElementById("form1"));
            const param = {
                method: "POST",
                body: fd
            }
            const response = await fetch("/api/FileUpload", param);
            if (response.ok) {
                const message = await response.text();
                resultDiv.innerText = message;
            } else {
                resultDiv.innerText = "アップロード失敗";
            }
        };
    </script>
}

送信されるデータは以下のように multipart/form-data 形式になります。

------WebKitFormBoundaryoasm5HIwy0wijTSo
Content-Disposition: form-data; name="caption"

Summer Vacation
------WebKitFormBoundaryoasm5HIwy0wijTSo
Content-Disposition: form-data; name="file"; filename="0305Result.jpg"
Content-Type: image/jpeg

それを .NET Framework 版の ASP.NET Web API のアクションメソッドで取得してサーバーのフォルダにファイルとして書き込むコードを書いてみました。以下の通りです。

説明はコメントに書きましたのでそれを見てください。手抜きでスミマセン。

using System.Diagnostics;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Web;
using System.Web.Http;
using System.Threading.Tasks;
using System.Collections.Specialized;
using System.Text;
using System.Net.Http.Headers;

namespace WebAPI.Controllers
{
    public class FileUploadController : ApiController
    {
        // アクションメソッドは引数無しにする。フォーマッタを呼び出さ
        // ずアクションメソッド内で要求本文を処理するため
        public async Task<HttpResponseMessage> Post()
        {
            // 要求にマルチパート MIME メッセージが含まれているか確認
            if (!Request.Content.IsMimeMultipartContent())
            {
                throw new HttpResponseException(
                               HttpStatusCode.UnsupportedMediaType);
            }

            // アプリケーションルート直下に UploadedFiles というフォル
            // ダを設け、そこにアップロードされてきたファイルを書き込
            // む。以下のコードでそのフォルダの物理パスを取得
            string root = HttpContext.Current.Server
                                     .MapPath("~/UploadedFiles");

            // アップロードされたファイルにファイルストリームを割り当て
            var provider = new MultipartFormDataStreamProvider(root);

            // マルチパート MIME メッセージのすべてのファイル部分を
            // 抽出し、上の root で指定されたフォルダに書き込む。
            // ファイル名は ReadAsMultipartAsync が自動的に一意に
            // なるように生成する
            await Request.Content.ReadAsMultipartAsync(provider);

            // ReadAsMultipartAsync メソッドが完了すると、FileData
            // プロパティで MultipartFileData オブジェクトのコレ
            // クションを取得できる。MultipartFileData オブジェクト
            // からファイルに関する情報を取得できる
            foreach (MultipartFileData file in provider.FileData)
            {
                // ReadAsMultipartAsync メソッドが自動的に生成した
                // ファイル名をフルパスで取得。UploadedFiles フォル
                // ダにはその名前でアップロードされてきたファイルが
                // 書き込まれている
                string source = file.LocalFileName;

                // 要求ヘッダに含まれるファイル名を取得。取得した
                // 文字列は何故か " で囲われるのでそれを除去
                var name = file.Headers.ContentDisposition
                                       .FileName.Replace("\"", "");
                string dist = root + "\\" + name;

                // ReadAsMultipartAsync メソッドが生成したファイル
                // 名を要求ヘッダに含まれるファイル名に書き換える。
                // 同名のファイルが存在する場合は先に削除する
                File.Delete(dist);
                File.Move(source, dist);
            }

            // <input name="caption" type="text" /> で送信されて
            // きたデータは以下のようにして取得できる
            NameValueCollection col = provider.FormData;
            foreach (string s in col.AllKeys)
            {
                Trace.WriteLine($"Key: {s}, Value: {col[s]}");
            }

            string result = "アップロード完了";

            // .NET Framework 版の Web API は JSON を返すのが基本
            // らしく、アクションメソッドの戻り値の型を string にし
            // て return result; とすると送信される文字列は " で囲
            // われてしまうので以下のようにする
            return new HttpResponseMessage
            {
                Content = new StringContent(result,
                                            Encoding.UTF8,
                                            "text/plain")
            };
        }
    }
}

何か見落としがあるような気もしますが、自分が検証した限り期待通りに動きました。

ちなみに、ASP.NET MVC5 および .NET 6.0 で作った ASP.NET Core Web API では multipart/form-data 形式に対応するフォーマッターが組み込まれているので、上記のようなことをする必要はありません。そちらの方向に進んだ方が正解かもしれません。

Tags: ,

Web API

img 要素の画像データを取得してアップロード

by WebSurfer 2023年2月1日 14:30

下の画像のように html の img 要素の src 属性に url が指定されてブラウザ上に表示されている状態で、その画像データを JavaScript で取得し、さらにサーバーにアップロードする方法を書きます。

img 要素の画像をアップロード

img 要素から直接画像データを JavaScript で取得する方法は無さそうです。少なくとも自分は見つけられませんでした。なので、img 要素の画像を一旦 canvas に描画し、canvas から画像データを取得するようにしました。

なお、この記事の方法は src 属性の url が同一オリジンになっている場合に限りますので注意してください。

クロスドメインになっている場合は、canvas から画像データを取得する toDataURL メソッドまたは toBlob メソッドで DOMException がスローされます。エラーメッセージは、例えば Chrome で toDataURL メソッドを使った場合、以下のようになります。

DOMException: Failed to execute 'toDataURL' on 'HTMLCanvasElement': Tainted canvases may not be exported.

以下にコード例を載せます。ASP.NET Core MVC の View のものですが、html とJavaScript のコードはそのまま使えるはずです。

コードのコメントに書いた「その1」は toDataURL メソッドを使って画像データを DataURL 形式の文字列として取得し JSON 文字列として送信するものです。データは BASE64 でエンコードされるのでバイナリ形式よりサイズが約 1.3 倍大きくなることに注意してください。

「その2」は toBlob メソッドを使って画像データをバイナリ形式で取得し multipart/form-data 形式で送信するものです。toBlob メソッドは非同期で動き、バイナリデータは引数のコールバックに渡されることに注意してください。

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

<h1>UploadImage</h1>

<input id="button1" type="button" value="Upload DataUrl" />
<input id="button2" type="button" value="Upload Blob" />
<br />
<img id="image1" src="/images/911GT2_2.jpg" alt="" />
<div id="result"></div>

@section Scripts {
    <script type="text/javascript">
        //<![CDATA[
        let myImage, uploadDataUrl, uploadBlob, myCanvas, resultDiv;

        // DOMContentLoaded イベントのリスナ設定
        window.addEventListener('DOMContentLoaded', () => {
            uploadDataUrl = document.getElementById("button1");
            uploadBlob = document.getElementById("button2");
            myImage = document.getElementById("image1");
            resultDiv = document.getElementById("result");
            myCanvas = document.createElement("canvas");

            uploadDataUrl.addEventListener('click', uploadDataUrlImage);
            uploadBlob.addEventListener('click', uploadBlobImage);
        });

        // その1
        // 上の img 要素の画像を canvas に描画、canvas の画像データ
        // を DataURL 形式の文字列として取得し JSON 文字列を組み立
        // て、それを fetch API を使ってサーバーに送信。
        const uploadDataUrlImage = async () => {
            const context = myCanvas.getContext('2d');
            myCanvas.setAttribute('width', myImage.width);
            myCanvas.setAttribute('height', myImage.height);
            context.drawImage(myImage, 0, 0);
            const dataUrl = myCanvas.toDataURL("image/jpeg");
            const params = {
                method: "POST",
                body: '{"imgBase64":"' + dataUrl + '"}',
                headers: { 'Content-Type': 'application/json' }
            }
            const response = await fetch("/api/Canvas", params);
            if (response.ok) {
                const message = await response.text();
                resultDiv.innerText = message;
            } else {
                resultDiv.innerText = "アップロード失敗";
            }
        }

        // その2
        // img 要素の画像を canvas に描画、canvas の画像データを 
        // Blob 形式で取得し、それを fetch API を使ってサーバー
        // に送信。
        const uploadBlobImage = async () => {
            const context = myCanvas.getContext('2d');
            myCanvas.setAttribute('width', myImage.width);
            myCanvas.setAttribute('height', myImage.height);
            context.drawImage(myImage, 0, 0);

            const data = await getBlobFromCanvas();

            const formData = new FormData();
            formData.append("postedFile", data);
            const param = {
                method: "POST",
                body: formData
            }
            const response = await fetch("/api/Upload", param);
            if (response.ok) {
                const message = await response.text();
                resultDiv.innerText = message;
            } else {
                resultDiv.innerText = "アップロード失敗";
            }
        }

        // toBlob メソッドは非同期で動き、結果の Blob データは引数
        // のコールバックに渡されるので、結果は以下のように Promise
        // にラップして返す
        const getBlobFromCanvas = () => {
            return new Promise((resolve) => {
                myCanvas.toBlob((blob) => resolve(blob), "image/jpeg");
            });
        };

        //]]>
    </script>
}

上のコードで送信された画像データを受け取るサーバー側のコードは、「その1」の Data Url 形式のデータを JSON 文字列で受け取る場合は先の記事「canvas の画像をアップロード (その 2)」の下の方に例がありますので見てください。

「その2」のコードでは以下の Fiddler でのキャプチャ画像のようにバイナリデータが multipart/form-data 形式で送信されます。

Blob を maultipart/form-data 形式で送信

サーバー側のコードはそれを受け取って処理できるように書く必要があります。ASP.NET Core Web API でのコード例を下に載せておきます。

using Microsoft.AspNetCore.Mvc;

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

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

        // .NET 6.0 で作ったプロジェクトはnull許容参照型がデフォルトで有効に
        // 設定してあるので、引数を NULL 許容にしておかないと、クライアントでファ
        // イルを選択しないで送信した場合、HTTP 400 Bad Request エラーになって
        //  "The postedFile field is required."というエラーメッセージが返ってくる
        [HttpPost]
        public async Task<string> Post([FromForm] IFormFile? postedFile)
        {
            string result = "";
            if (postedFile != null && postedFile.Length > 0)
            {
                // アップロードされたファイル名を取得
                string filename = System.IO.Path.GetFileName(postedFile.FileName);

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

                string filePath = $"{contentRootPath}\\UploadedFiles\\" +
                    $"{filename}{DateTime.Now.ToString("yyyyMMddHHmmss")}.jpg";

                // フォルダ UploadedFile に画像ファイルを保存
                using (var stream = new FileStream(filePath, FileMode.Create))
                {
                    await postedFile.CopyToAsync(stream);
                }

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

            return result;
        }
    }
}

JavaScript はブラウザ依存なところがありますが、Edge 109.0.1518.70, Chrome 109.0.5414.120, Firefox 109.0, Opera 94.0.4606.76 で期待通り動くことは確認しました。

Tags: , , , ,

Upload Download

About this blog

2010年5月にこのブログを立ち上げました。主に ASP.NET Web アプリ関係の記事です。

Calendar

<<  2024年5月  >>
2829301234
567891011
12131415161718
19202122232425
2627282930311
2345678

View posts in large calendar