コンテンツにスキップ
JP

Re:Earth CMS の Webhook を受信するローカルサーバーを作る

更新日

Re:Earth CMS でアイテムやアセットが変更されると、Webhook がその変更を HTTP POST で自分のサーバーへ送ります。このチュートリアルでは、自分のマシン上で小さな TypeScript サーバーを動かし、そのリクエストを受信して、本当に自分の CMS から届いたものかを確かめ、変更内容を読みやすい形で表示します。コンソールへの表示は出発点です。最後に、受信したイベントを誰にどう知らせるかを自分で設計します。

CMS でアイテムを編集すると、数秒後にターミナルへ、何が・誰によって・いつ変更されたかが表示されます。

[item.update] YYYY-MM-DD hh:mm:ss UTC
model: webhook-test
item: 01abc…
changed: title: "draft" → "final"
by: user 01xyz…
event: 01def…
received: YYYY-MM-DD hh:mm:ss UTC

CMS からは localhost に届かないため、トンネルを使って自分のマシンに一時的な公開アドレスを用意し、届いたリクエストを受信サーバーへ転送します。

Re:Earth CMS ──POST + Reearth-Signature──► cloudflared トンネル ──► localhost:3000/webhook

構成図。Re CMS が Reearth-Signature ヘッダー付きの POST リクエストを、cloudflared トンネル経由で自分のマシン上の受信サーバー(localhost:3000/webhook)に送る。受信サーバーのレスポンスはトンネルを通って CMS に返り、受信サーバーは各イベントを notify() に渡して、コンソールに表示する。

完成版のソースコードと自分のプロジェクトを比較する

始める前に、次のものを用意してください。

  • Node.js 20.6 以降。受信サーバーは Node 組み込みの --env-file で設定を読み込むため、このバージョンが必要です。
  • Git
  • 所有者または管理者の役割を持つ Re:Earth CMS のワークスペース。インテグレーションをワークスペースに連携するには、どちらかの役割が必要です。自分が所有者であるパーソナルワークスペースなら問題ありません。
  • TypeScript の基本的な知識

Webhook はインテグレーションに属するため、まずインテグレーションを作成してワークスペースに連携し、変更を加えるための小さなモデルを作成します。

インテグレーションを作成して連携する

Section titled “インテグレーションを作成して連携する”

パーソナルアカウントでインテグレーションを作成し、使用するワークスペースに連携します。

  1. パーソナルアカウントで、マイインテグレーションタブを開いてください。
  2. 新規インテグレーションを作成ボタンをクリックしてください。
  3. 名前に CMS Webhook Tutorial と入力してください。
  4. 作成ボタンをクリックしてください。
  5. 使用するワークスペースで、インテグレーションタブを開いてください。
  6. インテグレーションを接続ボタンをクリックしてください。
  7. CMS Webhook Tutorial を選択してください。
  8. 接続ボタンをクリックしてください。

ワークスペースのインテグレーション画面に、連携したインテグレーションが表示されます。このチュートリアルでは役割はどれでも構いません。CMS は、役割に関係なく、連携しているすべてのインテグレーションに Webhook のイベントを送ります。手順と役割の詳細は インテグレーションの作成 を参照してください。

Webhook そのものは、受信サーバーに CMS から届くアドレスができてから、ステップ4で追加します。

テスト用のプロジェクトとモデルを作成する

Section titled “テスト用のプロジェクトとモデルを作成する”

同じワークスペースに webhook-tutorial-test という名前のプロジェクトを作成してください。その中に webhook-test という名前のモデルを作成し、キーが title の テキストフィールドを追加してください。

ステップ5では、このモデルに変更を加えてイベントを発生させます。

ステップ2 — 受信サーバーを自分のマシンで動かす

Section titled “ステップ2 — 受信サーバーを自分のマシンで動かす”

完成版の受信サーバーをクローンし、依存パッケージをインストールします。

Terminal window
git clone https://github.com/eukarya-biz/cms-webhook-tutorial.git
cd cms-webhook-tutorial
npm install
cp .env.example .env

Windows(コマンドプロンプト)では、cp .env.example .env の代わりに copy .env.example .env を使ってください。

受信サーバーには、CMS と受信サーバーの両方が知っているランダムな値、シークレットが必要です。次のコマンドで生成します。

Terminal window
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

.env を開き、CMS_WEBHOOK_SECRET に表示された値を設定してください。PORT は 3000 のまま、CMS_PROJECT_ID は空のままにしてください。このシークレットはステップ4で CMS にも同じ値を入力するので、手元に控えておいてください。

CMS とつなぐ前に、署名の検証が動くことを確認します。リポジトリにはそのためのテストが含まれています。

Terminal window
npm test

8件のテストがすべて成功します。正しく署名されたリクエストは受け付けられ、本文が変更されたもの、別のシークレットで署名されたもの、タイムスタンプが5分を超えてずれているもの、形式が正しくないヘッダーはすべて拒否されることを確認しています。

受信サーバーを起動します。

Terminal window
npm run dev

ターミナルに CMS Webhook Receiver listening on http://localhost:3000 と表示されます。そのまま起動しておき、同じフォルダで2つ目のターミナルを開いてテストイベントを送ってください。リポジトリのテスト用スクリプトは、CMS と同じ方法でサンプルイベントにシークレットで署名します。

Terminal window
npm run send-test

2つ目のターミナルに Sent item.create (test-…) → 200 と表示され、1つ目のターミナルに最初の要約が表示されます。

[item.create] YYYY-MM-DD hh:mm:ss UTC
model: webhook-test
item: test-item
field: title = "hello from the test script"
by: user test-user
event: test-…
received: YYYY-MM-DD hh:mm:ss UTC

受信サーバーが、正しく署名されたリクエストを受け付けました。次は、表示する前に受信サーバーがリクエストをどう扱っているかを見ていきましょう。

ステップ3 — 受信サーバーがリクエストを確認する仕組みを見る

Section titled “ステップ3 — 受信サーバーがリクエストを確認する仕組みを見る”

リクエストはすべて、src/server.ts の POST /webhook に届きます。受信サーバーは次のようにリクエストを読み取ります。

src/server.ts
app.post(
"/webhook",
express.raw({ type: () => true, limit: "1mb" }),
(req, res) => {
const rawBody = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
const result = verifySignature(
rawBody,
req.get("Reearth-Signature"),
secret,
);
if (!result.ok) {
console.warn(`Rejected webhook: ${result.reason}`);
res.sendStatus(401);
return;
}
// Then: parse the JSON, reply 200, and handle the event.
},
);

ここには、見た目以上に大事な点が2つあります。

本文をそのままのバイト列で読み取ります。 CMS は送信するバイト列そのものに署名するため、受信サーバーは何かを解析する前に、そのバイト列で署名を確認する必要があります。また、CMS は Content-Type ヘッダーを送りません。パーサーに type: () => true を指定しているのはそのためです。よく使われる type: "*/*" の場合、Express は Content-Type ヘッダーがあるときにしか本文を読み取りません。すると、CMS からのリクエストはすべて本文が空のまま届き、署名の確認がすべて失敗します。

署名は Reearth-Signature ヘッダーに入っています。 形式は v1,t=<unix_time>,<signature> で、バージョン、CMS がリクエストを送った時刻(1970年からの秒数)、署名そのものが並んでいます。src/verify.ts は、シークレットを使って署名を計算し直し、2つを比較します。

src/verify.ts
const expected = createHmac("sha256", secret)
.update(`v1:${timestamp}:`)
.update(rawBody)
.digest("hex");
// timingSafeEqual throws if the lengths differ, so check that first.
const received = Buffer.from(signature);
const computed = Buffer.from(expected);
if (
received.length !== computed.length ||
!timingSafeEqual(received, computed)
) {
return { ok: false, reason: "signature mismatch" };
}

署名は、v1:、タイムスタンプ、:、本文をこの順につなげたものを、シークレットを鍵として HMAC-SHA256 で計算した値です。一致する署名を作れるのはシークレットを持つ人だけなので、一致すれば、リクエストが自分の CMS から届き、途中で変更されていないことがわかります。verifySignature は、この比較の前に、現在時刻から5分を超えてずれたタイムスタンプも拒否します。これにより、誰かが記録したリクエストをあとから再送されても受け付けません。

確認が済むと、受信サーバーはすぐに 200 を返し、そのあとでイベントを処理します。CMS は返答を最大30秒しか待たず、300 を超えるステータスはすべて配信失敗として扱うため、時間のかかる処理を返答より先に行うことはありません。

イベントの処理は handleEvent() が行います。すでに処理したイベントと、扱わない種類のイベントを読み飛ばしたうえで、src/notify.ts の notify() にイベントを渡します。先ほどの要約を表示したのはこの関数で、ステップ6で再び取り上げます。

ステップ4 — トンネル経由で CMS とつなぐ

Section titled “ステップ4 — トンネル経由で CMS とつなぐ”

CMS はインターネット上で動いているため、localhost には届きません。Cloudflare のクイックトンネルは、自分のマシンに一時的な公開アドレスを用意し、届いたリクエストを変更せずにそのまま転送します。署名の確認はこの点を前提にしています。無料で、Cloudflare のアカウントも不要です。

使用している OS に合わせて cloudflared をインストールしてください。受信サーバーを起動したまま、新しいターミナルでトンネルを開始してください。

Terminal window
cloudflared tunnel --url http://localhost:3000

数秒後、末尾が trycloudflare.com のアドレスを含む枠が表示されます。そのアドレスをコピーし、このターミナルは起動したままにしておきます。閉じるとトンネルが終了し、アドレスは cloudflared を起動するたびに変わります。

インターネットからのリクエストが受信サーバーに届くこと、そして署名のないリクエストが拒否されることを確認します。<tunnel_url> をコピーしたアドレス全体に置き換えて、次のコマンドを実行してください。

Terminal window
curl -i -X POST <tunnel_url>/webhook --data '{"hello":"world"}'

レスポンスは 401 Unauthorized になり、受信サーバーのターミナルに Rejected webhook: missing Reearth-Signature header と表示されます。

このアドレスを CMS に登録します。マイインテグレーションで CMS Webhook Tutorial インテグレーションを開き、Webhook タブを開いて 新規Webhook ボタンをクリックしてください。

インテグレーション詳細ページの Webhook タブ。まだ Webhook が無い状態で、Webhook タブと New Webhook ボタンが強調表示されている。

フォームに次のように入力してください。

  • 名前:local-receiver
  • URL:トンネルのアドレスの末尾に /webhook を付けたもの(<tunnel_url>/webhook)
  • シークレット:.env の CMS_WEBHOOK_SECRET と同じ値
  • トリガーイベント:アイテムとアセットのイベントすべて
CMS Webhook Tutorial インテグレーションの新規 Webhook フォーム。名前に local-receiver、URL に末尾が /webhook の trycloudflare.com のアドレス、シークレットに your-secret-here が入力され、Item と Asset のトリガーイベントがすべてチェックされている。

保存ボタンをクリックし、新しい Webhook のスイッチが オン になっていることを確認してください。

CMS Webhook Tutorial インテグレーションの Webhook タブ。local-receiver という名前の Webhook が1件あり、スイッチが ON で、URL は末尾が /webhook の trycloudflare.com のアドレスになっている。

ステップ5 — 実際のイベントが届くのを確認する

Section titled “ステップ5 — 実際のイベントが届くのを確認する”

受信サーバーのターミナルが見える状態にしておいてください。webhook-tutorial-test プロジェクトで次の変更を1つずつ行い、そのたびにターミナルを確認してください。

  1. webhook-test モデルで、タイトルが draft のアイテムを作成してください。[item.create] の要約が表示され、field: 行に title = "draft" と表示されます。
  2. タイトルを final に変更して保存してください。[item.update] の要約が表示され、changed: 行に変更前と変更後の値 title: "draft" → "final" が表示されます。
  3. アイテムを公開してください。[item.publish] の要約が表示されます。
  4. アイテムを非公開にしてください。[item.unpublish] の要約が表示されます。
  5. プロジェクトのアセットに小さな画像をアップロードしてください。[asset.create] の要約が、ファイル名とサイズとともに表示されます。
受信サーバーのターミナル。3つの要約が順に表示されている。field 行が title = "draft" の item.create、changed 行が title: "draft" → "final" の item.update、item.publish。各要約にはモデル、アイテム、ユーザー、イベント ID、受信時刻が並び、ユーザー ID は隠されている。

要約は、変更から数秒以内に表示されます。1行目の時刻と received: を比べてみてください。前者は CMS で変更が発生した時刻、後者は受信サーバーが受け取った時刻です。

どのイベントが届くかについて、次のことにも気づくはずです。

  • 保存するたびに、別々のイベントになります。 アイテムを2回保存すると、item.update の要約が2つ届きます。
  • CMS でアイテムやアセットを削除しても、イベントは送られません。 item.delete はモデル全体を削除したときに、そのモデル内のアイテムごとに1つずつ送られます。asset.delete は、インテグレーションAPI でアセットを削除したときに送られます。
  • 配信に失敗すると、あとで再送されます。 受信サーバーが停止していたり、リクエストを拒否したりした場合、同じイベントがしばらく経ってから、より新しいイベントのあとに届くことがあります。受信サーバーは、処理済みのイベント ID を読み飛ばし、順序には依存しません。

作業を終えたら、Webhook のスイッチを オフ にしてください。トンネルを停止するとそのアドレスは使えなくなり、ワークスペースでの変更がすべて配信失敗となって、CMS があとで再送することがあります。次回は、トンネルを起動して新しいアドレスを Webhook の設定に入力し、スイッチを再び オン にしてください。

ステップ6 — 独自の通知を設計する

Section titled “ステップ6 — 独自の通知を設計する”

コンソールへの表示で、全体の経路が動くことを確認できました。実際の受信サーバーなら、誰かに知らせるはずです。このステップでは設計を指定しません。受信サーバーに何をさせるか、そしてそれが動いているとどう判断するかは、自分で決めます。

変更する場所は、src/notify.ts の notify() です。検証済みで、フィルターを通過したイベントごとに1回呼ばれます。async にできるので、ほかのサービスの応答を待つこともできます。

src/notify.ts
export async function notify(event: WebhookEvent): Promise<void> {
console.log(formatEvent(event));
}

コードを書く前に、次の問いを考えてみてください。

  • 誰に、どのイベントを知らせる必要があるか。 レビュー担当者は特定のモデルの item.publish だけに関心があり、編集者は item.update に関心があるかもしれません。event.type、event.data.model.key、アイテムのフィールドは、すべてイベントに含まれています。
  • いつ知らせるか。 すぐに知らせるのか、1時間ごとにまとめて1つのメッセージにするのか。1つのアイテムを続けて保存すると、イベントも続けて届きます。
  • 送信に失敗したらどうするか。 受信サーバーはすでに 200 を返しているため、CMS はそのイベントを再送しません。再試行するのか、ログに残すのか、失われてもよいとするのかを決めます。
  • 認証情報をどこに置くか。 Slack の Webhook URL やメールのパスワードもシークレットです。コードには書かず、CMS_WEBHOOK_SECRET と同じく .env に置きます。
  • CMS なしでどうテストするか。 npm run send-test は --type、--id、--project を受け付けるので、設計で扱うイベントを送れます。

よく使われる2つの通知先については、次の公式ガイドが出発点になります。

作り始める前に、自分の設計にとっての「完成」を、確認できる形で決めておきましょう。たとえば、「webhook-test モデルのアイテムを公開すると、そのアイテム名を含む Slack メッセージが1件投稿され、投稿に失敗した場合はログに記録される」のように決めます。そのうえで、受信サーバーがそれを満たしているか確認します。

受信サーバーに Rejected webhook: signature mismatch と表示される

Webhook のシークレットと .env の CMS_WEBHOOK_SECRET が完全には一致していません。余分なスペースや欠けている文字がないか、両方を確認してください。受信サーバーの起動中に .env を編集した場合は、いったん停止して npm run dev を実行し直してください。受信サーバーは起動時にしか .env を読み込みません。

アイテムを変更しても何も表示されない

Webhook のスイッチが オン になっているか、URL の末尾が /webhook になっているか、発生させているイベントが選択されているか、作業しているワークスペースにインテグレーションが連携されているかを確認してください。

トンネルのターミナルに Unable to reach the origin service と表示される

トンネルは動いていますが、受信サーバーが動いていません。npm run dev で起動し直してください。

受信サーバーに Ignored … from another project と表示される

.env の CMS_PROJECT_ID に別のプロジェクトが設定されています。空にすると、ワークスペース内のすべてのプロジェクトからのイベントを受け付けます。

  • トンネルのアドレスへの署名のないリクエストが 401 で拒否される。
  • CMS でアイテムを作成・編集・公開・非公開にすると、それぞれの要約がターミナルに表示される。
  • 受信サーバーが本文をそのままのバイト列で読み取る理由と、イベントを処理する前に返答する理由を説明できる。
  • 通知で何をするか、そしてその「完成」が何かを決めている。