บันทึกการใช้ Cloudflare Worker เพื่อรับ Webhook จาก Github

Published on
Web Dev
cloudflare-worker-webhook-github-slack
Discord

สืบเนื่องมาจากผมใช้ giscus.app ที่เป็น Comment System โดยใช้ Github Discussions แต่ปัญหาคือ ผมไม่เคยได้ Notifications เวลามี activity ใน discussions เลย ทั้งที่ watch repository ไว้แล้ว เลยคิดว่า น่าจะต้องสร้าง Github Webhook โพสไป Slack น่าจะดี

สำหรับ Service Workers แนะนำ 2 เว็บนี้ เพื่ออ่าน พื้นฐาน เบื้องต้น

จริงๆ แล้ว ถ้าไม่ใช้ Github Webhook ก็สามารถใช้ Github App ใน Slack ได้เลย เพียงแค่ Add Github เข้า Channel แล้วกด subscribe ผ่าน command ได้เลย

/github subscribe user/repo discussions

แต่ประเด็นที่จะใช้ Github Webhook คือ เผื่อว่าเราจะไม่ได้ใช้แค่ Slack อาจจะให้ส่ง Email / ส่งเข้า Line / ส่งเข้า SMS หรือแล้วแต่เราต้องการ ก็ได้ เลยเป็นที่มาว่า ลองเขียนเป็น Cloudflare Worker ด้วยดีกว่า

สร้าง Cloudflare Worker

หน้า Cloudflare Dashboard เลือก Workers จากนั้นกด Create a Service ส่วน Select a starter จะเลือกเป็นอะไรก็ได้ เพราะสุดท้ายแล้ว เราก็จะ edit code อยู่ดี

ตัวอย่าง Worker ที่ response Hello World

หน้าตาแบบ Module Syntax

export default {
  async fetch(request, env) {
    return await handleRequest(request)
  }
}

async function handleRequest(request) {
  return new Response("Hello world")
}

หน้าตาแบบ Service Worker Syntax

addEventListener("fetch", event => {
  event.respondWith(handleRequest(event.request))
})

async function handleRequest(request) {
  return new Response("Hello world")
}

ใน Worker Service เราสามารถ เพิ่ม Environment variables ได้ โดยเลือก Service -> Settings -> Variables จากนั้น สามารถ access ได้เหมือนตัวแปร Global เลย

console.log(YOUR_VAR_NAME)

ในโค๊ดด้านล่าง จะเห็นมี GITHUB_SECRET และ SLACK_WEBHOOK_URL ก็คือ Environment variables ที่ผมเพิ่มไปนั่นเอง

handle event ของ Workers เริ่มต้นด้วย

addEventListener('fetch', (event) => {
  // your code here
})

เช็คว่า Request เป็น Method เป็น POST

const request = event.request

if (request.method.toUpperCase() === 'POST') {

}

การ return response ของ Worker

return event.responseWith(new Response('OK'))

// หรือ set status code
return event.respondWith(new Response('Unauthorized', { status: 403 }))

การเช็ค content-type ของ headers

const { headers } = request
headers.get('content-type')

การรับค่า POST Body เราจะใช้

const body = await request.json()

ตัว Worker สามารถใช้ fetch ได้ตรงๆเลย

const slackMessage = {
  text: 'Your message'
}

await fetch(SLACK_WEBHOOK_URL, {
  method: 'POST',
  body: JSON.stringify(slackMessage)
})

ตัว Webhook ของ Github จะส่ง X-Hub-Signature-256 มากับ Headers ด้วย ทำให้เราต้อง verify กับ GITHUB_SECRET ที่เราตั้ง เพื่อแน่ใจว่าคนที่ส่งข้อมูลมาเป็น Github จริงๆ

ใช้ req.payload และ GITHUB_SECRET เพื่อ verify ว่า hash ตรงกัน

// ตัว github ส่งมาคือ sha256=xxxx เลยต้อง substring เพื่อเอา sha256= ออก เหลือแต่ signature
const signatureStr = headers.get('X-Hub-Signature-256')?.substring(7)
const signature = hexToBytes(signatureStr)

const body = await request.json()
const bodyText = JSON.stringify(body)

const encoder = new TextEncoder()

const key = await crypto.subtle.importKey(
  'raw',
  encoder.encode(GITHUB_SECRET),
  { name: 'HMAC', hash: 'SHA-256' },
  false,
  ['verify']
)

const verified = await crypto.subtle.verify('HMAC', key, signature, encoder.encode(bodyText));

if (!verified) {
  return new Response('Invalid signature', { status: 403 });
}

จากข้อด้านบน เวลาที่ต้อง verify sha256 จำเป็นต้องใช้ ArrayBuffer / Buffer ก็เลยได้ utils function แบบนี้

ขอบคุณตัวอย่าง Source Code จากที่นี่

function hexToBytes(hex) {
  const bytes = new Uint8Array(hex.length / 2)
  for (let c = 0; c < hex.length; c += 2) {
      bytes[c / 2] = parseInt(hex.substr(c, 2), 16)
  }

  return bytes.buffer
}

Source Code ทั้งหมด ของ Worker

function hexToBytes(hex) {
  const bytes = new Uint8Array(hex.length / 2);
  for (let c = 0; c < hex.length; c += 2) {
    bytes[c / 2] = parseInt(hex.substr(c, 2), 16);
  }

  return bytes.buffer;
}

async function handlePostRequest(request) {
  const { headers } = request;
  const contentType = headers.get('content-type') || '';

  if (!contentType.includes('application/json')) {
    return new Response('Only JSON');
  }

  const body = await request.json();

  // verify that a request come from github
  // otherwise return 403

  let encoder = new TextEncoder();

  const signatureStr = headers.get('X-Hub-Signature-256')?.substring(7); // remove sha256= prefix
  const signature = hexToBytes(signatureStr);
  const bodyText = JSON.stringify(body);

  const key = await crypto.subtle.importKey(
    'raw',
    encoder.encode(GITHUB_SECRET),
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['verify']
  );

  const verified = await crypto.subtle.verify('HMAC', key, signature, encoder.encode(bodyText));

  if (!verified) {
    return new Response('Invalid signature', { status: 403 });
  }

  const slackMessage = {
    text: `Your message`
  };

  await fetch(SLACK_WEBHOOK_URL, {
    method: 'POST',
    body: JSON.stringify(slackMessage)
  });

  return new Response('OK');
}

addEventListener('fetch', (event) => {
  try {
    const request = event.request;
    if (request.method.toUpperCase() === 'POST')
      return event.respondWith(handlePostRequest(request));
    return event.respondWith(fetch(request));
  } catch (e) {
    return event.respondWith(new Response('Error thrown ' + e.message));
  }
});

เป็นครั้งแรกที่ลองใช้ Cloudflare Worker ซึ่งก่อนหน้านี้ใช้แค่เป็น Proxy ของ Plausible แต่ว่าไม่ได้อ่านโค๊ด Copy มาวางอย่างเดียว วันนี้ได้ลองเล่น ลองอ่าน Example และ Tutorial เบื้องต้น ถ้าเขียน JavaScript หรือ Node.js มาก่อน คิดว่าเข้าใจได้ไม่ยากครับ ทั้งหมด ผมโน๊ตไว้ที่ Repo ด้านล่างครับ

Buy Me A Coffee
Authors
Discord