UNPKG

@papra/webhooks

Version:

Webhooks helper library for Papra, the document archiving platform.

180 lines (173 loc) 5.3 kB
import { EventEmitter } from "tsee"; import { createId } from "@paralleldrive/cuid2"; import { ofetch } from "ofetch"; //#region src/handler/handler.errors.ts function createInvalidSignatureError() { return Object.assign(/* @__PURE__ */ new Error("[Papra Webhooks] Invalid signature"), { code: "webhook.invalid_signature" }); } function createUnsupportedSignatureVersionError() { return Object.assign(/* @__PURE__ */ new Error("[Papra Webhooks] Unsupported signature version, supported versions are \"v1\""), { code: "webhook.unsupported_signature_version" }); } function createInvalidSignatureFormatError() { return Object.assign(/* @__PURE__ */ new Error("[Papra Webhooks] Invalid signature format, unprocessable signature"), { code: "webhook.invalid_signature_format" }); } //#endregion //#region src/signature.ts const WEBHOOK_SIGNATURE_HMAC_VERSION = "v1"; function arrayBufferToBase64(arrayBuffer) { return btoa(String.fromCharCode(...new Uint8Array(arrayBuffer))); } function base64ToArrayBuffer(base64) { return new Uint8Array(atob(base64).split("").map((char) => char.charCodeAt(0))).buffer; } function createSignaturePayload({ serializedPayload, webhookId, timestamp }) { return `${webhookId}.${timestamp}.${serializedPayload}`; } async function hmacSign({ secret, payload }) { const encoder = new TextEncoder(); const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); return crypto.subtle.sign("HMAC", key, encoder.encode(payload)); } async function signBody({ serializedPayload, webhookId, timestamp, secret }) { const payload = createSignaturePayload({ serializedPayload, webhookId, timestamp }); const rawSignature = await hmacSign({ secret, payload }); const signatureBase64 = arrayBufferToBase64(rawSignature); const signature = `${WEBHOOK_SIGNATURE_HMAC_VERSION},${signatureBase64}`; return { signature }; } async function verifySignature({ serializedPayload, webhookId, timestamp, signature: base64Signature, secret }) { const [version, signature] = base64Signature.split(",", 2); if (!signature || !version) throw createInvalidSignatureFormatError(); if (version !== WEBHOOK_SIGNATURE_HMAC_VERSION) throw createUnsupportedSignatureVersionError(); const payload = createSignaturePayload({ serializedPayload, webhookId, timestamp }); const signatureBuffer = base64ToArrayBuffer(signature); const encoder = new TextEncoder(); const keyData = encoder.encode(secret); const key = await crypto.subtle.importKey("raw", keyData, { name: "HMAC", hash: "SHA-256" }, false, ["verify"]); return crypto.subtle.verify("HMAC", key, signatureBuffer, encoder.encode(payload)); } //#endregion //#region src/webhooks.models.ts function serializeBody({ now = /* @__PURE__ */ new Date(), payload, event }) { const body = { data: payload, type: event, timestamp: now.toISOString() }; return JSON.stringify(body); } function parseBody(body) { return JSON.parse(body); } //#endregion //#region src/handler/handler.services.ts function handleError({ error }) { if (error) throw error; throw createInvalidSignatureError(); } function createWebhooksHandler({ secret, onError = handleError }) { const eventEmitter = new EventEmitter(); return { on: eventEmitter.on, ee: eventEmitter, handle: async ({ body, signature, webhookId, timestamp }) => { try { const isValid = await verifySignature({ serializedPayload: body, signature, secret, webhookId, timestamp }); if (!isValid) throw createInvalidSignatureError(); const parsedBody = parseBody(body); const { type } = parsedBody; eventEmitter.emit(type, parsedBody); eventEmitter.emit("*", parsedBody); } catch (error) { await onError({ body, signature, webhookId, timestamp, error }); } } }; } //#endregion //#region src/webhooks.constants.ts const EVENT_NAMES = [ "document:created", "document:deleted", "document:updated", "document:tag:added", "document:tag:removed" ]; //#endregion //#region src/webhooks.services.ts async function webhookHttpClient({ url,...options }) { const response = await ofetch.raw(url, { ...options, ignoreResponseError: true }); return { responseStatus: response.status, responseData: response._data }; } async function triggerWebhook({ webhookUrl, webhookSecret, httpClient = webhookHttpClient, now = /* @__PURE__ */ new Date(), payload, event, webhookId = `msg_${createId()}` }) { const timestamp = Math.floor(now.getTime() / 1e3).toString(); const headers = { "user-agent": "papra-webhook-client", "content-type": "application/json", "webhook-id": webhookId, "webhook-timestamp": timestamp }; const body = serializeBody({ event, payload, now }); if (webhookSecret) { const { signature } = await signBody({ serializedPayload: body, webhookId, timestamp, secret: webhookSecret }); headers["webhook-signature"] = signature; } const { responseData, responseStatus } = await httpClient({ url: webhookUrl, method: "POST", body, headers }); return { responseData, responseStatus, requestPayload: body }; } //#endregion export { EVENT_NAMES, createWebhooksHandler, triggerWebhook }; //# sourceMappingURL=index.js.map