Skip to content

拡張ライブラリで外部公開カレンダーを作る(設計) ​

第1版作成 最終更新 (日本時間)
確認バージョン1.5.8.1

本体の標準機能ではありません

このページは 拡張ライブラリ で機能を足す場合の設計メモです。前提にした現行の実装は 1.5.8.1 です。

サイトのカレンダーを、ログインしていない外部の人にも見せたいことがあります。本体の情報公開はサイト単位で全項目が見えてしまい、項目を絞れません。ここでは、フォーム機能と同じく推測しにくい GUID の URL で、指定したサイト・ビュー・項目だけを見せるカレンダーを、本体を改修せずに拡張ライブラリとして作る設計をまとめます。購読用の .ics 配信を本体に組み込む設計は iCal フィード(カレンダー購読 URL)の設計 を参照してください。

前提にした現行の実装 ​

フォーム機能の GUID による公開 ​

フォーム機能は、ログインなしでレコードの作成画面を見せる仕組みで、次の形になっています。

  • URL は /forms/{guid}/new。GUID は Sites.Form 列(nvarchar(32))に入る、ハイフンなしの 32 文字の 16 進数です。添付ファイル用のルートでも Guid = "[A-Fa-f0-9]{32}" の制約を掛けています(Startup.cs#L710-L724)。
  • FormsController は [AllowAnonymous]・[FormsAttributes]・[ConditionalValidateAntiForgeryToken] 付きです(FormsController.cs#L34-L37)。
  • FormsAttributes は new Context(tenantId: 0) を作り、context.IsForm でなければ BadRequest にリダイレクトします。IsForm は Context.SetPublish() が、GUID でサイトを引き、Parameters.Form.Enabled かテナントの ContractSettings.Extensions["Form"] が有効なときに立てます(Context.cs#L723-L775)。

図を読み込み中…

カレンダー ​

種類値描画
StandardCalendarTypes.Standard(1)サーバー側の HTML テーブル
FullCalendarCalendarTypes.FullCalendar(2)クライアント側の FullCalendar(1.5.8.1 の package.json は @fullcalendar/core 6.1.21)

カレンダーの JSON は ResultUtilities / IssueUtilities の CalendarJson が返します。ビューから開始・終了の列(fromColumn / toColumn)・期間・グループ化の列を決め、表示範囲を計算し、CalendarDataRows でレコードを取り、HTML を組み立てる流れです。イベントの型は Standard 用が CalendarElement(Id・SiteId・Title・From・To・StatusHtml など)、FullCalendar 用が FullCalendarElement(id・siteId・title・start・end・StatusHtml など)です。

設計の方針 ​

フォーム機能(本体)外部公開カレンダー(この設計)
URL/forms/{guid}/new/public-calendars/{guid}
GUID の保存先Sites.Form 列拡張側の専用テーブル
認証不要(AllowAnonymous)不要(AllowAnonymous)
コントローラFormsControllerPublicCalendarController(拡張ライブラリ)
対象サイトサイト ID + ビュー ID
見せる範囲サイト単位項目単位で指定

図を読み込み中…

URL ​

メソッドURL内容
GET/public-calendars/{guid}カレンダー画面
POST/public-calendars/{guid}/events表示範囲のイベント(JSON)
GET/public-calendars/{guid}/events.icsiCalendar でのエクスポート

GUID はフォーム機能と同じくハイフンなし 32 文字の 16 進数(Guid.NewGuid().ToString("N"))で、小文字にそろえ、DB の一意制約で重複を防ぎます。

設定テーブル ​

拡張ライブラリの Initialize() で、無ければ作成します。

図を読み込み中…

列型NULL内容
Idint不可主キー(自動採番)
TenantIdint不可テナント ID
SiteIdbigint不可対象サイト
ViewIdint不可対象ビュー(0 は既定のビュー)
Guidnvarchar(32)不可公開用 GUID(一意)
VisibleColumnsnvarchar(max)可見せる項目の JSON 配列(例: ["Title", "DateA", "DateB", "ClassA", "Status"])
Titlenvarchar(256)可カレンダーの見出し
CalendarColumnnvarchar(128)不可開始日の項目
CalendarEndColumnnvarchar(128)可終了日の項目(空なら 1 日のイベント)
TitleColumnnvarchar(128)不可イベント名に使う項目
Enabledbit不可有効(既定 1)
StartDateTime / EndDateTimedatetime2可公開期間(空なら即時・無期限)
CreatedTime / UpdatedTimedatetime2不可作成・更新日時

コントローラ ​

text
Implem.Pleasanter.ExtendedLibrary.PublicCalendar/
├── ExtendedLibrary.cs            ← Initialize でテーブルを作る
├── Controllers/PublicCalendarController.cs
├── Models/PublicCalendarSettingsModel.cs
├── Models/PublicCalendarEventModel.cs
├── Services/PublicCalendarService.cs
└── Views/PublicCalendar/Index.cshtml

Initialize を使うので、クラスは Implem.Pleasanter.NetCore.ExtendedLibrary.ExtendedLibrary にします(拡張ライブラリ)。

PublicCalendarController の例
csharp
[AllowAnonymous]
[Route("public-calendars")]
public class PublicCalendarController : Controller
{
    [HttpGet("{guid:regex([[a-fA-F0-9]]{{32}})}")]
    public ActionResult Index(string guid)
    {
        var settings = PublicCalendarService.GetSettings(guid);
        if (settings == null || !settings.IsAccessible()) return NotFound();
        var context = PublicCalendarService.CreateContext(settings);
        return View("Index", PublicCalendarService.BuildCalendarHtml(context, settings));
    }

    [HttpPost("{guid:regex([[a-fA-F0-9]]{{32}})}/events")]
    public ActionResult Events(string guid, [FromBody] CalendarEventsRequest request)
    {
        var settings = PublicCalendarService.GetSettings(guid);
        if (settings == null || !settings.IsAccessible()) return NotFound();
        var context = PublicCalendarService.CreateContext(settings);
        return Json(PublicCalendarService.GetEvents(context, settings, request.Start, request.End));
    }

    [HttpGet("{guid:regex([[a-fA-F0-9]]{{32}})}/events.ics")]
    public ActionResult ExportIcs(string guid)
    {
        var settings = PublicCalendarService.GetSettings(guid);
        if (settings == null || !settings.IsAccessible()) return NotFound();
        var context = PublicCalendarService.CreateContext(settings);
        return File(
            System.Text.Encoding.UTF8.GetBytes(PublicCalendarService.GenerateIcs(context, settings)),
            "text/calendar",
            "calendar.ics");
    }
}

public class CalendarEventsRequest
{
    public DateTime Start { get; set; }
    public DateTime End { get; set; }
}

public class PublicCalendarEvent  // FullCalendar に渡す形
{
    public long Id { get; set; }
    public string Title { get; set; }
    public DateTime Start { get; set; }
    public DateTime? End { get; set; }
    public Dictionary<string, string> ExtendedProps { get; set; }
}

[AllowAnonymous] を付けないと、本体のグローバルの AuthorizeFilter で拒否されます(拡張ライブラリのコントローラに掛かるフィルタ)。

データの取り出し ​

  • Context: 本体の SetPublish() は通さず、設定テーブルの TenantId を使って Context を作ります。
  • イベント: SiteSettings を取り、ViewId のビュー(無ければ既定)と、CalendarColumn・CalendarEndColumn の列を決め、本体の CalendarDataRows と同じ要領で表示範囲のレコードを取ります。
  • 項目の絞り込み: VisibleColumns にある項目だけを ExtendedProps に入れて返します。サイトの他の項目は返しません。
csharp
private static Dictionary<string, string> BuildExtendedProps(
    Context context, DataRow row, List<string> visibleColumns, SiteSettings ss)
{
    var props = new Dictionary<string, string>();
    foreach (var columnName in visibleColumns)
    {
        var column = ss.GetColumn(context: context, columnName: columnName);
        if (column == null) continue;
        var value = row[column.ColumnName]?.ToString();
        if (!string.IsNullOrEmpty(value)) props[column.LabelText] = value;
    }
    return props;
}

画面 ​

FullCalendar の events コールバックから /public-calendars/{guid}/events に表示範囲を POST し、返ったイベントを描きます。月を移動するたびに同じ流れで取り直します。

Index.cshtml の例
html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="utf-8" />
    <title>@Model.Title</title>
</head>
<body>
    <h1>@Model.Title</h1>
    <div id="calendar"></div>
    <script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.21/index.global.min.js"></script>
    <script>
        document.addEventListener('DOMContentLoaded', function () {
            var calendar = new FullCalendar.Calendar(document.getElementById('calendar'), {
                initialView: 'dayGridMonth',
                locale: 'ja',
                events: function (info, successCallback, failureCallback) {
                    fetch('@Model.EventsUrl', {
                        method: 'POST',
                        headers: { 'Content-Type': 'application/json' },
                        body: JSON.stringify({ start: info.startStr, end: info.endStr })
                    })
                        .then((response) => response.json())
                        .then((data) => successCallback(data))
                        .catch((error) => failureCallback(error));
                }
            });
            calendar.render();
        });
    </script>
</body>
</html>

セキュリティ ​

対策内容
推測しにくい URL128 ビットの GUID。小文字にそろえ、一意制約とインデックスを付ける
有効・無効Enabled = false ですぐ止める
公開期間StartDateTime・EndDateTime の外は 404
項目の制限VisibleColumns の項目だけ返す
ビューのフィルタ指定した ViewId のフィルタ条件を掛ける
返さないもの作成者・更新者などのユーザー情報、サイト設定、例外の詳細。レコード ID もそのまま返さない(ハッシュ化などを検討)
レート制限公開エンドポイントに掛ける(画面操作へのレート制限の追加)
csharp
public bool IsAccessible()
{
    if (!Enabled) return false;
    var now = DateTime.UtcNow;
    if (StartDateTime.HasValue && now < StartDateTime.Value) return false;
    if (EndDateTime.HasValue && now > EndDateTime.Value) return false;
    return true;
}

管理 API ​

設定は API キーで認証する管理用の API で作ります。作成時に GUID を発行し、公開 URL を返します。

メソッドURL内容
GET/api/public-calendar-settings一覧
POST/api/public-calendar-settings作成(GUID を発行)
GET/api/public-calendar-settings/{id}詳細
PUT/api/public-calendar-settings/{id}更新
DELETE/api/public-calendar-settings/{id}削除
json
{
    "SiteId": 12345,
    "ViewId": 1,
    "Title": "社内イベントカレンダー",
    "CalendarColumn": "DateA",
    "CalendarEndColumn": "DateB",
    "TitleColumn": "Title",
    "VisibleColumns": ["Title", "DateA", "DateB", "ClassA"],
    "Enabled": true,
    "StartDateTime": null,
    "EndDateTime": null
}

応答には Id・Guid・Url(https://example.com/public-calendars/<GUID>)と、登録した値・作成日時・更新日時を返します。API キーでの認証の書き方は 拡張ライブラリの「API キーで使う」 を参照してください。

iCalendar ​

/public-calendars/{guid}/events.ics では RFC 5545 の iCalendar を返し、Google カレンダーや Outlook から購読できるようにします。

text
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Pleasanter//PublicCalendar//JA
CALSCALE:GREGORIAN
METHOD:PUBLISH
X-WR-CALNAME:社内イベントカレンダー
BEGIN:VEVENT
DTSTART:20260310T090000Z
DTEND:20260310T180000Z
SUMMARY:定例会議
UID:event-12345@example.com
END:VEVENT
END:VCALENDAR

タイムゾーン・終日の扱いなどは iCal フィードの設計 と同じ考え方です。

進め方 ​

  1. 基盤: プロジェクト作成、設定テーブル(Initialize)、コントローラとサービス
  2. 画面: FullCalendar の画面、events での取得、月・週・日の切り替え
  3. 管理: 管理 API、GUID の発行と URL の表示、公開期間
  4. セキュリティ: レート制限、漏えいが無いかの検証、CORS
  5. 追加: .ics のエクスポート、見た目のカスタマイズ

関連ページ ​

変更履歴

第1版拡張ライブラリの読み込みと開発・デバッグ、拡張ヘッドリンク、SMTP の OAuth 送信の解説と、多言語・外部公開カレンダー・スレッド型サイトなどの改修・設計メモを追加