@papra/webhooks
Version:
Webhooks helper library for Papra, the document archiving platform.
180 lines (173 loc) • 5.3 kB
JavaScript
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