WebSurfer's Home

Filter by APML

Blazor Web App で HttpClient を使ってファイルアップロード

by WebSurfer 2. January 2025 14:42

Blazor Web App から HttpCLient を使ってファイルアップロードする方法を書きます。ベースとしたのは Visual Studio 2022 のテンプレートを使って、ターゲットフレームワークを .NET 9.0 に、 [Interactive render mode] を [WebAssembly] に、 [Interactive location] を [Per page/component] に設定して作成したプロジェクトです。設定が異なる場合は下の説明の中には当てはまらない事がありますので注意してください。

Blazor Web App で HttpClient を使ってファイルアップロード

JavaScript の fetch を使ってアップロードする例は先の記事「Blazor Web App でファイルアップロード」に書きました。それに比べて、HttpClient を使う場合は、Program.cs での HttpClient の登録が必要なこと、本来ブラウザでは使えない HttpClient を使うことの違和感などが気になりました。それでも Blazor では JavaScript より HttpClient を使うのが本筋のようです。

この記事の例では、ファイルのアップロード先は外部 Web API または本 Blazor アプリのプロジェクト内に実装した Web API としました。その Web API のアクションメソッドに Blazor Web App の Razor コンポーネントから HttpClient を使ってファイルを送信します。(実際にはブラウザから送信されますので、HttpClient から生成された WebAssembly が送信しているのだろうと思いますが、具体的にどうなっているのかは分かりません)

Visual Studio でプロジェクトを作成すると、下のソリューションエクスプローラーの画像の構成となります。青枠と赤枠の中のフォルダ / ファイルはこの記事を書く際に追加したものです。青枠がプロジェクト内に作成した Web API とファイル保存用のフォルダ、赤枠がファイルをアップロードする Razor コンポーネントです。

プロジェクトの構成

この記事の例は、Microsoft のドキュメント「クライアント側のレンダリング (CSR) を使用するサーバーへのファイルのアップロード」に該当します。それを読んですべて分かれば良いのですが、自分は読んでも分からないことがありました。

なので、この記事に、Microsoft のドキュメントを読んでも分からなかったことを調べて書くとともに、実装を基本的な部分のみに簡略化したコードを備忘録として載せることにしました。

(1) InputFile クラスの使用

html の <input type="file"> を使った場合は、ユーザーが選択したファイルのデータを HttpClient の C# のコードに渡すことができません (裏ワザとかがあるかもしれませんが)。

InputFile クラスを使用すると、ユーザーがファイルの選択を完了したときに発生する OnChange イベントのハンドラの引数に InputFileChangeEventArgs オブジェクト が渡され、ユーザーが選択したファイルのデータを HttpClient の C# のコードで取得できます。

それゆえ、アップロードするのに HttpClient を使う場合は、InputFile クラスを使用するほか選択肢はなさそうです。

(2) Razor コンポーネントの配置場所

上のソリューションエクスプローラーの画像に示すように、メインプロジェクトとクライアントプロジェクト (.Client) の 2 つのプロジェクトが作られます。そのどちらにも Razor コンポーネントを配置できるのですが、今回のファイルをアップロードする Razor コンポーネントはどちらに配置すればいいのでしょうか?

答えはクライアントプロジェクト (.Client) 側です。上のソリューションエクスプローラーの画像の赤枠で示した FileUpload.razor がそれです。理由は以下の通りです。

Microsoft のドキュメント「ASP.NET Core Blazor プロジェクトの構造」に "対話型 WebAssembly または対話型自動レンダリング モードを使用するコンポーネントは、.Client プロジェクトに配置する必要があります" と書いてあります。

「対話型」の対話というのはユーザーとの対話を意味します。例えばテンプレートで自動生成されるコンポーネント Counter.razor のようにユーザーのクリックに応じてカウント値が変わっていくものが該当するようです。InputFile クラスの OnChange イベントで処理を行うのも対話に含まれるようです。

実は、最初 FileUpload.razor はメインプロジェクトに配置していたのですが、OnChange のハンドラが動かず、その解決に半日ほどハマったのは内緒です。(笑)

(3) Program.cs で HttpClient サービスの追加

HttpClient サービスの追加をメインプロジェクとクライアントプロジェクト (.Client) 両方の Program.cs で行う必要があります。

上に紹介した Microsoft のドキュメントに書いてある通り、メインプロジェクトの Program.cs に、

// IHttpClientFactory および関連するサービスを追加
builder.Services.AddHttpClient();

クライアントプロジェクト (.Client) の Program.cs に、

// Web API に対する POST 要求のための HttpClient の登録
builder.Services.AddScoped(sp =>
    new HttpClient 
    { 
        BaseAddress = new Uri(builder.HostEnvironment.BaseAddress)
    });

・・・を追加します。(注: 後者のコードは「Blazor WebAssembly スタンドアロン アプリ」のテンプレートを使って作成した Blazor WebAssembly プロジェクトの Prpgram.cs には最初から含まれています)

でも、考えてみると HttpClient を使うのはクライアントプロジェクト (.Client) の FileUpload.razor なのに、なぜメインプロジェクトの Program.cs でも追加しなければならないのかが腑に落ちません。

Microsoft のドキュメントには "クライアント側コンポーネントはサーバーでプリレンダリングされるため、HttpClient サービスをメインプロジェクトに追加する必要があります。対話型コンポーネントではプリレンダリングが既定で有効になっています" と書いてありました。そう言われても、その仕組みは全く理解できていませんが。(汗)

上のクライアントプロジェクト (.Client) の Program.cs での設定例では、HttpClient の BaseAddres プロパティを設定していますがその理由を書きます。

builder.HostEnvironment.BaseAddress というのは Blazor アプリのベースアドレスで、この記事の例では https://localhost:44340/ となります。

Microsoft のドキュメント「HttpClient.BaseAddress プロパティ」に "相対 URI を使用して HttpRequestMessage を送信すると、メッセージ Uri が BaseAddress プロパティに追加され、絶対 URI が作成されます" と書かれています。

なので、Blazor アプリ内の Web API (上のソリューションエクスプローラーの画像の青枠がそれ) にファイルを送信する場合は、上のコードのように BaseAddress プロパティを設定しておくと、FileUpload.razor の HttpClient のコードで URI を指定する際、相対 URI を使うことができるようになります。

外部 Web API にファイルを送信する場合は、FileUpload.razor の HttpClient のコードで絶対 URI を指定しますが、その場合はそれが HttpClient の BaseAddress に追加されるということはなく、コードで指定した絶対 URI がそのまま使用されます (ドキュメントにはそのことは書いてないですが検証して確認)。

HttpClient のコードで常に絶対 URI を指定するのであれば上のような HttpClient の BaseAddress の設定は不要で、以下のようにすれば良いです。

// FileUpload.razor の Http.PostAsync(url, content) の url に
// http から始まる絶対 URI を指定するのであれば以下で良い
builder.Services.AddScoped(sp => new HttpClient());

(4) FileUpload.razor

ユーザーがファイルを選択してアップロードする razor コンポーネント (FileUpload.razor) のサンプルコードは以下の通りです。Blazor Web App はデフォルトでは static rendering になるそうで、@rendermode InteractiveWebAssembly を設定しないと対話型にならないので注意してください。

@page "/fileupload"
@rendermode InteractiveWebAssembly
@using System.Net.Http.Headers
@inject HttpClient Http

<h3>Upload File</h3>

<InputFile OnChange="OnInputFileChange" multiple />
<br />
<p>@result</p>

@code {
    // 外部 Web API の URI
    // private string url = "https://localhost:44366/FileUpDownload/multiple";

    // Blazor アプリのプロジェクト内に作成した Web API の URI
    // Program.cs での HttpClient の登録で BaseAddress に本 Blazor 
    // アプリのベースアドレスを設定してあるので相対 URI を使用可
    private string url = "/FileSave";

    private string result = string.Empty;

    private async Task OnInputFileChange(InputFileChangeEventArgs e)
    {
        using var content = new MultipartFormDataContent();

        // GetMultipleFiles メソッドの引数 maximumFileCount は
        // デフォルトで 10。超えると例外がスローされる
        foreach (var file in e.GetMultipleFiles())
        {
            // OpenReadStream メソッドの引数 maxAllowedSize は
            // デフォルトで 500KB。超えると例外がスローされる
            var streamContent = new StreamContent(file.OpenReadStream());
            streamContent.Headers.ContentDisposition =
                new ContentDispositionHeaderValue("form-data")
                    {
                        // Name は Web API の引数名に合わせる
                        Name = "postedfiles",  
                        FileName = Path.GetFileName(file.Name)
                    };
            streamContent.Headers.ContentType =
                new MediaTypeHeaderValue(file.ContentType);

            content.Add(streamContent);
        }

        HttpResponseMessage response = await Http.PostAsync(url, content);

        // Web API からの応答のコンテンツの文字列を取得
        result = await response.Content.ReadAsStringAsync();
    }
}

上のコードでは、ユーザーがファイルの選択を完了すると、ファイルは即 Web API に送信されます。

それを、ユーザーがファイルを選択した後でボタンクリックによりアップロードしたい場合は、OnInputFileChange メソッドでは e.GetMultipleFiles() メソッドで IReadOnlyList<IBrowserFile> オブジェクトを取得してそれを保持するに留め、ボタンクリックのハンドラで保持された IReadOnlyList<IBrowserFile> オブジェクトからファイルデータを取得して送信するのが良さそうです。

(5) Web API

FileUpload.razor から送信されたファイルを受け取って UploadedFiles フォルダに保存する Web API のサンプルコードを下に載せておきます。

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

using Microsoft.AspNetCore.Mvc;

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

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

        [HttpPost]
        public async Task<IActionResult> MuilipleFiles(List<IFormFile>? postedFiles)
        {
            string result = "Uploaded files: ";
            if (postedFiles != null)
            {
                // アプリケーションルートの物理パスを取得
                // wwwroot の物理パスは WebRootPath プロパティを使う
                string contentRootPath = _hostingEnvironment.ContentRootPath;

                foreach (var postedFile in postedFiles)
                {
                    if (postedFile != null && postedFile.Length > 0)
                    {
                        // アップロードされたファイル名を取得
                        string filename = Path.GetFileName(postedFile.FileName);

                        // アプリケーションルート直下の UploadedFiles フォルダに書き込み
                        string filePath = $"{contentRootPath}\\UploadedFiles\\{filename}";
                        using (var stream = new FileStream(filePath, FileMode.Create))
                        {
                            await postedFile.CopyToAsync(stream);
                        }

                        result += $"{filename} ";
                    }
                }
            }
            else
            {
                result = "postedFiles is null";
            }

            return Content(result);
        }
    }
}

Blazor アプリのプロジェクト内に実装する場合は、上のソリューションエクスプローラーの画像の青枠に示したようにメインプロジェクトに配置します。さらに、メインプロジェクトの Program.cs に下の 2 行を追加します。

// Controller を使用できるようサービスを追加
builder.Services.AddControllers();

// ・・・中略・・・

// 属性でルーティングされたコントローラーをマップ
app.MapControllers();

外部 Web API に上と同様なコードを実装して FileUpload.razor から送信されたファイルを受け取ることもできます。ただし、その場合はクロスドメインになるので、Web API 側のサーバーが CORS 対応している必要があります。

HttpClient を使っていると言っても、結局それは WebAssembly に変換されてブラウザに送信され、ブラウザから Web API に要求が出るので、クロスドメインでの要求は、JavaScript の fetch を使った場合と同様に、CORS 対応がされてないと失敗します。

外部 Web API のサーバーで CORS 対応がされていれば、下の Fiddler でのキャプチャ画像のように #95 でプリフライトリクエストが出て、#96 でファイルが送信されます。

Fiddler でのキャプチャ画像

Tags: , , , ,

Upload Download

Blazor Web App の WebAssembly モード

by WebSurfer 17. November 2024 18:15

Visual Studio 2022 を使って下の画像のように Blazor Web App をテンプレートに選んで作成した Blazor アプリは、予想に反して WebAssembly モードでも Server-Side Rendering (SSR) になることがありました。以下にそのことを書きます。

Blazor Web App を選択

作成時に [Interactive render mode] を [WebAssembly] に設定しても、[Interactive location] に [Per page/component] を選んだ場合(多くの人はこちらを選ぶのではなかろうかと思います)、

[WebAssembly], [Per page/component] に設定

アプリを起動して [Home], [Counter], [Weather] ボタンをクリックして画面を切り替えると、

アプリを起動

下の Fiddler によるキャプチャ画像の通り、

Fiddler によるキャプチャ画像

毎回サーバーに要求を出し、完全な html ソースをサーバーから応答として受け取り、それをブラウザに表示するという SSR になります。WebAssembly だからサーバーとのやり取りはしない、即ち CSR になることを期待していましたがそうはなりません。

Microsoft のドキュメント ASP.NET Core Blazor render modes(日本語版もありますが翻訳がアレなので英語版がお勧め)の Render modes のセクションに説明があります。

そこにはコンポーネントで @rendermode を InteractiveWebAssembly に設定すると Client-side rendering (CSR) using Blazor WebAssembly となって Render location が Client になると書いてあります。

なので、試しに Home, Counter, Weather 全てのコンポーネントで @rendermode InteractiveWebAssembly を設定してみました。しかし、相変わらずサーバとのやり取りを行う SSR になります。

プロジェクトを作成する際に [Interactive location] を [Global] に設定すればサーバーとのやり取りはなくなり CSR になります。コンポーネントに @rendermode InteractiveWebAssembly を設定する必要はないようで、自動生成される各コンポーネントには @rendermode の設定は含まれていません。

知ってました? 自分は知らなかったです。 [Interactive render mode] を [WebAssembly] に設定するだけで無条件で CSR になると思ってました。WebAssembly のメリットは CSR のはずなので、勝手に知らないうちに SSR になっては困ると思うのですが・・・

ちなみに、テンプレートに Blazor WebAssembly を使って作成した Blazor アプリの場合は [Home], [Counter], [Weather] ボタンをクリックして画面を切り替えてもサーバーとの通信は発生せず CSR になります。


そのあたりのことを書いた Microsoft のドキュメントは見つからなかったので Copilot に聞いてみました。回答を載せておきます。(ホントかどうかの裏は取ってませんのでご参考まで)

(1) Blazor Web App と Blazor WebAssembly の違いに関して

The difference you're seeing between the two cases is indeed a bit subtle but has to do with how the Blazor Web App and Blazor WebAssembly templates are configured and what each is optimized for.

Blazor Web App

Interactive Render Mode: When you set the interactive render mode to WebAssembly but also have it set to Per page/component, Blazor is likely configured to request the server to render the pages/components interactively. This is why you're seeing GET requests being made to the server.

Behavior: Even though WebAssembly is being used, each time you navigate, a new GET request is made to the server. This means the server is still involved in delivering the HTML content, and the WebAssembly part is handling the interactive functionalities of the individual components on the page. This can be useful if you want the server to control the navigation and then WebAssembly to handle the client-side interactivity.

Blazor WebAssembly

Pure Client-Side Rendering: This template is optimized for a purely client-side experience. Once the initial application is loaded, all subsequent navigation and component rendering are handled entirely by the client without additional requests to the server.

Behavior: This makes it behave like a traditional Single Page Application (SPA) where the entire application, including navigation between pages, is handled within the client browser using WebAssembly. This is why you're not seeing additional requests to the server when navigating between Home, Counter, and Weather.

To summarize, the main difference is in how much the server is involved in rendering pages. In the Blazor Web App with interactive mode set to WebAssembly and per page/component, the server still plays a role in serving up pages, whereas, in Blazor WebAssembly, once the application is loaded, the server is not involved in further page rendering. This setup can affect performance and user experience depending on your application's needs.


(2) [Interactive location] を [Global] に設定すると何が変わるか

You're right, changing the Interactive location to Global in a Blazor Web App with the WebAssembly interactive render mode would make it behave similarly to the pure Blazor WebAssembly app, where no additional server requests are made during navigation. This setup alters the application's structure in a way that makes the WebAssembly component responsible for rendering more globally, not just per page/component.

Configuration Differences

Project Structure:

Global Interactive Location: This mode places components like Home.razor and Weather.razor under the .Client project, indicating that the client-side WebAssembly is handling the rendering globally for all these components.

Per Page/Component Interactive Location: In this mode, each page or component might still be interacting with the server for rendering purposes, leading to the behavior you observed with additional server requests.

Component Loading:

Global Interactive Location: The client-side WebAssembly loads the components globally, so once the application is loaded, navigation between different pages like Home, Counter, and Weather is handled entirely on the client side.

Per Page/Component Interactive Location: Each navigation may involve a server request because the rendering is done on a per-component basis, making the server more involved in the process.

Potential Reasons for Similar Program.cs

Shared Configuration: The Program.cs file might not differ significantly because the core configuration and service registrations for Blazor WebAssembly applications are quite similar. The main difference lies in how the application components are managed and rendered within the project structure and specific component files.

Conclusion

By setting the interactive location to Global, you've essentially optimized the application to function more like a typical SPA where the client-side WebAssembly handles most of the rendering and navigation logic, reducing the need for server interaction during page changes. This change primarily affects how components are structured and rendered rather than altering the fundamental configuration in Program.cs.

Tags: , , , ,

CORE

Blazor Web App で JavaScript を使ってファイルアップロード

by WebSurfer 5. November 2024 14:45

Visal Studio 2022 の Blazor プロジェクト作成用テンプレートで Blazor Web App を選び、[Interactive render mode] を [Server] に設定して作成した Blazor アプリから、JavaScript を使ってファイルをアップロードする方法を書きます。

テンプレートで Blazor Web App を選択

基本的には先の記事「Blazor WASM でファイルアップロード」とほぼ同じです。違いは、WASM ではなく Web App であること、ファイルを選択する画面とアップロードする画面を分けたこと、ファイルをアップロードするスクリプトは jQuery ajax に替えて fetch を使ったこと、複数のファイルを一度にアップロードできるようにしたことです。

ファイルを選択する画面とアップロードする画面を分ける必要はないのですが、訳あって、画面遷移しても <input type="file" /> から取得できる FileList オブジェクトを画面間で受け渡すことができるかを調べるため分けました。

正確に言うと「受け渡す」のではなくて、Blazor アプリは基本 SPA なので、画面が遷移しても書き変わらない共通部分に情報を保持しておいて、異なる画面で共通に使うということです。

作成したアプリを動かして、Select File 画面でファイルを選択し、

Select File 画面

その後 Upload File 画面に移って[Upload]ボタンをクリックすると、別プロジェクトとして作成した ASP.NET Core Web API にファイルをアップロードするようになっています。

Upload File 画面

下に載せたコードで、Select File 画面でファイルを選択して FileList オブジェクト作成し、それを共通部分にある JavaScript の変数に代入し、Upload File 画面に移ってその変数から FileList オブジェクトを取得して FormData オブジェクトを作成し、ファイルをアップロードすることはできました。

ただし、Select File 画面の <input type="file" /> の DOM は、Upload File 画面ではなくなってしまうので、時間が経つとブラウザのガベージコレクタで FileList オブジェクトもなくなってしまう可能性は否定できません。

検証に使ったブラウザは、Windows 10 64-bit の Chrome 130.0.6723.117、Microsoft Edge 130.0.2849.68、Firefox 132.0.1、Opera 114.0.5282.115 です。


以下にどのようにアプリを作成したかを書きます。

まず、一番上の画像に示したように Visual Studio 2022 のテンプレートを使って Blazor Web App のプロジェクトを作成します。この記事のサンプルでは Interactive render mode は Server に、Interactive location は Per Page/component に設定しました。(Interactive render mode は Auto でも WebAssembly でも OK です)

Razor Component の追加

Select File 画面と File Upload 画面の Razor Component を追加します。コードはそれぞれ以下の通りです。Blazor Web App はデフォルトでは static rendering になるので、下のコード例のように rendermode を指定しないと interactive にならず、スクリプトを Invoke できないことに注意してください。

Select File 画面用

@page "/selectfile"
@rendermode InteractiveServer
@inject IJSRuntime JSRuntime;

<h3>Select File</h3>

<input type="file" name="fileupload" id="fileupload" 
    multiple="multiple" @onchange="Select" />

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

@code {
    private async Task Select()
    {
        await JSRuntime.InvokeVoidAsync("select");
    }
}

Upload File 画面用

@page "/uploadfile"
@rendermode InteractiveServer
@inject IJSRuntime JSRuntime;

<h3>Upload File</h3>

<button type="button" class="btn btn-primary" @onclick="Upload">
    Upload
</button>

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

@code {
    private async Task Upload()
    {
        await JSRuntime.InvokeVoidAsync("upload");
    }
}

ファイル選択とアップロード用 JavaScript

上の Select File 画面と Upload File 画面でファイル選択とアップロードを行う JavaScript ファイルを作成し、wwwroot フォルダに配置します。名前は任意ですがこの記事では exampleJsInterop.js としています。

window.select = () => {
    let fileUpload = document.getElementById("fileupload");
    fileList = fileUpload.files;

    let str = "<ul>";
    for (let i = 0; i < fileList.length; i++) {
        str += `<li>${fileList[i].name}, ${fileList[i].type}, ${fileList[i].size}</li>`
    }
    str += "</ul>";

    let result = document.getElementById("result");
    result.innerHTML = str;
}

window.upload = async () => {   
    if (fileList) {
        let resultDiv = document.getElementById("result");

        let fd = new FormData();
        for (let i = 0; i < fileList.length; i++) {
            fd.append("postedfiles", fileList[i])
        }

        const param = {
            method: "POST",
            body: fd
        }
        const response = await fetch("送信先 Web API の url", param);
        if (response.ok) {
            const message = await response.text();
            resultDiv.innerText = message;
        } else {
            resultDiv.innerText = "アップロード失敗";
        }
    }
}

App.razor にコードを追加

上の Select File 画面の JavaScript で <input type="file" /> から取得した FileList オブジェクトへの参照を保持するスクリプト fileList = null; と、wwwroot に配置した外部スクリプトファイル exampleJsInterop.js をダウンロードできるよう、下の画像の赤枠で囲ったコードを追加します。

App.razor にコードを追加

Blazor は基本 Single Page Application なので、追加した部分は遷移で書き換えられることはなく、すべての画面で共有されます。

ファイル受信用 Web API

上の JavaScript により送信されたファイルファイルを受信するため、別プロジェクトとして CORS 対応した Web API を作成しています。そのアクションメソッドのコードを下に載せておきます。

using Microsoft.AspNetCore.Mvc;

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

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

        [HttpPost("multiple")]
        public async Task<IActionResult> MuilipleFiles(List<IFormFile>? postedFiles)
        {
            string result = "アップロードされたファイル: ";
            if (postedFiles != null)
            {
                // アプリケーションルートの物理パスを取得
                // wwwroot の物理パスは WebRootPath プロパティを使う
                string contentRootPath = _hostingEnvironment.ContentRootPath;

                foreach (var postedFile in postedFiles)
                {
                    if (postedFile != null && postedFile.Length > 0)
                    {
                        // アップロードされたファイル名を取得
                        string filename = Path.GetFileName(postedFile.FileName);

                        // アプリケーションルート直下の UploadedFiles フォルダに書き込み
                        string filePath = $"{contentRootPath}\\UploadedFiles\\{filename}";
                        using (var stream = new FileStream(filePath, FileMode.Create))
                        {
                            await postedFile.CopyToAsync(stream);
                        }

                        result += $"{filename} ";
                    }
                }
            }
            else
            {
                result = "ファイルアップロードに失敗しました";
            }

            return Content(result);
        }
    }
}

Tags: , , ,

Upload Download

About this blog

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

Calendar

<<  January 2025  >>
MoTuWeThFrSaSu
303112345
6789101112
13141516171819
20212223242526
272829303112
3456789

View posts in large calendar