mirror of
https://github.com/tiennm99/claude-status-webhook.git
synced 2026-04-17 15:20:37 +00:00
refactor: per-subscriber KV keys, HMAC verification, cron trigger
Major refactor addressing scalability, security, and reliability:
- KV schema: single-key → per-subscriber keys (sub:{chatId}:{threadId})
eliminates read-modify-write race conditions
- Component-specific subscriptions: /subscribe component <name>
- HMAC-SHA256 webhook verification with URL secret fallback
- Cron trigger (every 5 min) polls status.claude.com as safety net
- Shared telegram-api.js module (DRY fix)
- Error logging in all catch blocks
- Migration endpoint for existing subscribers
- Setup moved to standalone script (scripts/setup-bot.js)
- Removed setup HTTP route to reduce attack surface
This commit is contained in:
@@ -1,6 +1,49 @@
|
||||
import { getSubscribersByType } from "./kv-store.js";
|
||||
import { humanizeStatus, escapeHtml } from "./status-fetcher.js";
|
||||
|
||||
/**
|
||||
* Convert hex string to Uint8Array
|
||||
*/
|
||||
function hexToBytes(hex) {
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < hex.length; i += 2) {
|
||||
bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify Statuspage HMAC-SHA256 signature
|
||||
*/
|
||||
async function verifyHmacSignature(request, hmacKey) {
|
||||
if (!hmacKey) return false;
|
||||
const signature = request.headers.get("X-Statuspage-Signature");
|
||||
if (!signature) return false;
|
||||
|
||||
const body = await request.clone().arrayBuffer();
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
new TextEncoder().encode(hmacKey),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["verify"]
|
||||
);
|
||||
|
||||
const sigBytes = hexToBytes(signature);
|
||||
return crypto.subtle.verify("HMAC", key, sigBytes, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Timing-safe string comparison
|
||||
*/
|
||||
async function timingSafeEqual(a, b) {
|
||||
const encoder = new TextEncoder();
|
||||
const bufA = encoder.encode(a);
|
||||
const bufB = encoder.encode(b);
|
||||
if (bufA.byteLength !== bufB.byteLength) return false;
|
||||
return crypto.subtle.timingSafeEqual(bufA, bufB);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format incident event as Telegram HTML message
|
||||
*/
|
||||
@@ -36,13 +79,13 @@ function formatComponentMessage(component, update) {
|
||||
* Handle incoming Statuspage webhook
|
||||
*/
|
||||
export async function handleStatuspageWebhook(c) {
|
||||
// Validate secret (timing-safe comparison)
|
||||
const secret = c.req.param("secret");
|
||||
const encoder = new TextEncoder();
|
||||
const a = encoder.encode(secret);
|
||||
const b = encoder.encode(c.env.WEBHOOK_SECRET);
|
||||
if (a.byteLength !== b.byteLength || !crypto.subtle.timingSafeEqual(a, b)) {
|
||||
return c.text("Unauthorized", 401);
|
||||
// Try HMAC verification first, fall back to URL secret
|
||||
const hmacValid = await verifyHmacSignature(c.req.raw, c.env.STATUSPAGE_HMAC_KEY);
|
||||
if (!hmacValid) {
|
||||
const secret = c.req.param("secret");
|
||||
if (!await timingSafeEqual(secret, c.env.WEBHOOK_SECRET)) {
|
||||
return c.text("Unauthorized", 401);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse body
|
||||
@@ -56,20 +99,23 @@ export async function handleStatuspageWebhook(c) {
|
||||
const eventType = body?.meta?.event_type;
|
||||
if (!eventType) return c.text("Bad Request", 400);
|
||||
|
||||
console.log(`Statuspage webhook: ${eventType}`);
|
||||
|
||||
// Determine category and format message
|
||||
let category, html;
|
||||
let category, html, componentName;
|
||||
if (eventType.startsWith("incident.")) {
|
||||
category = "incident";
|
||||
html = formatIncidentMessage(body.incident);
|
||||
} else if (eventType.startsWith("component.")) {
|
||||
category = "component";
|
||||
componentName = body.component?.name || null;
|
||||
html = formatComponentMessage(body.component, body.component_update);
|
||||
} else {
|
||||
return c.text("Unknown event type", 400);
|
||||
}
|
||||
|
||||
// Get filtered subscribers
|
||||
const subscribers = await getSubscribersByType(c.env.claude_status, category);
|
||||
// Get filtered subscribers (with component name filtering)
|
||||
const subscribers = await getSubscribersByType(c.env.claude_status, category, componentName);
|
||||
|
||||
// Enqueue messages for fan-out via CF Queues (batch for performance)
|
||||
const messages = subscribers.map(({ chatId, threadId }) => ({
|
||||
@@ -79,5 +125,7 @@ export async function handleStatuspageWebhook(c) {
|
||||
await c.env["claude-status"].sendBatch(messages.slice(i, i + 100));
|
||||
}
|
||||
|
||||
console.log(`Enqueued ${messages.length} messages for ${category}${componentName ? `:${componentName}` : ""}`);
|
||||
|
||||
return c.text("OK", 200);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user