|
1 | | -import type { Crypto } from "@cloudflare/workers-types/experimental"; |
2 | | - |
3 | 1 | import type { Route } from "./+types/shopify.webhooks"; |
4 | 2 | import { createShopify } from "~/shopify.server"; |
5 | 3 |
|
6 | 4 | export async function action({ context, request }: Route.ActionArgs) { |
7 | | - const shopify = createShopify(context); |
8 | | - |
9 | | - // validate.body |
10 | | - const body = await request.text(); |
11 | | - if (body.length === 0) { |
12 | | - return new Response("Webhook body is missing", { status: 400 }); |
13 | | - } |
| 5 | + try { |
| 6 | + const shopify = createShopify(context); |
| 7 | + shopify.utils.log.debug("shopify.webhooks"); |
14 | 8 |
|
15 | | - // validate.hmac |
16 | | - const header = request.headers.get("X-Shopify-Hmac-Sha256"); |
17 | | - if (header === null) { |
18 | | - return new Response("Webhook header is missing", { status: 400 }); |
19 | | - } |
| 9 | + const webhook = await shopify.webhook(request); |
| 10 | + shopify.utils.log.debug("shopify.webhooks", { ...webhook }); |
20 | 11 |
|
21 | | - const encoder = new TextEncoder(); |
22 | | - const encodedKey = encoder.encode(shopify.config.apiSecretKey); |
23 | | - const encodedData = encoder.encode(body); |
24 | | - const hmacKey = await crypto.subtle.importKey( |
25 | | - "raw", |
26 | | - encodedKey, |
27 | | - { |
28 | | - name: "HMAC", |
29 | | - hash: "SHA-256", |
30 | | - }, |
31 | | - true, |
32 | | - ["sign", "verify"], |
33 | | - ); |
34 | | - const signature = await crypto.subtle.sign("HMAC", hmacKey, encodedData); |
35 | | - const hmac = btoa(String.fromCharCode(...new Uint8Array(signature))); // base64 |
| 12 | + const session = await shopify.session.get(webhook.domain); |
36 | 13 |
|
37 | | - const encodedBody = encoder.encode(hmac); |
38 | | - const encodedHeader = encoder.encode(header); |
39 | | - if (encodedBody.byteLength !== encodedHeader.byteLength) { |
40 | | - return new Response("Encoded byte length mismatch", { status: 401 }); |
41 | | - } |
42 | | - |
43 | | - const valid = (crypto as Crypto).subtle.timingSafeEqual( |
44 | | - encodedBody, |
45 | | - encodedHeader, |
46 | | - ); |
47 | | - if (!valid) { |
48 | | - return new Response("Invalid hmac", { status: 401 }); |
49 | | - } |
| 14 | + switch (webhook.topic) { |
| 15 | + // app |
| 16 | + case "APP_UNINSTALLED": { |
| 17 | + if (!session) { |
| 18 | + break; |
| 19 | + } |
| 20 | + await shopify.session.delete(session.id); |
50 | 21 |
|
51 | | - // validate.headers |
52 | | - const requiredHeaders = { |
53 | | - apiVersion: "X-Shopify-API-Version", |
54 | | - domain: "X-Shopify-Shop-Domain", |
55 | | - hmac: "X-Shopify-Hmac-Sha256", |
56 | | - topic: "X-Shopify-Topic", |
57 | | - webhookId: "X-Shopify-Webhook-Id", |
58 | | - }; |
59 | | - if ( |
60 | | - !Object.values(requiredHeaders).every((header) => |
61 | | - request.headers.has(header), |
62 | | - ) |
63 | | - ) { |
64 | | - return new Response("Webhook headers are missing", { status: 400 }); |
65 | | - } |
66 | | - const optionalHeaders = { subTopic: "X-Shopify-Sub-Topic" }; |
67 | | - const headers = { ...requiredHeaders, ...optionalHeaders }; |
68 | | - const webhook = Object.values(headers).reduce( |
69 | | - (headers, header) => ({ |
70 | | - ...headers, |
71 | | - [header]: request.headers.get(header), |
72 | | - }), |
73 | | - {} as typeof headers, |
74 | | - ); |
75 | | - shopify.utils.log.debug("shopify.webhooks", { ...webhook }); |
76 | | - |
77 | | - const session = await shopify.session.get(webhook.domain); |
78 | | - |
79 | | - switch (webhook.topic) { |
80 | | - // app |
81 | | - case "APP_UNINSTALLED": { |
82 | | - if (!session) { |
83 | 22 | break; |
84 | 23 | } |
85 | | - await shopify.session.delete(session.id); |
86 | | - |
87 | | - break; |
| 24 | + case "APP_PURCHASES_ONE_TIME_UPDATE": |
| 25 | + case "APP_SUBSCRIPTIONS_APPROACHING_CAPPED_AMOUNT": |
| 26 | + case "APP_SUBSCRIPTIONS_UPDATE": |
| 27 | + |
| 28 | + // compliance |
| 29 | + case "CUSTOMERS_DATA_REQUEST": // eslint-disable-line no-fallthrough |
| 30 | + case "CUSTOMERS_REDACT": |
| 31 | + case "SHOP_REDACT": |
| 32 | + break; |
88 | 33 | } |
89 | | - case "APP_PURCHASES_ONE_TIME_UPDATE": |
90 | | - case "APP_SUBSCRIPTIONS_APPROACHING_CAPPED_AMOUNT": |
91 | | - case "APP_SUBSCRIPTIONS_UPDATE": |
92 | 34 |
|
93 | | - // compliance |
94 | | - case "CUSTOMERS_DATA_REQUEST": // eslint-disable-line no-fallthrough |
95 | | - case "CUSTOMERS_REDACT": |
96 | | - case "SHOP_REDACT": |
97 | | - break; |
| 35 | + return new Response(undefined, { status: 204 }); |
| 36 | + } catch (error: any /* eslint-disable-line @typescript-eslint/no-explicit-any */) { |
| 37 | + return new Response(error.message, { |
| 38 | + status: error.status, |
| 39 | + statusText: "Unauthorized", |
| 40 | + }); |
98 | 41 | } |
99 | | - |
100 | | - return new Response(undefined, { status: 204 }); |
101 | 42 | } |
0 commit comments