WebSurfer's Home

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

クロスドメインの WCF サービス

by WebSurfer 2016年12月27日 21:58

先の記事「WCF と jQuery AJAX」で書いた ASP.NET がホストする WCF サービスを、クロスドメインで利用する方法について書きます。

プリフライトリクエスト

MSDN Blog の記事「JavaScript のクロス ドメイン (Cross-Domain) 問題の回避と諸注意」では以下の 3 つの方法が紹介されています。それぞれの概要が説明されていて分かりやすいので一読されるといいと思います。

  1. Cross Document Messaging (XDM)
  2. Cross-Origin Resource Sharing (CORS)
  3. JSONP

ここでは 2 番目の Cross-Origin Resource Sharing (CORS) という方法を使います。上の画像は CORS でのプリフライトリクエストとそれに対する応答を Fiddler で見たものです。

(最初は jQuery.ajax を利用した JSONP が一番簡単かと思ったのですが、jQuery.ajax では応答のスクリプトにコールバック jQuery1830...26( ... ) で囲んだ JSON 文字列を期待しているのに、WCF サービスは JSON 文字列しか返さないので使えませんでした)

CORS は HTML 5 の仕様なので依然として使えないブラウザがありますが、対応ブラウザが増えるにつれ今後の本命になるだろうということです。(ちなみに、IE9 は対応してませんでした。上に紹介した MSDN Blog の記事によると独自の XDomainRequest オブジェクトを使用するという回避方法はあるそうですが)

まず、この記事を書くに当たって自分が参考にさせていただいた記事へのリンクを張っておきます。私の説明では分からない場合はそちらを読まれるといいと思います。手抜きでスミマセン。(汗)

  1. HTTP アクセス制御 (CORS)
  2. CORS(Cross-Origin Resource Sharing)について整理してみた

ブラウザが CORS をサポートしていれば、クライアント側の処理はブラウザが自動的に行いますので、開発者はクライアント側の対応は何もする必要がないです。

サーバー側すなわち ASP.NET Web アプリで CORS に必要な応答ヘッダ返せるようにするだけで対応できます。基本的には Global.asax ファイルに以下のコードを記述してやれば可能になります。(厳格なセキュリティを要求せず無条件で応答するというような場合はもっと簡単になります)

protected void Application_BeginRequest(object sender, 
                                                 EventArgs e)
{
  CrossOriginResourceSharingSetting();
}

private void CrossOriginResourceSharingSetting()
{
  HttpRequest request = HttpContext.Current.Request;
  HttpResponse response = HttpContext.Current.Response;

  // プリフライトリクエストの場合
  if (request.HttpMethod == "OPTIONS")
  {
    // 要求ヘッダの origin フィールドを取得
    string origin = request.Headers["origin"];

    // ここで origin に指定されたドメインのチェックを行った
    // 方がいいかもしれないが省略。要求ヘッダの origin フィ
    // ールドで送信されてきたものをそのまま返す

    // 要求ヘッダに origin フィールドがなければ何もしない
    if (!string.IsNullOrEmpty(origin))
    {
      string method = 
       request.Headers["Access-Control-Request-Method"];
      string header = 
        request.Headers["Access-Control-Request-Headers"];

      // 要求ヘッダに Access-Control-Request-Method および
      // Access-Control-Request-Headers がなければ何もしない
      if (!string.IsNullOrEmpty(method) && 
          !string.IsNullOrEmpty(header))
      {
        // method が GET または POST 以外は対応しない
        // header もチェックした方がいいかもしれない・・・
        if (method.Contains("GET") || method.Contains("POST"))
        {                            
          response.
            AddHeader("Access-Control-Allow-Origin", origin);                            
          response.
            AddHeader("Access-Control-Allow-Methods", method);                            
          response.
            AddHeader("Access-Control-Allow-Headers", header);

          // Access-Control-Max-Age ヘッダも任意で追加できる。
          // 指定した時間(秒)プリフライトの結果をキャッシュで
          // きるので、実際に運用に移す際は追加する方がよさそう
          //response.AddHeader("Access-Control-Max-Age", "600");

          response.End();
        }
      }
    }                
  }
  else
  {
    // 要求ヘッダの origin フィールドを取得
    string origin = request.Headers["origin"];

    // ここで origin に指定されたドメインのチェックを行った方
    // がいいかもしれないが省略。要求ヘッダの origin フィール
    // ドで送信されてきたものをそのまま返す

    // 要求ヘッダに origin フィールドがなければ何もしない
    if (!string.IsNullOrEmpty(origin))
    {                    
      response.AddHeader("Access-Control-Allow-Origin", origin);
    }
  }            
}

上記のコードが何をしているかと言うと、要求が「プリフライトリクエスト」になる場合と「シンプルなリクエスト」になる場合とに処置を分けて、CORS に必要な応答ヘッダを設定しているだけです。詳しくはコード中のコメントに書きましたのでそれを見てください。

CORS をサポートしているブラウザがクロスドメインでどのような処置を行うかは、要求が「シンプルなリクエスト」になるか否かによって異なってきます。要求が「シンプルなリクエスト」になる条件は、上に紹介した記事「HTTP アクセス制御 (CORS)」を読んでください。

例えば、先の記事「WCF と jQuery AJAX」で言うと「(3) WCF AJAX サービスへのアクセス」のコードで getAllCars メソッドを使う方が「シンプルなリクエスト」になります。

getCarsByDoors メソッドを使う方は要求ヘッダに contentType: "application/json; charset=utf-8" が含まれるので「シンプルなリクエスト」にはならず、「プリフライトリクエスト」が事前に行われます。

シンプルなリクエスト

要求が「シンプルなリクエスト」になる場合、CORS をサポートしているブラウザからは origin: http://<要求元のドメイン> を追加する以外には普通にサーバーに要求を出します。(ただし、IE9 では要求さえ出ませんので注意)

サーバー側では origin フィールドを見て要求側のドメインがクロスドメインアクセスを許可する対象であるかを判定し、受け入れの判断をすることができます。

ただし、上の Global.asax のコードでは要求側のドメインのチェックは行っていません。要求ヘッダの origin フィールドのドメインをそのまま応答ヘッダの Access-Control-Allow-Origin に設定して返しています。それだけでも、* を設定するよりはよさそうです。

ブラウザは応答ヘッダの Access-Control-Allow-Origin: http://<求元のドメイン> を見てクロスドメインアクセスに成功したと判断し、応答に含まれる JSON 文字列などのコンテンツを処置します。

プリフライトリクエスト

要求が「シンプルなリクエスト」にならない場合、CORS をサポートしているブラウザからは実際のリクエストを送信しても安全かを確かめるための「プリフライトリクエスト」が事前に行われます。

上の画像はその要求と応答を Fiddler で見たものです。

左下のウィンドウはブラウザがクロスドメインに対する要求でかつ「シンプルなリクエスト」にならないと判定して自動的にプリフライトリクエストを要求した要求ヘッダの内容です。

赤枠で囲った部分を見てください。HTTP メソッドの OPTIONS、要求ヘッダに含まれる origin, Access-Control-Request-Method, Access-Control-Request-Headers に注目です。

サーバーがクロスドメインからのリクエストを受け、それがプリフライトリクエストであるかどうかを判断するにはそれらの情報をチェックします。

そして、プリフライトリクエストであると判断した場合は上の画像の右下のウィンドウの赤枠で囲ったような応答ヘッダを返します。ここでは、Access-Control-Allow-Origin, Access-Control-Allow-Methods, Access-Control-Allow-Headers は要求ヘッダで受けたものと同じ内容に設定しています。

ブラウザはそれらの情報を見て実際のリクエストを送信しても安全かを確認し、サーバーに要求を出して(上の画像で #3 がそれ)応答を取得し、コンテンツに含まれる JSON 文字列などの情報を処置します。

Tags: , , ,

AJAX

WCF と executionTimeout

by WebSurfer 2016年6月18日 13:23

ASP.NET でホストされていても、WCF サービスを要求した場合は httpRuntime 要素の executionTimeout 属性の設定は効かない(タイムアウトしない)という話を書きます。

元は MSDN Forum の「webconfig 内の executionTimeout 値が有効にならない」という表題のスレッドでの話です。

MSDN Forum での話では、非同期ではないのかとか ASP.NET 互換モードの設定がされてないのではとかいろいろ紆余曲折ありましたが、結局一番の原因は:

ASP.NET でホストされていても、.NET 3.0 SP1 以降 WCF サービスを要求した場合は executionTimeout が Int32.MaxValue に設定される

・・・ということだと分かりました。

ググって見つけた記事「All WCF timeouts explained」にそれが書いてありました。記事の一番下に "Nowadays (.NET 3.0 SP1 and later) there is no more to say. The ExecutionTimeout is set to int.MaxValue for WCF requests. Thereby WCF is granted full control over its own request lifetimes." という記述があります。

上記の記事を裏付ける Microsoft の公式文書は見つかっていませんが、実際に検証してタイムアウトしないことやHttpServerUtility.ScriptTimeout プロパティ設定値を調べた結果から、間違いないようです。

MSDN Blog の記事(例えば「Timeouts in WCF and their default values」)などによると、WCF サービスでも ASP.NET でホストされていれば executionTimeout 属性の設定は有効というようなことが書いてあって惑わされましたが、それは .NET 3.0 SP1 適用前の話だったようです。

主な話は以上ですが、これを調べる過程でタイムアウトや WCF に関することがいろいろわかったので、忘れないように以下にまとめて書いておきます。

  1. WCF サービスでは executionTimeout 属性の設定は効かない
    WCF サービスを要求した場合は executionTimeout が Int32.MaxValue に設定されます。ScriptTimeout プロパティの値で確認すると、compilation debug="false" / "true" の設定に関わらず Int32.MaxValue 即ち 2147483647 になることが確認できます。
  2. タイムアウトのチェックは 15 秒毎
    MSDN Blog の記事「How the Execution Timeout is managed in ASP.NET」によると、タイムアウトは System.Threading.Timer によって 15 秒毎に発生するイベントでチェックしているとのことです。従って、普通の .aspx ページなど executionTimeout が有効な場合でも、設定された時間で正確にタイムアウトすることはないです。
  3. 非同期ではタイムアウトさせられない
    ASP.NET の非同期というのは MSDN の記事「ASP.NET の非同期/待機の概要」に書いてあることですが、その記事の図3にあるように、「外部リソースを非同期に待機中」はスレッドプールにスレッドが戻されます。一方、上記 2 で紹介した MSDN Blog の記事に書かれていますように、タイムアウトはスレッドを Abort することで行われるそうです。ということは、非同期の場合はタイムアウトさせるスレッドが存在しない(=タイムアウトさせられない)ということになります。.NET 4.5 では async / await が使えるようになって、Visual Studio でコードを生成する際に自動的に非同期になるようですが、その場合は executionTimeout が有効にならないと思います。(未検証です)
  4. WCF サービスには非同期 HTTP ハンドラを利用できる
    MSDN Blog の記事「Orcas SP1 Improvement: Asynchronous WCF HTTP Module/Handler for IIS7 for Better Server Scalability」によると、.NET 3.5 SP1 から非同期版ハンドラが利用できるようになったそうです。自分の開発マシン (Vista SP2 32-bit, IIS7) を調べてみると、記事に書いてある通りの同期版・非同期版両方のハンドラが存在してました。記事によるとデフォルトでは同期版を使うそうです。しかし、自分の環境でHttpContext.Handler プロパティを使って調べた限りでは非同期版が使われていました。MSDN Blog の記事と矛盾する理由は不明です。
  5. debag="true" の場合の executionTimeout
    MSDN ライブラリの executionTimeout 属性の説明には "このタイムアウトは、compilation 要素のデバッグ属性が False の場合だけ適用されます" と書いてありますが、普通の .aspx ページを呼び出したとき compilation debug="true" では、ScriptTimeout プロパティの値で確認すると 30000000 になります。(WCF サービスを呼び出したときは、debug="false" / "true" に関わらず、ScriptTimeout プロパティの値は Int32.MaxValue 即ち 2147483647 になります)

Tags: ,

ASP.NET

WCF と jQuery AJAX

by WebSurfer 2015年10月15日 21:01

Web サービスは "Legacy Technology" なので Windows Communication Foundation (WCF) を使うようにと言われています。なので、今さらながらですが、先の記事 jQuery AJAX と Web サービスで作ったサンプルと同様なものを WCF で実装してみました。

AJAX 対応 WCF サービス

作成に当たって参考にしたのは以下の 2 つの記事です。しかし、読んだだけではほとんど意味が分かりません。(汗)

自分の手を動かして作ってみれば少しは理解が深まるであろうということで、自分の環境(Vista SP2 32-bit の IIS7、ASP.NET 4, Visual Studio 2010 Professional、ASP.NET Web Forms アプリ)で実際にやってみました。やってみていろいろ分かったことがありましたので備忘録として書いておきます。

まず、開発マシンの IIS7 上で動く既存の ASP.NET Web Forms アプリに、上の画像のように Visual Studio の[新しい項目の追加(W)...]メニューからテンプレートを使って[AJAX 対応 WCF サービス]を CarService.svc という名前で追加します。([WCF サービス]の方は従来の SOAP を使う WCF サービス)

CarService.svc を追加すると以下のコードが自動生成されます。今回の記事では Web アプリケーションプロジェクトを使っています。Web サイトプロジェクトの場合は、コードビハンド CarService.svc.cs が CarService.cs という名前になって App_Code フォルダに置かれる、アセンブリ名 / 名前空間名が付与されないという違いがありますので注意してください。

CarService.svc(xxx は名前空間名)

<%@ ServiceHost
  Language="C#"
  Debug="true"
  Service="xxx.CarService"
  CodeBehind="CarService.svc.cs"
%>

CarService.svc.cs(xxx は名前空間名)

using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.Serialization;
using System.ServiceModel;
using System.ServiceModel.Activation;
using System.ServiceModel.Web;
using System.Text;

namespace xxx
{
  [ServiceContract(Namespace = "")]
  [AspNetCompatibilityRequirements(RequirementsMode = 
                AspNetCompatibilityRequirementsMode.Allowed)]
  public class CarService
  {
    // HTTP GET を使用するために [WebGet] 属性を追加します 
    // (既定の ResponseFormat は WebMessageFormat.Json)。
    // XML を返す操作を作成するには、
    // [WebGet(ResponseFormat=WebMessageFormat.Xml)] を追加し、
    // 操作本文に次の行を含めます。
    // WebOperationContext.Current.OutgoingResponse.ContentType = 
    //     "text/xml";
    [OperationContract]
    public void DoWork()
    {
      // 操作の実装をここに追加してください
      return;
    }

    // 追加の操作をここに追加して、[OperationContract] とマーク
    // してください
  }
}

web.config への追加(xxx は名前空間名)

<system.serviceModel>
  <behaviors>
    <endpointBehaviors>
      <behavior name="xxx.CarServiceAspNetAjaxBehavior">
        <enableWebScript />
      </behavior>
    </endpointBehaviors>
  </behaviors>
  <serviceHostingEnvironment aspNetCompatibilityEnabled="true"
    multipleSiteBindingsEnabled="true" />
  <services>
    <service name="xxx.CarService">
      <endpoint address=""
        behaviorConfiguration="xxx.CarServiceAspNetAjaxBehavior"
        binding="webHttpBinding"
        contract="xxx.CarService" />
    </service>
  </services>
</system.serviceModel>

上記は、web.config に <enableWebScript /> とあることから分かるように、ASP.NET AJAX の ScriptManager を使用する Web ページからサービスを使用するための WCF AJAX サービスのコードです。

これらのコードを、上の 2 つの記事を参考に、JavaScript / jQuery を使用する Web ページからアクセスできるように書き換えてみました。

上に紹介した ASP.NET を使用せずに WCF AJAX サービスを作成する方法 に書いてあるように、以下の 3 つの手順に分けて説明します。

(1) ブラウザーからアクセスできる AJAX エンドポイントの作成
(2) AJAX 互換サービス コントラクトの作成
(3) WCF AJAX サービスへのアクセス


(1) ブラウザーからアクセスできる AJAX エンドポイントの作成

AJAX エンドポイントを作成するためには、

  1. .svc ファイルの @ServiceHost の Factory 属性に WebServiceHostFactory を設定する、または
  2. web.config の <system.serviceModel> 要素で WebHttpBinding と WebHttpBehavior を使用してエンドポイントを構成する

のいずれかの方法を取るのだそうです。具体例は上の記事(前者)のサンプルコードを参照してください。

1 の方が簡単そうに思えますが、実は 2 の方は web.config に自動生成されて追加されたコード(上のコードを見てください)の中の <enableWebScript /> を <webHttp /> に書き換えるだけで済みますので、手間的には大差はないです。

今回は 2 の方法(.svc ファイルには手を加えないで、web.config に自動生成されたコードの中の <enableWebScript /> を <webHttp /> に書き換え)を取りました。

エンドポイントアドレス名を指定してサービスを呼び出したい場合は、自動生成された address="" を修正してください。例えば、address="ajaxEndpoint" とすると、carservice.svc/ajaxEndpoint/GetAllCars でアクセスできます。(ちなみに、そのように address を設定すると、carservice.svc/GetAllCars では HTTP 404 Not Found になります)

1 の方法を取る場合、web.config に自動生成されたコードは削除した方がよさそうです。<enableWebScript /> を <webHttp /> に書き換えれば WebScriptEnablingBehavior では BodyStyle の Wrapped がサポートされてない等の問題は回避できますが、既存のサービスなどと競合してエラーになることがありましたので。


(2) AJAX 互換サービス コントラクトの作成

先の記事 ASP.NET AJAX と Web サービスの Web サービス(.asmx ファイル)のコードを CarService.svc.cs に移植します。

GetAllCars は GET 要求で呼び出せるようにしました。(JSON サービスを GET で要求するのは潜在的なセキュリティ上のリスクがあるそうです。詳しくはこのセクションの下の方で紹介した記事を見てください)

GetCarsByDoors は先の例と同様に POST 要求で呼び出すようにしました。サンプルコードは以下のようになります。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.Serialization;
using System.ServiceModel;
using System.ServiceModel.Activation;
using System.ServiceModel.Web;
using System.Text;

namespace WebApplication1
{
    // web.config で aspNetCompatibilityEnabled="true" と設定され
    // ているので [AspNetCompatibilityRequirements(...)] が必要
    [ServiceContract(Namespace = "")]
    [AspNetCompatibilityRequirements(RequirementsMode = 
        AspNetCompatibilityRequirementsMode.Allowed)]
    public class CarService
    {
        List<Car> cars = new List<Car> {
            new Car {Make="Audi",Model="A4",Year=1995,
                Doors=5,Colour="Red",Price=2995f},
            new Car {Make="Ford",Model="Focus",Year=2002,
                Doors=5,Colour="Black",Price=3250f},
            new Car {Make="BMW",Model="5 Series",Year=2006,
                Doors=4,Colour="Grey",Price=24950f},
            new Car {Make="Renault",Model="Laguna",Year=2000,
                Doors=5,Colour="Red",Price=3995f},
            new Car {Make="Toyota",Model="Previa",Year=1998,
                Doors=5,Colour="Green",Price=2695f},
            new Car {Make="Mini",Model="Cooper",Year=2005,
                Doors=2,Colour="Grey",Price=9850f},
            new Car {Make="Mazda",Model="MX 5",Year=2003,
                Doors=2,Colour="Silver",Price=6995f},
            new Car {Make="Ford",Model="Fiesta",Year=2004,
                Doors=3,Colour="Red",Price=3759f},
            new Car {Make="Honda",Model="Accord",Year=1997,
                Doors=4,Colour="Silver",Price=1995f}
        };

        [OperationContract]
        [WebGet(ResponseFormat = WebMessageFormat.Json, 
            BodyStyle = WebMessageBodyStyle.Wrapped)]
        public List<Car> GetAllCars()
        {
            return cars;
        }

        [OperationContract]
        [WebInvoke(ResponseFormat = WebMessageFormat.Json, 
            BodyStyle = WebMessageBodyStyle.Wrapped)]
        public List<Car> GetCarsByDoors(int doors)
        {
            var query = from c in cars
                        where c.Doors == doors
                        select c;

            return query.ToList();
        }
    }

    [DataContract]
    public class Car
    {
        [DataMember]
        public string Make { get; set; }

        [DataMember]
        public string Model { get; set; }

        [DataMember]
        public int Year { get; set; }

        [DataMember]
        public int Doors { get; set; }

        [DataMember]
        public string Colour { get; set; }

        [DataMember]
        public float Price { get; set; }
    }
}

ポイントは WebGetAttribute 属性と WebInvokeAttribute 属性の設定で、具体的には以下の通りです。

  • ResponseFormat = WebMessageFormat.Json を設定して、応答に XML ではなく JSON を明示的に指定します。
  • 上に紹介した記事(後者)には "WebGetAttribute または WebInvokeAttribute 属性に Wrapped の本文スタイルが必要です" とありますが、その通りにすると応答もラップされて {"GetAllCarsResult":[{"Colour":"Red", ... ,"Year":1997}]} というような形になります。
  • BodyStyle = WebMessageBodyStyle.Wrapped を削除すると "GetAllCarsResult" というラップはなくなりますが、今度は POST 要求で HTTP 400 Bad Request となってしまいます。なので、少なくとも POST 要求する方は BodyStyle = WebMessageBodyStyle.WrappedRequest とする必要があります。

今回は記事に従って BodyStyle = WebMessageBodyStyle.Wrapped とし、ラップを外す処置をクライアントスクリプトにコーディングしました。具体的には、下の「(3) WCF AJAX サービスへのアクセス」のセクションを見てください。

ラップすると、Web サービスの JSON 応答を "d" でラップするのと同様に、セキュリティ上のリスクの軽減に効果があるはずです。

Web サービスの JSON 応答は .NET 3.5 から "d" でラップされるようになりましたが、それは JSON サービスの脆弱性に起因する XSS 対策だそうです。そのメカニズムは以下の記事に詳しく書いてあります。

WCF では "<メソッド名>Result" でラップされますが、Web サービスで "d" でラップするのと同様に JSON 配列を返さないという効果があります。(JSON 配列は JavaScript として有効というところが問題だそうです)

あと、上に紹介した記事に書いてありますが、GET 要求を使うことにもリスクがあるそうです。

いろいろ条件が重ならないとリスクにはならないですが、「君子危うきに近寄らず」ということで、(1) ラップする、(2) POST 要求を使う、という 2 つのことは実施した方がよさそうです。(ちなみに、ASP.NET AJAX を利用する場合はデフォルトでそうなります)


(3) WCF AJAX サービスへのアクセス

これは先の記事 jQuery AJAX と Web サービス とほぼ同様な jQuery.ajax のコードを利用して可能です。

上記 (2) の手順で、GetAllCars は GET 要求で、GetCarsByDoors は POST 要求で取得するようにしたので、それぞれ以下のようなコードで要求をかけ、JSON 文字列の応答を得てそれを処置することができます。

function getAllCars() {
    $.ajax({
        type: "GET",
        url: "carservice.svc/GetAllCars",
                
        success: function (cars) {
            if (cars.hasOwnProperty('GetAllCarsResult')) {
                cars = cars.GetAllCarsResult;

                // ・・・cars の処置(省略)・・・
            }
        },
        error: function (jqXHR, textStatus, errorThrown) {
            // ・・・省略・・・
        }
    });
}
        
function getCarsByDoors() {
    $.ajax({
        type: "POST",
        url: "carservice.svc/GetCarsByDoors",
        data: '{"doors":' + $("#ddlDoors").val() + '}',
        contentType: "application/json; charset=utf-8",

        success: function (cars) {
            if (cars.hasOwnProperty('GetCarsByDoorsResult')) {
                cars = cars.GetCarsByDoorsResult;

                // ・・・cars の処置(省略)・・・
            }
        },
        error: function (jqXHR, textStatus, errorThrown) {
            // ・・・省略・・・
        }
    });                
}

POST の方には contentType: "application/json; charset=utf-8" の設定は必須です。それがないと HTTP 400 Bad Request となって失敗します。

success オプションに設定したコールバックの引数 cars にはパース済みの JavaScript オブジェクトが渡されます。上の「(2) AJAX 互換サービス コントラクトの作成」のセクションで述べたように "<メソッド名>Result" でラップされていますので、ラップを外す処置が必要です。具体的には上のコードを見てください。(命名規則が不明なので、ラップを外す処置をハードコーディングするのはちょっと不安なのですが)

Tags: , ,

AJAX

About this blog

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

Calendar

<<  2017年1月  >>
25262728293031
1234567
891011121314
15161718192021
22232425262728
2930311234

View posts in large calendar