# 通知設計書 53-site.changed

## 概要

本ドキュメントは、Ghostにおける `site.changed` Webhookイベントの設計仕様を記述する。このWebhookは、サイト全体に影響を与える変更が発生した際に、登録されたWebhookエンドポイントへHTTPリクエストを送信する仕組みである。

### 本通知の処理概要

`site.changed` イベントは、サイトのキャッシュを無効化する必要がある変更が発生した際に自動的にトリガーされるWebhook通知である。投稿の公開、ページの公開、設定の変更など、サイト全体のコンテンツに影響を与える操作で発火する。

**業務上の目的・背景**：静的サイトジェネレーター（SSG）やCDN、外部キャッシュシステムとの連携において、コンテンツの変更をリアルタイムで検知することは重要である。`site.changed` イベントは、サイト全体のキャッシュ無効化が必要なタイミングを外部システムに通知することで、コンテンツの整合性を保ちながら効率的なキャッシュ運用を可能にする。例えば、Netlify、Vercel、CloudflareなどのCDNと連携してキャッシュパージをトリガーしたり、静的サイトの再ビルドを開始するユースケースが想定される。

**通知の送信タイミング**：HTTPレスポンスのヘッダーに `X-Cache-Invalidate: /*` が設定された場合に送信される。これは以下のような操作で発生する：
- 投稿/ページの公開
- 投稿/ページの編集（公開済み）
- 設定の変更
- テーマの変更
- ユーザー情報の変更

**通知の受信者**：このイベントに対してWebhookを登録したインテグレーション（外部システム）のエンドポイントURL。Webhook登録時に指定したtarget_urlに対してHTTP POSTリクエストが送信される。

**通知内容の概要**：空のJSONオブジェクト `{}` が送信される。このイベントは「サイトが変更された」という事実のみを通知し、具体的な変更内容は含まない。

**期待されるアクション**：受信側システムは、サイト全体のキャッシュ無効化、静的サイトの再ビルド、検索インデックスの更新などの処理を実行することが期待される。

## 通知種別

Webhook（HTTP POST）

## 送信仕様

### 基本情報

| 項目 | 内容 |
|-----|------|
| 送信方式 | 非同期（イベント駆動） |
| 優先度 | 高（キャッシュ無効化に関わる） |
| リトライ | 有・最大5回（テスト環境では0回） |

### 送信先決定ロジック

1. レスポンス完了時に `X-Cache-Invalidate: /*` ヘッダーが設定されているか確認
2. 設定されている場合、`events.emit('site.changed')` が呼び出される
3. `Webhook` モデルから `event = 'site.changed'` に一致するWebhook設定を全件取得
4. `customIntegrations` の制限がある場合、内部インテグレーション（`type = 'internal'`）のWebhookのみ送信対象
5. 各Webhookの `target_url` に対してHTTP POSTリクエストを送信

## 通知テンプレート

### Webhook通知の場合

| 項目 | 内容 |
|-----|------|
| 送信先URL | Webhook設定の `target_url` |
| HTTPメソッド | POST |
| Content-Type | application/json |
| 形式 | JSON |

### ヘッダー

| ヘッダー名 | 値 | 説明 |
|-----------|-----|------|
| Content-Type | application/json | コンテンツタイプ |
| Content-Length | 2 | ペイロードサイズ（空オブジェクト `{}`） |
| Content-Version | v{Ghost APIバージョン} | Ghost APIバージョン |
| X-Ghost-Signature | sha256={署名}, t={タイムスタンプ} | Webhook秘密鍵が設定されている場合のみ |

### 本文テンプレート

```json
{}
```

**注意**: `site.changed` イベントはmodelを伴わないため、ペイロードは空のJSONオブジェクトとなる。

### 添付ファイル

なし

## テンプレート変数

なし（ペイロードは空オブジェクト）

## 送信トリガー・条件

### トリガー一覧

| トリガー種別 | トリガーイベント | 送信条件 | 説明 |
|------------|----------------|---------|------|
| HTTPレスポンス | X-Cache-Invalidate: /* | Webhook登録あり | レスポンス完了時にキャッシュ無効化ヘッダーが設定されている場合 |

### X-Cache-Invalidateが設定される主な操作

| 操作 | APIエンドポイント | 条件 |
|-----|------------------|------|
| 投稿公開 | POST /posts/, PUT /posts/{id} | status = 'published' |
| ページ公開 | POST /pages/, PUT /pages/{id} | status = 'published' |
| 公開済み投稿の編集 | PUT /posts/{id} | status変更または重要フィールド変更 |
| 設定変更 | PUT /settings/ | 設定値の変更 |
| テーマ変更 | PUT /themes/{name}/activate | テーマのアクティベート |
| ユーザー情報変更 | PUT /users/{id} | プロフィール変更 |
| タグの変更 | POST/PUT/DELETE /tags/ | タグの追加/編集/削除 |

### 送信抑止条件

| 条件 | 説明 |
|-----|------|
| X-Cache-Invalidate未設定 | レスポンスにキャッシュ無効化ヘッダーがない場合 |
| customIntegrations制限 | プラン制限により外部Webhookが制限されている場合、内部インテグレーション以外は送信されない |
| Webhook未登録 | 該当イベントに対するWebhook登録がない場合は送信されない |

## 処理フロー

### 送信フロー

```mermaid
flowchart TD
    A[APIリクエスト処理] --> B[レスポンス生成]
    B --> C{X-Cache-Invalidate設定?}
    C -->|No| D[終了]
    C -->|Yes| E[res.on'finish']
    E --> F{X-Cache-Invalidate == '/*'?}
    F -->|No| D
    F -->|Yes| G[events.emit'site.changed']
    G --> H[WebhookTrigger.trigger]
    H --> I[Webhook.findAllByEvent]
    I --> J{Webhook登録あり?}
    J -->|Yes| K[ペイロード生成（空オブジェクト）]
    K --> L[HTTP POSTリクエスト送信]
    L --> M{送信成功?}
    M -->|成功| N[last_triggered更新]
    M -->|失敗| O{リトライ?}
    O -->|Yes| L
    O -->|No| P[エラーログ記録]
    N --> D
    P --> D
    J -->|No| D
```

## データベース参照・更新仕様

### 参照テーブル一覧

| テーブル名 | 用途 | 備考 |
|-----------|------|------|
| webhooks | 登録済みWebhook取得 | event = 'site.changed' |
| integrations | Webhookの所属インテグレーション | 内部/外部の判定 |

### テーブル別参照項目詳細

#### webhooks

| 参照項目（カラム名） | 用途 | 取得条件 |
|-------------------|------|---------|
| id | Webhook識別 | - |
| event | イベントフィルタ | event = 'site.changed' |
| target_url | 送信先URL | - |
| secret | 署名生成用秘密鍵 | 任意 |
| integration_id | インテグレーション参照 | - |

### 更新テーブル一覧

| テーブル名 | 操作 | 概要 |
|-----------|------|------|
| webhooks | UPDATE | 送信結果の記録 |

#### webhooks（送信結果記録）

| 操作 | 項目（カラム名） | 更新値 | 備考 |
|-----|-----------------|-------|------|
| UPDATE | last_triggered_at | Date.now() | 送信日時 |
| UPDATE | last_triggered_status | HTTPステータスコード | 成功/失敗時のステータス |
| UPDATE | last_triggered_error | エラーメッセージ | 失敗時のみ |

## エラー処理

### エラーケース一覧

| エラー種別 | 発生条件 | 対処方法 |
|----------|---------|---------|
| 送信失敗 | ネットワークエラー、タイムアウト | リトライ後、エラーログ記録 |
| 410 Gone | 送信先が410を返した | Webhookを自動削除 |
| 宛先不正 | target_urlが無効 | エラーログ記録、Webhook更新 |

### リトライ仕様

| 項目 | 内容 |
|-----|------|
| リトライ回数 | 最大5回（テスト環境では0回） |
| リトライ間隔 | @tryghost/requestのデフォルト動作に従う |
| リトライ対象エラー | ネットワークエラー、一時的なサーバーエラー |

## 配信設定

### レート制限

| 項目 | 内容 |
|-----|------|
| リクエストタイムアウト | 2秒 |
| 1分あたり上限 | なし（システム側の制限なし） |
| 1日あたり上限 | なし |

### 配信時間帯

制限なし（イベント発生時に即時送信）

## セキュリティ考慮事項

- **署名検証**: Webhook設定で `secret` が設定されている場合、`X-Ghost-Signature` ヘッダーにHMAC-SHA256署名が付与される
- **署名形式**: `sha256={ペイロード+タイムスタンプのHMAC署名}, t={タイムスタンプ}`
- **タイムスタンプ**: リプレイ攻撃対策として、受信側でタイムスタンプの検証が推奨される
- **HTTPS推奨**: target_urlにはHTTPSの使用が推奨される
- **空ペイロード**: ペイロードが空オブジェクトのため、署名は `{}` + タイムスタンプに対して生成される

## 備考

- `site.changed` はGhostの内部キャッシュ管理にも使用されており、posts-public、tags-public、link-redirection などのサービスがこのイベントをリッスンしてキャッシュをリセットする
- このイベントはモデルイベントではなく、HTTPレスポンスのミドルウェアで発火されるため、他のWebhookイベントとは発火メカニズムが異なる
- 頻繁な編集操作では `site.changed` が連続して発火する可能性があるため、受信側でのデバウンス処理が推奨される

---

## コードリーディングガイド

本通知を理解するために参照すべきファイルと、推奨する読み解き順序を以下に示す。

### 推奨読解順序

#### Step 1: イベント発火元を理解する

site.changedイベントの発火元であるミドルウェアを理解することが最も重要。

| 順序 | ファイル | パス | 読解ポイント |
|-----|---------|------|-------------|
| 1-1 | emit-events.js | `ghost/core/core/server/web/parent/middleware/emit-events.js` | site.changedイベントの発火条件（X-Cache-Invalidate: /*） |

**主要処理フロー**:
1. **1行目**: `INVALIDATE_ALL = '/*'` 定数定義
2. **11-20行目**: emitEventsミドルウェア関数
3. **12-15行目**: res.on('finish') でレスポンス完了時にX-Cache-Invalidateをチェック
4. **14行目**: `events.emit('site.changed')` でイベント発火

**読解のコツ**: このミドルウェアはExpressのレスポンス完了イベントにフックしており、モデルイベントとは異なる発火メカニズムである点に注意。

#### Step 2: X-Cache-Invalidateヘッダーの設定箇所を理解する

どの操作でX-Cache-Invalidateが設定されるかを理解する。

| 順序 | ファイル | パス | 読解ポイント |
|-----|---------|------|-------------|
| 2-1 | pages.js | `ghost/core/core/server/api/endpoints/pages.js` | ページ公開時のcacheInvalidate設定（123行目） |
| 2-2 | posts.js | `ghost/core/core/server/api/endpoints/posts.js` | 投稿公開時のcacheInvalidate設定 |
| 2-3 | settings.js | `ghost/core/core/server/api/endpoints/settings.js` | 設定変更時のcacheInvalidate設定 |

**読解のコツ**: 各APIエンドポイントで `frame.setHeader('X-Cache-Invalidate', '/*')` が呼び出される条件を確認する。

#### Step 3: Webhookリスナーを理解する

イベントリスナーの登録とイベント発火の仕組みを理解する。

| 順序 | ファイル | パス | 読解ポイント |
|-----|---------|------|-------------|
| 3-1 | listen.js | `ghost/core/core/server/services/webhooks/listen.js` | Webhookシステムのエントリーポイント、WEBHOOKSリスト（11行目）でsite.changedが最初に定義されている |
| 3-2 | events.js | `ghost/core/core/server/lib/common/events.js` | EventRegistryクラス、イベントバスの実装 |

**主要処理フロー**:
1. **11行目**: サポートされるWebhookイベントの最初に`'site.changed'`が定義
2. **47-68行目**: listen関数でイベントリスナーを登録
3. **59-66行目**: processWebhookTriggerハンドラでWebhookTrigger.triggerを呼び出し

#### Step 4: ペイロード生成を理解する

site.changedの特殊なペイロード（空オブジェクト）を理解する。

| 順序 | ファイル | パス | 読解ポイント |
|-----|---------|------|-------------|
| 4-1 | payload.js | `ghost/core/core/server/services/webhooks/payload.js` | modelがない場合の空オブジェクト返却（13-15行目） |

**主要処理フロー**:
- **3-4行目**: payload関数でmodelの有無をチェック
- **6-12行目**: modelがある場合はserializeを呼び出し
- **13-15行目**: modelがない場合は空オブジェクト `{}` を返却

#### Step 5: Webhook送信処理を理解する

WebhookTriggerクラスでの実際のHTTP送信処理を理解する。

| 順序 | ファイル | パス | 読解ポイント |
|-----|---------|------|-------------|
| 5-1 | webhook-trigger.js | `ghost/core/core/server/services/webhooks/webhook-trigger.js` | triggerメソッド（98-143行目）でWebhook送信を実行 |

**主要処理フロー**:
- **98-102行目**: triggerメソッドの定義
- **109行目**: `this.payload(webhook.get('event'), model)` でペイロード生成（modelはundefined）
- **122-124行目**: secretが設定されている場合のX-Ghost-Signature生成

### プログラム呼び出し階層図

```
API Request (posts/pages/settings等)
    │
    ├─ Controller処理
    │      └─ frame.setHeader('X-Cache-Invalidate', '/*')
    │
    └─ web/parent/middleware/emit-events.js
           │
           └─ res.on('finish')
                  │
                  └─ X-Cache-Invalidate === '/*' ?
                         │
                         ├─ No → 終了
                         │
                         └─ Yes → events.emit('site.changed')
                                      │
                                      └─ services/webhooks/listen.js
                                             └─ processWebhookTrigger()
                                                    │
                                                    └─ WebhookTrigger.trigger('site.changed', undefined)
                                                           │
                                                           ├─ Webhook.findAllByEvent()
                                                           │
                                                           ├─ payload() → {} (空オブジェクト)
                                                           │
                                                           └─ request(url, opts)
                                                                  │
                                                                  ├─ onSuccess()
                                                                  │
                                                                  └─ onError()
```

### データフロー図

```
[入力]                    [処理]                         [出力]

APIリクエスト ─────────▶ Controller
     │                        │
     │                        ├─ ビジネスロジック
     │                        │
     │                        └─ setHeader('X-Cache-Invalidate', '/*')
     │                              │
     ▼                              ▼
レスポンス ────────────▶ emit-events.js ミドルウェア
                              │
                              └─ res.on('finish')
                                    │
                                    ▼
                              X-Cache-Invalidate === '/*'
                                    │
                                    ▼
                              events.emit('site.changed')
                                    │
                                    ▼
                              WebhookTrigger.trigger()
                                    │
                                    ▼
                              payload(event, undefined)
                                    │
                                    ▼
                              {} ─────────────────────▶ HTTP POST
                                                         │
                                                         ▼
                                                    外部Webhookエンドポイント
```

### site.changedを利用する内部サービス

| サービス | ファイル | 用途 |
|---------|---------|------|
| posts-public | `ghost/core/core/server/services/posts-public/service.js` | 投稿キャッシュのリセット |
| tags-public | `ghost/core/core/server/services/tags-public/service.js` | タグキャッシュのリセット |
| link-redirection | `ghost/core/core/server/services/link-redirection/link-redirect-repository.js` | リダイレクトキャッシュのリセット |

### 関連ファイル一覧

| ファイル | パス | 種別 | 役割 |
|---------|------|------|------|
| emit-events.js | `ghost/core/core/server/web/parent/middleware/emit-events.js` | ソース | site.changedイベント発火ミドルウェア |
| listen.js | `ghost/core/core/server/services/webhooks/listen.js` | ソース | Webhookリスナー登録 |
| webhook-trigger.js | `ghost/core/core/server/services/webhooks/webhook-trigger.js` | ソース | Webhook送信処理 |
| payload.js | `ghost/core/core/server/services/webhooks/payload.js` | ソース | ペイロード生成（空オブジェクト） |
| webhook.js | `ghost/core/core/server/models/webhook.js` | ソース | Webhookモデル |
| events.js | `ghost/core/core/server/lib/common/events.js` | ソース | EventRegistry |
| pages.js | `ghost/core/core/server/api/endpoints/pages.js` | ソース | X-Cache-Invalidate設定（123行目） |
| posts.js | `ghost/core/core/server/api/endpoints/posts.js` | ソース | X-Cache-Invalidate設定 |
| settings.js | `ghost/core/core/server/api/endpoints/settings.js` | ソース | X-Cache-Invalidate設定 |
| site.test.js | `ghost/core/test/e2e-webhooks/site.test.js` | テスト | E2Eテスト |
| site.test.js.snap | `ghost/core/test/e2e-webhooks/__snapshots__/site.test.js.snap` | テスト | スナップショット |
