diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 8294cbb..d5673cb 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -12,11 +12,12 @@ import { cors } from "@elysiajs/cors"; import { Elysia, NotFoundError, status } from "elysia"; import { PrintfulClient, + PrintfulError, ProductSyncer, WebflowClient, + WebflowError, Printful, Webflow, - FetchError, } from "@blade-and-brawn/commerce"; import zipcodesUs from "zipcodes-us"; import z from "zod"; @@ -66,14 +67,18 @@ export const app = new Elysia() ) .error({ - FetchError, + PrintfulError, + WebflowError, }) - .onError(async ({ code, error }) => { + .onError(({ code, error }) => { switch (code) { - case "FetchError": - console.error(error.message, await error.parse()); - return error; + case "PrintfulError": + case "WebflowError": + console.error(error.message, error.status, error.payload); + return status(502, { error: error.message }); + default: + console.error(error); } }) @@ -130,36 +135,35 @@ export const app = new Elysia() .post("/", async ({ }) => { const syncState = await productSyncer.getState(); if (syncState.isSyncing) - return status(409, { error: "Sync already in progress" }); + throw status(409, "Sync already in progress"); await productSyncer.sync(); - return { ok: true }; }) // Per-product sync .post("/:printfulProductId", async ({ params }) => { const syncState = await productSyncer.getState(); if (syncState.isSyncing) - return status(409, { error: "Sync already in progress" }); + throw status(409, "Sync already in progress"); await productSyncer.sync(params.printfulProductId); - return { ok: true }; - }, { params: z.object({ printfulProductId: z.number() }) }), + }, { params: z.object({ printfulProductId: z.coerce.number() }) }), ) // TODO: add schema validation .get("/:printfulProductId", async ({ params }) => { - const printfulProductId = +params.printfulProductId; const printfulProduct = - await printful.Products.get(printfulProductId); - if (!printfulProduct) return new NotFoundError(); + await printful.Products.get(params.printfulProductId); + if (!printfulProduct) throw new NotFoundError("Missing printful product"); const webflowProductId = printfulProduct.sync_product.external_id.split("-")[0]; - if (!webflowProductId) return new NotFoundError(); + if (!webflowProductId) throw new NotFoundError("Missing webflow product ID"); const webflowProduct = await webflow.Products.get(webflowProductId); return { printfulProduct, webflowProduct }; + }, { + params: z.object({ printfulProductId: z.coerce.number() }) }), ) @@ -194,14 +198,10 @@ export const app = new Elysia() break; } } - - return { ok: true }; }) - .post("/webhook/webflow", async ({ request, body, set }) => { - if (!webflow.Util.verifyWebflowSignature(request, body)) { - set.status = 400; - return "Invalid signature"; - } + .post("/webhook/webflow", async ({ request, body }) => { + if (!webflow.Util.verifyWebflowSignature(request, body)) + throw status(400, "Invalid signature"); const payload = body as Webflow.Webhook.EventPayload; @@ -237,8 +237,6 @@ export const app = new Elysia() break; } } - - return { ok: true }; }); app.listen(3000); diff --git a/packages/commerce/src/printful.ts b/packages/commerce/src/printful.ts index ceea113..f5726db 100644 --- a/packages/commerce/src/printful.ts +++ b/packages/commerce/src/printful.ts @@ -1,5 +1,8 @@ import type { Printful } from "./util/types"; -import { FetchError, type DeepPartial } from "./util/misc"; +import { type DeepPartial } from "./util/misc"; + +const parsePayload = (res: Response): Promise => + res.json().catch(() => res.text().catch(() => undefined)); type ClientOptions = { token: string; @@ -11,6 +14,17 @@ type Credentials = { authHeaders: Record; }; +export class PrintfulError extends Error { + constructor( + message: string, + public status: number, + public payload: unknown, + ) { + super(message); + this.name = "PrintfulError"; + } +} + class VariantsClient { constructor(private creds: Credentials) { } @@ -20,7 +34,7 @@ class VariantsClient { headers: this.creds.authHeaders, }); if (res.status === 404 || res.status === 400) return; - if (!res.ok) throw new FetchError("Failed to get Printful variant", res); + if (!res.ok) throw new PrintfulError("Failed to get Printful variant", res.status, await parsePayload(res)); const payload = (await res.json()) as Printful.Products.MetaDataSingle; return payload.result; } @@ -40,7 +54,7 @@ class ProductsClient { method: "GET", headers: this.creds.authHeaders, }); - if (!res.ok) throw new FetchError("Failed to get all Printful products", res); + if (!res.ok) throw new PrintfulError("Failed to get all Printful products", res.status, await parsePayload(res)); const payload = (await res.json()) as Printful.Products.MetaDataMulti; allSyncProducts.push(...payload.result); offset = allSyncProducts.length; @@ -55,7 +69,7 @@ class ProductsClient { headers: this.creds.authHeaders, }); if (res.status === 404 || res.status === 400) return; - if (!res.ok) throw new FetchError("Failed to get Printful product", res); + if (!res.ok) throw new PrintfulError("Failed to get Printful product", res.status, await parsePayload(res)); const payload = (await res.json()) as Printful.Products.MetaDataSingle; return payload.result; } @@ -69,7 +83,7 @@ class ProductsClient { }, body: JSON.stringify(printfulProduct), }); - if (!res.ok) throw new FetchError("Failed to update Printful product", res); + if (!res.ok) throw new PrintfulError("Failed to update Printful product", res.status, await parsePayload(res)); } } @@ -85,7 +99,7 @@ class OrdersClient { }, body: JSON.stringify(printfulOrder), }); - if (!res.ok) throw new FetchError("Failed to create printful order", res); + if (!res.ok) throw new PrintfulError("Failed to create Printful order", res.status, await parsePayload(res)); } async getAll(offset = 0): Promise { @@ -95,7 +109,7 @@ class OrdersClient { method: "GET", headers: this.creds.authHeaders, }); - if (!res.ok) throw new FetchError("Failed to get all Printful orders", res); + if (!res.ok) throw new PrintfulError("Failed to get all Printful orders", res.status, await parsePayload(res)); const payload = (await res.json()) as Printful.Products.MetaDataMulti; allPrintfulOrders.push(...payload.result); offset = allPrintfulOrders.length; diff --git a/packages/commerce/src/sync.ts b/packages/commerce/src/sync.ts index e64aea3..7c6293b 100644 --- a/packages/commerce/src/sync.ts +++ b/packages/commerce/src/sync.ts @@ -1,5 +1,5 @@ import type { Printful, Webflow } from "./util/types"; -import { FetchError, formatSlug, type DeepPartial } from "./util/misc"; +import { formatSlug, type DeepPartial } from "./util/misc"; import { WebflowClient } from "./webflow"; import { PrintfulClient } from "./printful"; import { redis } from "bun"; @@ -31,12 +31,12 @@ export class ProductSyncer { ) { } async sync(printfulProductId?: number) { - await this.setState({ isSyncing: true, syncingIds: [] }); - - // Populate products - const mainPrintfulProducts: MainProduct[] = []; - const webflowProducts: Webflow.Products.ProductAndSkus[] = []; try { + await this.setState({ isSyncing: true, syncingIds: [] }); + + // Populate products + const mainPrintfulProducts: MainProduct[] = []; + const webflowProducts: Webflow.Products.ProductAndSkus[] = []; let printfulProducts = await this.printful.Products.getAll(); if (printfulProductId) { const printfulProduct = printfulProducts.find( @@ -82,17 +82,12 @@ export class ProductSyncer { }); mainPrintfulProduct.colors.add(productColor); } - } catch (error) { - await this.setState({ isSyncing: false, syncingIds: [] }); - throw error; - } - // TODO: Validate main printful products + // TODO: Validate main printful products - // Sync the main printful products - console.log("Syncing main products..."); - for (const mainPrintfulProduct of mainPrintfulProducts) { - try { + // Sync the main printful products + console.log("Syncing main products..."); + for (const mainPrintfulProduct of mainPrintfulProducts) { await this.setState({ isSyncing: true, syncingIds: mainPrintfulProduct.variants.map( @@ -161,9 +156,8 @@ export class ProductSyncer { const webflowProductId = mainPrintfulProduct.externalId.split("-")[0]; - if (!webflowProductId) { + if (!webflowProductId) throw new Error("Malformed printful product ID"); - } await this.webflow.Products.update(webflowProductId, { product: { fieldData: { @@ -254,10 +248,8 @@ export class ProductSyncer { ); existingWebflowProduct = await this.webflow.Products.get(webflowProductId); - if (!existingWebflowProduct) { - console.error("Missing webflow product"); - throw new Error("Failed to create Webflow product"); - } + if (!existingWebflowProduct) + throw new Error("Webflow product missing after create"); for (const specialVariant of mainPrintfulProduct.variants) { const newPrintfulVariants: DeepPartial[] = @@ -289,14 +281,16 @@ export class ProductSyncer { ); } } - } catch (err) { - if (err instanceof FetchError) { - console.error(err.message, err.payload); - } + } + console.log("Done"); + } + finally { + try { + await this.setState({ isSyncing: false, syncingIds: [] }); + } catch { + console.error("Failed to reset sync state"); } } - await this.setState({ isSyncing: false, syncingIds: [] }); - console.log("Done"); } async setState(state: SyncState) { @@ -323,14 +317,4 @@ export class ProductSyncer { return productName; } } - - private async getProductImageUrls(slug: string): Promise { - const res = await fetch(`${R2_WORKER_URL}/product-images/${slug}`); - if (!res.ok) { - console.error("Failed to get product image keys:", await res.json()); - throw new Error("Failed to get product image keys"); - } - const productImageKeys: string[] = (await res.json()) as string[]; - return productImageKeys.map((key) => `${WEBSITE_MEDIA_URL}/${key}`); - } } diff --git a/packages/commerce/src/util/misc.ts b/packages/commerce/src/util/misc.ts index 0cc8b76..5fdbd1a 100644 --- a/packages/commerce/src/util/misc.ts +++ b/packages/commerce/src/util/misc.ts @@ -1,7 +1,7 @@ export type DeepPartial = T extends object ? { - [P in keyof T]?: DeepPartial; - } + [P in keyof T]?: DeepPartial; + } : T; export const formatSlug = (name: string): string => { @@ -13,25 +13,3 @@ export const formatSlug = (name: string): string => { .replace(/--+/g, "-") // collapse multiple dashes .replace(/^([^a-z0-9_])/, "_$1"); // if it starts with an invalid char, prefix underscore }; - -export class FetchError extends Error { - public payload: Object | undefined; - - constructor( - message: string, - public response: Response, - ) { - super(message); - this.name = "FetchError"; - } - - async parse() { - try { - this.payload = (await this.response.clone().json()) as Object; - } catch { - this.payload = await this.response.text().catch(() => undefined); - } finally { - return this.payload; - } - } -} diff --git a/packages/commerce/src/webflow.ts b/packages/commerce/src/webflow.ts index 4a807ca..6f87c8f 100644 --- a/packages/commerce/src/webflow.ts +++ b/packages/commerce/src/webflow.ts @@ -1,7 +1,10 @@ import type { Webflow } from "./util/types"; -import { FetchError, type DeepPartial } from "./util/misc"; +import { type DeepPartial } from "./util/misc"; import crypto from "node:crypto"; +const parsePayload = (res: Response): Promise => + res.json().catch(() => res.text().catch(() => undefined)); + type ClientOptions = { siteId: string; collectionsId: string; @@ -15,8 +18,19 @@ type Credentials = { authHeader: Record; }; +export class WebflowError extends Error { + constructor( + message: string, + public status: number, + public payload: unknown, + ) { + super(message); + this.name = "WebflowError"; + } +} + class SkusClient { - constructor(private creds: Credentials) {} + constructor(private creds: Credentials) { } async create( webflowProductId: string, @@ -33,7 +47,7 @@ class SkusClient { body: JSON.stringify({ skus }), }, ); - if (!res.ok) throw new FetchError("Failed to create Webflow product SKU", res); + if (!res.ok) throw new WebflowError("Failed to create Webflow product SKU", res.status, await parsePayload(res)); const payload = (await res.json()) as { skus: { id: string }[] }; return payload.skus.map((sku) => sku.id); } @@ -54,7 +68,7 @@ class SkusClient { body: JSON.stringify({ sku: webflowSku }), }, ); - if (!res.ok) throw new FetchError("Failed to update Webflow product SKU", res); + if (!res.ok) throw new WebflowError("Failed to update Webflow product SKU", res.status, await parsePayload(res)); } } @@ -76,7 +90,7 @@ class ProductsClient { }, body: JSON.stringify(webflowProductAndSku), }); - if (!res.ok) throw new FetchError("Failed to create Webflow product", res); + if (!res.ok) throw new WebflowError("Failed to create Webflow product", res.status, await parsePayload(res)); const payload = (await res.json()) as { product: { id: string } }; return payload.product.id; } @@ -86,7 +100,7 @@ class ProductsClient { method: "GET", headers: this.creds.authHeader, }); - if (!res.ok) throw new FetchError("Failed to get all Webflow products", res); + if (!res.ok) throw new WebflowError("Failed to get all Webflow products", res.status, await parsePayload(res)); const payload = (await res.json()) as { items: Webflow.Products.ProductAndSkus[] }; return payload.items; } @@ -97,7 +111,7 @@ class ProductsClient { headers: this.creds.authHeader, }); if (res.status === 404 || res.status === 400) return; - if (!res.ok) throw new FetchError("Failed to get Webflow product", res); + if (!res.ok) throw new WebflowError("Failed to get Webflow product", res.status, await parsePayload(res)); return (await res.json()) as Webflow.Products.ProductAndSkus; } @@ -113,7 +127,7 @@ class ProductsClient { }, body: JSON.stringify(webflowProduct), }); - if (!res.ok) throw new FetchError("Failed to update Webflow product", res); + if (!res.ok) throw new WebflowError("Failed to update Webflow product", res.status, await parsePayload(res)); } async remove(webflowProductId: string): Promise { @@ -128,12 +142,12 @@ class ProductsClient { body: JSON.stringify({}), }, ); - if (!res.ok) throw new FetchError("Failed to remove Webflow product", res); + if (!res.ok) throw new WebflowError("Failed to remove Webflow product", res.status, await parsePayload(res)); } } class OrdersClient { - constructor(private creds: Credentials) {} + constructor(private creds: Credentials) { } async getAll(opt: { status?: Webflow.Orders.Order["status"] } = {}): Promise { // TODO: pagination @@ -143,7 +157,7 @@ class OrdersClient { method: "GET", headers: this.creds.authHeader, }); - if (!res.ok) throw new FetchError("Failed to get all Webflow orders", res); + if (!res.ok) throw new WebflowError("Failed to get all Webflow orders", res.status, await parsePayload(res)); const payload = (await res.json()) as { orders: Webflow.Orders.Order[] }; return payload.orders; } @@ -165,7 +179,7 @@ class OrdersClient { }, body: JSON.stringify(webflowOrderUpdate), }); - if (!res.ok) throw new FetchError("Failed to update Webflow order", res); + if (!res.ok) throw new WebflowError("Failed to update Webflow order", res.status, await parsePayload(res)); } async fulfill( @@ -180,46 +194,37 @@ class OrdersClient { }, body: JSON.stringify(opt), }); - if (!res.ok) throw new FetchError("Failed to fulfill Webflow order", res); + if (!res.ok) throw new WebflowError("Failed to fulfill Webflow order", res.status, await parsePayload(res)); } } class UtilClient { - constructor(private webhookSecret: string) {} + constructor(private webhookSecret: string) { } verifyWebflowSignature(request: Request, body: unknown): boolean { + const timestamp = request.headers.get("x-webflow-timestamp"); + if (!timestamp) return false; + + const providedSignature = request.headers.get("x-webflow-signature"); + if (!providedSignature) return false; + + if (!body) return false; + + const requestTimestamp = parseInt(timestamp, 10); + if (Date.now() - requestTimestamp > 300000) return false; + + const data = `${requestTimestamp}:${JSON.stringify(body)}`; + const hash = crypto + .createHmac("sha256", this.webhookSecret) + .update(data) + .digest("hex"); + try { - const timestamp = request.headers.get("x-webflow-timestamp"); - if (!timestamp) throw new Error("No timestamp provided"); - - const providedSignature = request.headers.get("x-webflow-signature"); - if (!providedSignature) throw new Error("No signature provided"); - - if (!body) throw new Error("Body is empty"); - - const requestTimestamp = parseInt(timestamp, 10); - const data = `${requestTimestamp}:${JSON.stringify(body)}`; - const hash = crypto - .createHmac("sha256", this.webhookSecret) - .update(data) - .digest("hex"); - - if ( - !crypto.timingSafeEqual( - Buffer.from(hash, "hex"), - Buffer.from(providedSignature, "hex"), - ) - ) { - throw new Error("Invalid signature"); - } - - if (Date.now() - requestTimestamp > 300000) { - throw new Error("Request is older than 5 minutes"); - } - - return true; - } catch (err) { - console.error(`Error verifying signature: ${err}`); + return crypto.timingSafeEqual( + Buffer.from(hash, "hex"), + Buffer.from(providedSignature, "hex"), + ); + } catch { return false; } }