Last active
June 28, 2025 08:12
-
-
Save mduc-dev/33d489a06ed9aa43ec6c445a7e911811 to your computer and use it in GitHub Desktop.
Cloudflare Worker: Forward Expo EAS Build Webhook to Lark
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| async function sendLarkRequest(webhookUrl, larkPayload) { | |
| try { | |
| const response = await fetch(webhookUrl, { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ | |
| msg_type: "interactive", | |
| card: larkPayload.card, | |
| }), | |
| }); | |
| return response; | |
| } catch (error) { | |
| console.error("Error sending Lark request:", error); | |
| throw error; | |
| } | |
| } | |
| function handleExpoStatus(body) { | |
| const getFields = () => [ | |
| { | |
| is_short: true, | |
| text: { | |
| tag: "lark_md", | |
| content: `**📂 Project:**\n${body.projectName}` | |
| }, | |
| }, | |
| { | |
| is_short: true, | |
| text: { | |
| tag: "lark_md", | |
| content: `**⚙️ Build Profile:**\n${body.metadata.buildProfile}`, | |
| }, | |
| }, | |
| { | |
| is_short: true, | |
| text: { | |
| tag: "lark_md", | |
| content: `**🏷️ App Version:**\n${body.metadata.appVersion}`, | |
| }, | |
| }, | |
| { | |
| is_short: true, | |
| text: { | |
| tag: "lark_md", | |
| content: `**🔖 Build Version:**\n${body.metadata.appBuildVersion || "N/A"}`, | |
| }, | |
| }, | |
| ]; | |
| const getActionElements = (hasDownload, downloadLabel, downloadUrl) => { | |
| const elements = [ | |
| { | |
| tag: "button", | |
| text: { tag: "plain_text", content: "🔍 Open Build Details Page" }, | |
| url: body.buildDetailsPageUrl, | |
| type: "default", | |
| }, | |
| ]; | |
| if (hasDownload) { | |
| elements.unshift({ | |
| tag: "button", | |
| text: { tag: "plain_text", content: downloadLabel }, | |
| url: downloadUrl, | |
| type: "primary", | |
| }); | |
| } | |
| return elements; | |
| }; | |
| if (body.platform === "ios" && body.status === "finished") { | |
| // const itmsUrl = `itms-services://?action=download-manifest;url=https://exp.host/--/api/v2/projects/${body.appId}/builds/${body.id}/manifest.plist`; | |
| // const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?data=${encodeURIComponent(itmsUrl)}&size=250x250&qzone=2`; | |
| return { | |
| card: { | |
| config: { wide_screen_mode: true }, | |
| header: { title: { tag: "plain_text", content: "[IOS] Build Completed Successfully", template: 'turquoise' } }, | |
| elements: [ | |
| { tag: "div", fields: getFields() }, | |
| { tag: "action", actions: getActionElements(true, "📦 Download IPA", body.artifacts.buildUrl) }, | |
| // { tag: "img", img_key: qrUrl, alt: { tag: "plain_text", content: "QR code" } }, //dong tam tag img do lark yeu cau upload anh truoc => sau do moi ra img_key | |
| ], | |
| }, | |
| }; | |
| } | |
| if (body.platform === "android" && body.status === "finished") { | |
| // const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?data=${encodeURIComponent(body.artifacts.buildUrl)}&size=250x250&qzone=2`; | |
| return { | |
| card: { | |
| config: { wide_screen_mode: true }, | |
| header: { title: { tag: "plain_text", content: "[Android] Build Completed Successfully", template: 'turquoise' } }, | |
| elements: [ | |
| { tag: "div", fields: getFields() }, | |
| { tag: "action", actions: getActionElements(true, "📦 Download APK", body.artifacts.buildUrl) }, | |
| // { tag: "img", img_key: qrUrl, alt: { tag: "plain_text", content: "QR code" } }, //dong tam tag img do lark yeu cau upload anh truoc => sau do moi ra img_key | |
| ], | |
| }, | |
| }; | |
| } | |
| if (body.status === "errored") { | |
| return { | |
| card: { | |
| config: { wide_screen_mode: true }, | |
| header: { title: { tag: "plain_text", content: `Build failed for ${body.platform.toUpperCase()} ❌`, template: 'red' } }, | |
| elements: [ | |
| { tag: "div", fields: getFields() }, | |
| { tag: "action", actions: getActionElements(false) }, | |
| ], | |
| }, | |
| }; | |
| } | |
| if (body.status === "canceled") { | |
| return { | |
| card: { | |
| config: { wide_screen_mode: true }, | |
| header: { title: { tag: "plain_text", content: `Build was canceled for ${body.platform.toUpperCase()} 🚫`, template: 'red' } }, | |
| elements: [ | |
| { | |
| tag: "div", | |
| fields: [ | |
| ...getFields(), | |
| { is_short: false, text: { tag: "lark_md", content: "**Reason:** User cancellation or timeout" } }, | |
| ], | |
| }, | |
| { tag: "action", actions: getActionElements(false) }, | |
| ], | |
| }, | |
| }; | |
| } | |
| } | |
| export default { | |
| async fetch(request, env) { | |
| // IMPORTANT NOTE: | |
| // Expo (or the webhook provider) generates the signature based on the original "raw minified" JSON string sent. | |
| // If you "read" the body multiple times (bodyUsed becomes true), or parse JSON then stringify again, | |
| // => the body string changes (different formatting, line breaks, etc.), | |
| // => the signature will no longer match. | |
| // | |
| // => Solution: Always read the "raw body" (as ArrayBuffer) immediately & use it directly for verification. | |
| const userAgent = request.headers.get('user-agent') || ''; | |
| if (/bot|crawl|spider|slurp|facebookexternalhit|mediapartners-google/i.test(userAgent)) { | |
| console.log('🛑 Blocked bot:', userAgent); | |
| return new Response('Access denied', { status: 403 }); | |
| } | |
| // Read the raw body (original minified JSON) immediately upon receiving the request | |
| const rawBody = await request.arrayBuffer(); | |
| // Get the signature header (Expo sends it as: sha1=<signature>) | |
| const signatureHeader = request.headers.get('expo-signature'); | |
| if (!signatureHeader) { | |
| console.error("❌ No signature provided"); | |
| return new Response('No signature provided', { status: 400 }); | |
| } | |
| // Your secret key (HMAC key) | |
| const secret = env.LARK_WEBHOOK; | |
| if (!secret) { | |
| console.error("❌ LARK_WEBHOOK not configured"); | |
| return new Response("Server configuration error", { status: 500 }); | |
| } | |
| // Remove the "sha1=" prefix if present | |
| const signature = signatureHeader.replace(/^sha1=/, '').trim(); | |
| // Compute HMAC-SHA1 using rawBody | |
| const key = await crypto.subtle.importKey( | |
| 'raw', | |
| new TextEncoder().encode(secret), | |
| { name: 'HMAC', hash: 'SHA-1' }, | |
| false, | |
| ['sign'] | |
| ); | |
| const expectedBuffer = await crypto.subtle.sign( | |
| 'HMAC', | |
| key, | |
| rawBody | |
| ); | |
| // Convert ArrayBuffer to hex string (to compare with signature header) | |
| const expectedSignature = Array.from(new Uint8Array(expectedBuffer)) | |
| .map(b => b.toString(16).padStart(2, '0')) | |
| .join(''); | |
| if (expectedSignature !== signature) { | |
| console.log('=== Signature Mismatch ==='); | |
| console.log('Expected:', expectedSignature); | |
| console.log('Actual:', signature); | |
| console.log('🔴 Lý do hay gặp:'); | |
| console.log('✅ Body bị đọc nhiều lần (bodyUsed = true)'); | |
| console.log('✅ Hoặc JSON bị format khác (not minify)'); | |
| return new Response('Invalid signature', { status: 401 }); | |
| } | |
| const rawText = new TextDecoder().decode(rawBody); | |
| // Signature is valid ✅ | |
| const body = JSON.parse(rawText); | |
| const larkPayload = handleExpoStatus(body); | |
| if (!larkPayload) { | |
| return new Response("No relevant status", { status: 400 }); | |
| } | |
| const webhookUrl = `https://open.larksuite.com/open-apis/bot/v2/hook/${env.LARK_WEBHOOK}`; | |
| await sendLarkRequest(webhookUrl, larkPayload); | |
| // Return a response | |
| return new Response('Webhook processed.', { status: 200 }); | |
| }, | |
| }; | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment