diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 3a85221388..dde0e729b5 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -230,7 +230,10 @@ class App { }); // Support application/json type post data - this.app.use(bodyParser.json({ limit: "16mb" })); + this.app.use(bodyParser.json({ limit: "16mb", verify: (req, res, buf) => { + // @ts-ignore + req.rawBody = buf; + }})); // Make sure that Vue history mode works properly this.app.use(history({ diff --git a/packages/nodes-base/credentials/ShopifyApi.credentials.ts b/packages/nodes-base/credentials/ShopifyApi.credentials.ts new file mode 100644 index 0000000000..291f6af1f0 --- /dev/null +++ b/packages/nodes-base/credentials/ShopifyApi.credentials.ts @@ -0,0 +1,38 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + +export class ShopifyApi implements ICredentialType { + name = 'shopifyApi'; + displayName = 'Shopify API'; + properties = [ + { + displayName: 'API Key', + name: 'apiKey', + required: true, + type: 'string' as NodePropertyTypes, + default: '', + }, + { + displayName: 'Password', + name: 'password', + required: true, + type: 'string' as NodePropertyTypes, + default: '', + }, + { + displayName: 'Shop Subdomain', + name: 'shopSubdomain', + required: true, + type: 'string' as NodePropertyTypes, + default: '', + }, + { + displayName: 'Shared Secret', + name: 'sharedSecret', + type: 'string' as NodePropertyTypes, + default: '', + }, + ]; +} diff --git a/packages/nodes-base/nodes/Shopify/GenericFunctions.ts b/packages/nodes-base/nodes/Shopify/GenericFunctions.ts new file mode 100644 index 0000000000..d7e0634d0c --- /dev/null +++ b/packages/nodes-base/nodes/Shopify/GenericFunctions.ts @@ -0,0 +1,42 @@ +import { OptionsWithUri } from 'request'; + +import { + IExecuteFunctions, + IHookFunctions, + ILoadOptionsFunctions, + IExecuteSingleFunctions, + BINARY_ENCODING +} from 'n8n-core'; + +import { + IDataObject, +} from 'n8n-workflow'; + +export async function shopifyApiRequest(this: IHookFunctions | IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions, method: string, resource: string, body: any = {}, query?: IDataObject, uri?: string): Promise { // tslint:disable-line:no-any + const credentials = this.getCredentials('shopifyApi'); + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + const headerWithAuthentication = Object.assign({}, + { Authorization: ` Basic ${Buffer.from(`${credentials.apiKey}:${credentials.password}`).toString(BINARY_ENCODING)}` }); + + const options: OptionsWithUri = { + headers: headerWithAuthentication, + method, + qs: query, + uri: uri || `https://${credentials.shopSubdomain}.myshopify.com/admin/api/2019-10${resource}`, + body, + json: true + }; + + try { + return await this.helpers.request!(options); + } catch (error) { + const errorMessage = error.response.body.message || error.response.body.Message; + + if (errorMessage !== undefined) { + throw errorMessage; + } + throw error.response.body; + } +} diff --git a/packages/nodes-base/nodes/Shopify/ShopifyTrigger.node.ts b/packages/nodes-base/nodes/Shopify/ShopifyTrigger.node.ts new file mode 100644 index 0000000000..bd820833ee --- /dev/null +++ b/packages/nodes-base/nodes/Shopify/ShopifyTrigger.node.ts @@ -0,0 +1,391 @@ +import { + IHookFunctions, + IWebhookFunctions, +} from 'n8n-core'; + +import { + IDataObject, + INodeTypeDescription, + INodeType, + IWebhookResponseData, +} from 'n8n-workflow'; + +import { + shopifyApiRequest, +} from './GenericFunctions'; + +import { createHmac } from 'crypto'; + +export class ShopifyTrigger implements INodeType { + description: INodeTypeDescription = { + displayName: 'Shopify Trigger', + name: 'shopify', + icon: 'file:shopify.png', + group: ['trigger'], + version: 1, + description: 'Handle Shopify events via webhooks', + defaults: { + name: 'Shopify Trigger', + color: '#559922', + }, + inputs: [], + outputs: ['main'], + credentials: [ + { + name: 'shopifyApi', + required: true, + } + ], + webhooks: [ + { + name: 'default', + httpMethod: 'POST', + responseMode: 'onReceived', + path: 'webhook', + }, + ], + properties: [ + { + displayName: 'Topic', + name: 'topic', + type: 'options', + default: '', + options: + [ + { + name: 'App uninstalled', + value: 'app/uninstalled' + }, + { + name: 'Carts create', + value: 'carts/create' + }, + { + name: 'Carts update', + value: 'carts/update' + }, + { + name: 'Checkouts create', + value: 'checkouts/create' + }, + { + name: 'Checkouts delete', + value: 'checkouts/delete' + }, + { + name: 'Checkouts update', + value: 'checkouts/update' + }, + { + name: 'Collection listings add', + value: 'collection_listings/add' + }, + { + name: 'Collection listings remove', + value: 'collection_listings/remove' + }, + { + name: 'Collection listings update', + value: 'collection_listings/update' + }, + { + name: 'Collections create', + value: 'collections/create' + }, + { + name: 'Collections delete', + value: 'collections/delete' + }, + { + name: 'Collections update', + value: 'collections/update' + }, + { + name: 'Customer groups create', + value: 'customer_groups/create' + }, + { + name: 'Customer groups delete', + value: 'customer_groups/delete' + }, + { + name: 'Customer groups update', + value: 'customer_groups/update' + }, + { + name: 'Customers create', + value: 'customers/create' + }, + { + name: 'Customers delete', + value: 'customers/delete' + }, + { + name: 'Customers disable', + value: 'customers/disable' + }, + { + name: 'Customers enable', + value: 'customers/enable' + }, + { + name: 'Customers update', + value: 'customers/update' + }, + { + name: 'Draft orders create', + value: 'draft_orders/create' + }, + { + name: 'Draft orders delete', + value: 'draft_orders/delete' + }, + { + name: 'Draft orders update', + value: 'draft_orders/update' + }, + { + name: 'Fulfillment events create', + value: 'fulfillment_events/create' + }, + { + name: 'Fulfillment events delete', + value: 'fulfillment_events/delete' + }, + { + name: 'Fulfillments create', + value: 'fulfillments/create' + }, + { + name: 'Fulfillments update', + value: 'fulfillments/update' + }, + { + name: 'Inventory_items create', + value: 'inventory_items/create' + }, + { + name: 'Inventory_items delete', + value: 'inventory_items/delete' + }, + { + name: 'Inventory_items update', + value: 'inventory_items/update' + }, + { + name: 'Inventory_levels connect', + value: 'inventory_levels/connect' + }, + { + name: 'Inventory_levels disconnect', + value: 'inventory_levels/disconnect' + }, + { + name: 'Inventory_levels update', + value: 'inventory_levels/update' + }, + { + name: 'Locales create', + value: 'locales/create' + }, + { + name: 'Locales update', + value: 'locales/update' + }, + { + name: 'Locations create', + value: 'locations/create' + }, + { + name: 'Locations delete', + value: 'locations/delete' + }, + { + name: 'Locations update', + value: 'locations/update' + }, + { + name: 'Order transactions create', + value: 'order_transactions/create' + }, + { + name: 'Orders cancelled', + value: 'orders/cancelled' + }, + { + name: 'Orders create', + value: 'orders/create' + }, + { + name: 'Orders delete', + value: 'orders/delete' + }, + { + name: 'Orders fulfilled', + value: 'orders/fulfilled' + }, + { + name: 'Orders paid', + value: 'orders/paid' + }, + { + name: 'Orders partially fulfilled', + value: 'orders/partially_fulfilled' + }, + { + name: 'Orders updated', + value: 'orders/updated' + }, + { + name: 'Product listings add', + value: 'product_listings/add' + }, + { + name: 'Product listings remove', + value: 'product_listings/remove' + }, + { + name: 'Product listings update', + value: 'product_listings/update' + }, + { + name: 'Products create', + value: 'products/create' + }, + { + name: 'Products delete', + value: 'products/delete' + }, + { + name: 'Products update', + value: 'products/update' + }, + { + name: 'Refunds create', + value: 'refunds/create' + }, + { + name: 'Shop update', + value: 'shop/update' + }, + { + name: 'Tender transactions create', + value: 'tender_transactions/create' + }, + { + name: 'Themes create', + value: 'themes/create' + }, + { + name: 'Themes delete', + value: 'themes/delete' + }, + { + name: 'Themes publish', + value: 'themes/publish' + }, + { + name: 'Themes update', + value: 'themes/update' + } + ], + description: 'Event that triggers the webhook', + }, + ], + + }; + // @ts-ignore (because of request) + webhookMethods = { + default: { + async checkExists(this: IHookFunctions): Promise { + const webhookData = this.getWorkflowStaticData('node'); + if (webhookData.webhookId === undefined) { + return false; + } + const endpoint = `/webhooks/${webhookData.webhookId}.json`; + try { + await shopifyApiRequest.call(this, 'GET', endpoint, {}); + } catch (e) { + if (e.statusCode === 404) { + delete webhookData.webhookId; + return false; + } + throw e; + } + return true; + }, + async create(this: IHookFunctions): Promise { + const credentials = this.getCredentials('shopifyApi'); + const webhookUrl = this.getNodeWebhookUrl('default'); + const topic = this.getNodeParameter('topic') as string; + const endpoint = `/webhooks.json`; + const body = { + webhook: { + topic, + address: webhookUrl, + format: 'json', + } + }; + + let responseData; + try { + responseData = await shopifyApiRequest.call(this, 'POST', endpoint, body); + } catch(error) { + return false; + } + + if (responseData.webhook === undefined || responseData.webhook.id === undefined) { + // Required data is missing so was not successful + return false; + } + + const webhookData = this.getWorkflowStaticData('node'); + webhookData.webhookId = responseData.webhook.id as string; + webhookData.sharedSecret = credentials!.sharedSecret as string; + webhookData.topic = topic as string; + return true; + }, + async delete(this: IHookFunctions): Promise { + const webhookData = this.getWorkflowStaticData('node'); + if (webhookData.webhookId !== undefined) { + const endpoint = `/webhooks/${webhookData.webhookId}.json`; + try { + await shopifyApiRequest.call(this, 'DELETE', endpoint, {}); + } catch (e) { + return false; + } + delete webhookData.webhookId; + delete webhookData.sharedSecret; + delete webhookData.topic; + } + return true; + }, + }, + }; + + async webhook(this: IWebhookFunctions): Promise { + const headerData = this.getHeaderData() as IDataObject; + const req = this.getRequestObject(); + const webhookData = this.getWorkflowStaticData('node') as IDataObject; + if (headerData['x-shopify-topic'] !== undefined + && headerData['x-shopify-hmac-sha256'] !== undefined + && headerData['x-shopify-shop-domain'] !== undefined + && headerData['x-shopify-api-version'] !== undefined) { + // @ts-ignore + const computedSignature = createHmac('sha256', webhookData.sharedSecret as string).update(req.rawBody).digest('base64'); + if (headerData['x-shopify-hmac-sha256'] !== computedSignature) { + return {}; + } + if (webhookData.topic !== headerData['x-shopify-topic']) { + return {}; + } + } else { + return {}; + } + return { + workflowData: [ + this.helpers.returnJsonArray(req.body) + ], + }; + } +} diff --git a/packages/nodes-base/nodes/Shopify/shopify.png b/packages/nodes-base/nodes/Shopify/shopify.png new file mode 100644 index 0000000000..938dbd0bc4 Binary files /dev/null and b/packages/nodes-base/nodes/Shopify/shopify.png differ diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 60f74c27c2..8b4e66608e 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -41,9 +41,9 @@ "dist/credentials/HttpBasicAuth.credentials.js", "dist/credentials/HttpDigestAuth.credentials.js", "dist/credentials/HttpHeaderAuth.credentials.js", + "dist/credentials/Imap.credentials.js", "dist/credentials/IntercomApi.credentials.js", - "dist/credentials/Imap.credentials.js", - "dist/credentials/JiraSoftwareCloudApi.credentials.js", + "dist/credentials/JiraSoftwareCloudApi.credentials.js", "dist/credentials/LinkFishApi.credentials.js", "dist/credentials/MailchimpApi.credentials.js", "dist/credentials/MailgunApi.credentials.js", @@ -57,6 +57,7 @@ "dist/credentials/PayPalApi.credentials.js", "dist/credentials/Redis.credentials.js", "dist/credentials/RocketchatApi.credentials.js", + "dist/credentials/ShopifyApi.credentials.js", "dist/credentials/SlackApi.credentials.js", "dist/credentials/Smtp.credentials.js", "dist/credentials/StripeApi.credentials.js", @@ -133,6 +134,7 @@ "dist/nodes/SpreadsheetFile.node.js", "dist/nodes/Start.node.js", "dist/nodes/Stripe/StripeTrigger.node.js", + "dist/nodes/Shopify/ShopifyTrigger.node.js", "dist/nodes/Telegram/Telegram.node.js", "dist/nodes/Telegram/TelegramTrigger.node.js", "dist/nodes/Todoist/Todoist.node.js",