Add HMAC function, and update the CMS and Mailer services

A new module has been added to create HMACs, primarily used in the billing service for data verification. Keystatic CMS usage has been conditioned to Node.js runtime only, and a fallback to the mock CMS client has been implemented for Edge Runtime. Mailer services now accommodate environment-specific providers.
This commit is contained in:
giancarlo
2024-04-17 15:47:50 +08:00
parent d62bcad657
commit bf43c48dff
9 changed files with 107 additions and 30 deletions

View File

@@ -290,6 +290,7 @@ To deploy the application to Cloudflare, you need to do the following:
1. Opt-in to the Edge runtime
2. Using the Cloudflare Mailer
3. Install the Cloudflare CLI
4. Switching CMS
### 1. Opting in to the Edge runtime
@@ -318,3 +319,15 @@ Please follow [the Vercel Email documentation](https://github.com/Sh4yy/vercel-e
### 3. Installing the Cloudflare CLI
Please follow the instructions on the [Cloudflare documentation](https://github.com/cloudflare/next-on-pages/tree/main/packages/next-on-pages#3-deploy-your-application-to-cloudflare-pages) to install the Cloudflare CLI.
### 4. Switching CMS
By default, Makerkit uses Keystatic as a CMS. Keystatic's local mode (which relies on the file system) is not supported in the Edge runtime. Therefore, you will need to switch to another CMS.
At this time, the other CMS supported is WordPress. Set `CMS_CLIENT` to `wordpress` in the `apps/web/.env` file:
```
CMS_CLIENT=wordpress
```
More alternative CMS implementations will be added in the future.

View File

@@ -44,3 +44,6 @@ NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETION=true
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING=true
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS=true
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION=true
# MAIL
MAILER_PROVIDER=cloudflare

View File

@@ -17,3 +17,5 @@ EMAIL_PASSWORD=password
# STRIPE
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51K9cWKI1i3VnbZTq2HGstY2S8wt3peF1MOqPXFO4LR8ln2QgS7GxL8XyKaKLvn7iFHeqAnvdDw0o48qN7rrwwcHU00jOtKhjsf
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU

View File

@@ -11,11 +11,13 @@ export async function POST(request: Request) {
const provider = billingConfig.provider;
const logger = await getLogger();
logger.info(
{
const ctx = {
name: 'billing.webhook',
provider,
},
};
logger.info(
ctx,
`Received billing webhook. Processing...`,
);
@@ -32,9 +34,7 @@ export async function POST(request: Request) {
await service.handleWebhookEvent(request);
logger.info(
{
name: 'billing.webhook',
},
ctx,
`Successfully processed billing webhook`,
);
@@ -44,7 +44,7 @@ export async function POST(request: Request) {
logger.error(
{
name: 'billing',
...ctx,
error: JSON.stringify(error),
},
`Failed to process billing webhook`,

View File

@@ -1,18 +1,19 @@
import { getOrder, getVariant } from '@lemonsqueezy/lemonsqueezy.js';
import { createHmac, timingSafeEqual } from 'node:crypto';
import {
BillingConfig,
BillingWebhookHandlerService,
getLineItemTypeById,
} from '@kit/billing';
import { BillingConfig, BillingWebhookHandlerService, getLineItemTypeById } from '@kit/billing';
import { getLogger } from '@kit/shared/logger';
import { Database } from '@kit/supabase/database';
import { getLemonSqueezyEnv } from '../schema/lemon-squeezy-server-env.schema';
import { OrderWebhook } from '../types/order-webhook';
import SubscriptionWebhook from '../types/subscription-webhook';
import { SubscriptionWebhook } from '../types/subscription-webhook';
import { initializeLemonSqueezyClient } from './lemon-squeezy-sdk';
import { createHmac } from "./verify-hmac";
type UpsertSubscriptionParams =
Database['public']['Functions']['upsert_subscription']['Args'];
@@ -68,8 +69,10 @@ export class LemonSqueezyWebhookHandlerService
throw new Error('Signature header not found');
}
const isValid = await isSigningSecretValid(rawBody, signature);
// if the signature is invalid, throw an error
if (!isSigningSecretValid(Buffer.from(rawBody), signature)) {
if (!isValid) {
logger.error(
{
eventName,
@@ -411,12 +414,19 @@ function getISOString(date: number | null) {
return date ? new Date(date).toISOString() : undefined;
}
function isSigningSecretValid(rawBody: Buffer, signatureHeader: string) {
async function isSigningSecretValid(rawBody: string, signatureHeader: string) {
const { webhooksSecret } = getLemonSqueezyEnv();
const hmac = createHmac('sha256', webhooksSecret);
const digest = Buffer.from(hmac.update(rawBody).digest('hex'), 'utf8');
const { hex: digest } = await createHmac({
key: webhooksSecret,
data: rawBody
});
const signature = Buffer.from(signatureHeader, 'utf8');
return timingSafeEqual(digest, signature);
}
function timingSafeEqual(digest: string, signature: Buffer) {
return digest.toString() === signature.toString();
}

View File

@@ -0,0 +1,33 @@
function bufferToHex(buffer: ArrayBuffer) {
return Array.from(new Uint8Array(buffer))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
export async function createHmac({ key, data }: { key: string; data: string }) {
const encoder = new TextEncoder();
const crypto = new Crypto();
const encodedKey = encoder.encode(key);
const encodedData = encoder.encode(data);
const hmacKey = await crypto.subtle.importKey(
'raw',
encodedKey,
{
name: 'HMAC',
hash: 'SHA-256',
},
true,
['sign', 'verify'],
);
const signature = await window.crypto.subtle.sign(
'HMAC',
hmacKey,
encodedData,
);
const hex = bufferToHex(signature);
return { hex };
}

View File

@@ -1,10 +1,8 @@
interface SubscriptionWebhookResponse {
export interface SubscriptionWebhook {
meta: Meta;
data: Data;
}
export default SubscriptionWebhookResponse;
interface Data {
type: string;
id: string;

View File

@@ -34,7 +34,21 @@ async function getWordpressClient() {
}
async function getKeystaticClient() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
const { KeystaticClient } = await import('../../keystatic/src/client');
return new KeystaticClient();
}
console.error(`[CMS] Keystatic client is only available in Node.js runtime. Please choose a different CMS client. Returning a mock client instead of throwing an error.`);
return mockCMSClient();
}
function mockCMSClient() {
return {
async getContentItems() {
return [];
},
};
}

View File

@@ -9,11 +9,15 @@ const MAILER_PROVIDER = z
* @description Get the mailer based on the environment variable.
*/
export async function getMailer() {
switch (MAILER_PROVIDER) {
switch (process.env.MAILER_PROVIDER as typeof MAILER_PROVIDER) {
case 'nodemailer': {
if (process.env.NEXT_RUNTIME !== 'edge') {
const { Nodemailer } = await import('./impl/nodemailer');
return new Nodemailer();
} else {
throw new Error('Nodemailer is not available on the edge runtime side');
}
}
case 'cloudflare': {