příklady / integrace

GitHub webhook

Ověřuje podpisy GitHub webhooků a ukládá doručení do databáze.

libsqlsecretshmac

Přijímač webhooků, který před důvěrou v požadavek ověří GitHub podpis HMAC SHA-256. GitHub podepisuje každé doručení vaším webhook secretem a posílá jej v hlavičce X-Hub-Signature-256; tato služba podpis znovu spočítá nad surovým tělem požadavku a porovná jej v konstantním čase, takže nepodepsaný nebo pozměněný payload odmítne s kódem 401.

Ověřená doručení se ukládají do propojené libsql databáze (typ události, ID doručení a čas) a GET požadavek vykreslí ta nejnovější. Webhook secret se předává jako secret WEBHOOK_SECRET a databáze se při nasazení automaticky připojí jako DELIVERIES_DB.

kód
main.ts
import { createClient } from "npm:@libsql/client@0.14.0/web";

// Receive GitHub webhooks, verify their signature, and keep a log of recent
// deliveries in a libsql database.
//
// GitHub signs every delivery with your webhook secret using HMAC SHA-256 and
// sends it in the `X-Hub-Signature-256: sha256=<hex>` header. We recompute the
// signature over the RAW request body and compare it in constant time, then
// record the delivery. Configure the WEBHOOK_SECRET secret with the same value
// you set in the repository's webhook settings.

// Read an environment variable, treating an unset or unreadable one as
// undefined. Frontback scopes each service's env access to its declared secrets, so
// a name that was never configured simply reads as "not set".
function readEnv(name: string): string | undefined {
  try {
    return Deno.env.get(name);
  } catch {
    return undefined;
  }
}

// Open the deliveries database and make sure its table exists. The deploy links
// a dedicated database as DELIVERIES_DB; if that link is absent the service falls
// back to its own built-in database (DATABASE_URL).
async function openDb() {
  const url = readEnv("DELIVERIES_DB") ?? readEnv("DATABASE_URL");
  if (!url) throw new Error("no database URL in the environment");
  const db = createClient({ url });
  await db.execute(
    `CREATE TABLE IF NOT EXISTS deliveries (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      delivery_id TEXT,
      event TEXT,
      received_at TEXT NOT NULL
    )`,
  );
  return db;
}

// Recompute the HMAC SHA-256 of the raw body and compare it to the signature
// header in constant time.
async function verifySignature(secret: string, rawBody: string, header: string): Promise<boolean> {
  const enc = new TextEncoder();
  const key = await crypto.subtle.importKey(
    "raw",
    enc.encode(secret),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign"],
  );
  const mac = await crypto.subtle.sign("HMAC", key, enc.encode(rawBody));
  let hex = "";
  for (const byte of new Uint8Array(mac)) hex += byte.toString(16).padStart(2, "0");
  return timingSafeEqual("sha256=" + hex, header);
}

// Constant-time string comparison: differing lengths are never equal, and equal
// lengths are compared without an early return so timing cannot leak the answer.
function timingSafeEqual(a: string, b: string): boolean {
  if (a.length !== b.length) return false;
  let diff = 0;
  for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
  return diff === 0;
}

// Escape text before placing it into the HTML page.
function escapeHtml(value: string): string {
  return value
    .replaceAll("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;");
}

export default async (req: Request): Promise<Response> => {
  // POST: a webhook delivery. Verify the signature BEFORE touching the database
  // so an unsigned request cannot make us do any work.
  if (req.method === "POST") {
    const secret = readEnv("WEBHOOK_SECRET");
    if (!secret) return Response.json({ error: "WEBHOOK_SECRET is not set" }, { status: 500 });

    // Read the RAW body exactly as received; the HMAC is computed over these bytes.
    const rawBody = await req.text();
    const header = req.headers.get("x-hub-signature-256") ?? "";
    if (!(await verifySignature(secret, rawBody, header))) {
      // An unsigned or tampered delivery is an expected rejection, so answer 401
      // rather than a 5xx.
      return Response.json({ error: "invalid signature" }, { status: 401 });
    }

    const event = req.headers.get("x-github-event") ?? "unknown";
    const deliveryId = req.headers.get("x-github-delivery") ?? "";
    const db = await openDb();
    await db.execute({
      sql: "INSERT INTO deliveries (delivery_id, event, received_at) VALUES (?, ?, datetime('now'))",
      args: [deliveryId, event],
    });
    return Response.json({ ok: true, event });
  }

  // GET: render the most recent verified deliveries.
  const db = await openDb();
  const result = await db.execute(
    "SELECT delivery_id, event, received_at FROM deliveries ORDER BY id DESC LIMIT 20",
  );
  const rows = result.rows
    .map((row) => {
      const cells = [row.received_at, row.event, row.delivery_id]
        .map((cell) => "<td>" + escapeHtml(String(cell ?? "")) + "</td>")
        .join("");
      return "<tr>" + cells + "</tr>";
    })
    .join("");
  const tableBody = rows || `<tr><td colspan="3">No deliveries yet.</td></tr>`;
  const html = `<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>GitHub webhook deliveries</title>
  <style>
    body { font: 16px/1.5 system-ui, sans-serif; max-width: 48rem; margin: 3rem auto; padding: 0 1rem; }
    table { border-collapse: collapse; width: 100%; }
    th, td { text-align: left; padding: 0.4rem 0.6rem; border-bottom: 1px solid #e4e4e7; }
    code { background: #f4f4f5; padding: 0.1rem 0.3rem; border-radius: 3px; }
  </style>
</head>
<body>
  <h1>GitHub webhook</h1>
  <p>Point a repository webhook at this URL and set its secret as <code>WEBHOOK_SECRET</code>. Verified deliveries appear below.</p>
  <table>
    <thead><tr><th>Received</th><th>Event</th><th>Delivery</th></tr></thead>
    <tbody>${tableBody}</tbody>
  </table>
</body>
</html>`;
  return new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } });
};

Go beyond what seems possible.