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

สืบเนื่องมาจากผมใช้ giscus.app ที่เป็น Comment System โดยใช้ Github Discussions แต่ปัญหาคือ ผมไม่เคยได้ Notifications เวลามี activity ใน discussions เลย ทั้งที่ watch repository ไว้แล้ว เลยคิดว่า น่าจะต้องสร้าง Github Webhook โพสไป Slack น่าจะดี
สำหรับ Service Workers แนะนำ 2 เว็บนี้ เพื่ออ่าน พื้นฐาน เบื้องต้น
- Cloudflare Workers Docs - Docs และ Examples Workers.
- Cloudflare Playground - เอาไว้สำหรับลองเล่น Cloudflare Worker บนเว็บ
จริงๆ แล้ว ถ้าไม่ใช้ 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 codereturn event.respondWith(new Response('Unauthorized', { status: 403 }))
การเช็ค content-type
ของ headers
const { headers } = requestheaders.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= ออก เหลือแต่ signatureconst 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 ด้านล่างครับ
- Authors
-
Chai Phonbopit
เป็น Web Dev ในบริษัทแห่งหนึ่ง ทำงานมา 10 ปีกว่าๆ ด้วยภาษาและเทคโนโลยี เช่น JavaScript, Node.js, React, Vue และปัจจุบันกำลังสนใจในเรื่องของ Blockchain และ Crypto กำลังหัดเรียนภาษา Rust