拡張ライブラリで外部公開カレンダーを作る(設計)
本体の標準機能ではありません
このページは 拡張ライブラリ で機能を足す場合の設計メモです。前提にした現行の実装は 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)。
図を読み込み中…
カレンダー
| 種類 | 値 | 描画 |
|---|---|---|
| Standard | CalendarTypes.Standard(1) | サーバー側の HTML テーブル |
| FullCalendar | CalendarTypes.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) |
| コントローラ | FormsController | PublicCalendarController(拡張ライブラリ) |
| 対象 | サイト | サイト ID + ビュー ID |
| 見せる範囲 | サイト単位 | 項目単位で指定 |
図を読み込み中…
URL
| メソッド | URL | 内容 |
|---|---|---|
| GET | /public-calendars/{guid} | カレンダー画面 |
| POST | /public-calendars/{guid}/events | 表示範囲のイベント(JSON) |
| GET | /public-calendars/{guid}/events.ics | iCalendar でのエクスポート |
GUID はフォーム機能と同じくハイフンなし 32 文字の 16 進数(Guid.NewGuid().ToString("N"))で、小文字にそろえ、DB の一意制約で重複を防ぎます。
設定テーブル
拡張ライブラリの Initialize() で、無ければ作成します。
図を読み込み中…
| 列 | 型 | NULL | 内容 |
|---|---|---|---|
Id | int | 不可 | 主キー(自動採番) |
TenantId | int | 不可 | テナント ID |
SiteId | bigint | 不可 | 対象サイト |
ViewId | int | 不可 | 対象ビュー(0 は既定のビュー) |
Guid | nvarchar(32) | 不可 | 公開用 GUID(一意) |
VisibleColumns | nvarchar(max) | 可 | 見せる項目の JSON 配列(例: ["Title", "DateA", "DateB", "ClassA", "Status"]) |
Title | nvarchar(256) | 可 | カレンダーの見出し |
CalendarColumn | nvarchar(128) | 不可 | 開始日の項目 |
CalendarEndColumn | nvarchar(128) | 可 | 終了日の項目(空なら 1 日のイベント) |
TitleColumn | nvarchar(128) | 不可 | イベント名に使う項目 |
Enabled | bit | 不可 | 有効(既定 1) |
StartDateTime / EndDateTime | datetime2 | 可 | 公開期間(空なら即時・無期限) |
CreatedTime / UpdatedTime | datetime2 | 不可 | 作成・更新日時 |
コントローラ
Implem.Pleasanter.ExtendedLibrary.PublicCalendar/
├── ExtendedLibrary.cs ← Initialize でテーブルを作る
├── Controllers/PublicCalendarController.cs
├── Models/PublicCalendarSettingsModel.cs
├── Models/PublicCalendarEventModel.cs
├── Services/PublicCalendarService.cs
└── Views/PublicCalendar/Index.cshtmlInitialize を使うので、クラスは Implem.Pleasanter.NetCore.ExtendedLibrary.ExtendedLibrary にします(拡張ライブラリ)。
PublicCalendarController の例
[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に入れて返します。サイトの他の項目は返しません。
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 の例
<!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>セキュリティ
| 対策 | 内容 |
|---|---|
| 推測しにくい URL | 128 ビットの GUID。小文字にそろえ、一意制約とインデックスを付ける |
| 有効・無効 | Enabled = false ですぐ止める |
| 公開期間 | StartDateTime・EndDateTime の外は 404 |
| 項目の制限 | VisibleColumns の項目だけ返す |
| ビューのフィルタ | 指定した ViewId のフィルタ条件を掛ける |
| 返さないもの | 作成者・更新者などのユーザー情報、サイト設定、例外の詳細。レコード ID もそのまま返さない(ハッシュ化などを検討) |
| レート制限 | 公開エンドポイントに掛ける(画面操作へのレート制限の追加) |
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} | 削除 |
{
"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 から購読できるようにします。
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 フィードの設計 と同じ考え方です。
進め方
- 基盤: プロジェクト作成、設定テーブル(
Initialize)、コントローラとサービス - 画面: FullCalendar の画面、
eventsでの取得、月・週・日の切り替え - 管理: 管理 API、GUID の発行と URL の表示、公開期間
- セキュリティ: レート制限、漏えいが無いかの検証、CORS
- 追加:
.icsのエクスポート、見た目のカスタマイズ