From 6c50c84ab1fc52c150ab4d6a4486f1c03464cb5e Mon Sep 17 00:00:00 2001 From: Rodrigo Correia Date: Thu, 16 Sep 2021 14:09:19 -0300 Subject: [PATCH 001/290] Pipedrive - Get Activities from Deal Id --- .../nodes/Pipedrive/Pipedrive.node.ts | 126 +++++++++++++++++- 1 file changed, 125 insertions(+), 1 deletion(-) diff --git a/packages/nodes-base/nodes/Pipedrive/Pipedrive.node.ts b/packages/nodes-base/nodes/Pipedrive/Pipedrive.node.ts index 97b712e619..1ada18b0c3 100644 --- a/packages/nodes-base/nodes/Pipedrive/Pipedrive.node.ts +++ b/packages/nodes-base/nodes/Pipedrive/Pipedrive.node.ts @@ -118,6 +118,10 @@ export class Pipedrive implements INodeType { name: 'Deal', value: 'deal', }, + { + name: 'Deal Activity', + value: 'dealActivity', + }, { name: 'Deal Product', value: 'dealProduct', @@ -250,6 +254,27 @@ export class Pipedrive implements INodeType { description: 'The operation to perform.', }, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'dealActivity', + ], + }, + }, + options: [ + { + name: 'Get All', + value: 'getAll', + description: 'Get all products in a deal', + }, + ], + default: 'getAll', + }, + { displayName: 'Operation', name: 'operation', @@ -3423,6 +3448,76 @@ export class Pipedrive implements INodeType { description: 'How many results to return.', }, + // ---------------------------------- + // dealActivities:getAll + // ---------------------------------- + { + displayName: 'Deal ID', + name: 'dealId', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getDeals', + }, + required: true, + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'dealActivity', + ], + }, + }, + description: 'The ID of the deal whose products to retrieve', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'dealActivity', + ], + }, + }, + default: {}, + options: [ + { + displayName: 'Done', + name: 'done', + type: 'options', + options: [ + { + name: 'Not done', + value: '0', + }, + { + name: 'Done', + value: '1', + }, + ], + default: '0', + description: 'Whether the activity is done or not.', + }, + { + displayName: 'Exclude Activity Ids', + name: 'exclude', + type: 'string', + typeOptions: { + rows: 3, + }, + default: '', + description: 'A comma separated Activity Ids, to exclude from result. Ex. 4, 9, 11, ...', + }, + ], + }, // ---------------------------------------- // lead: getAll // ---------------------------------------- @@ -4387,6 +4482,35 @@ export class Pipedrive implements INodeType { } + } else if (resource === 'dealActivity') { + + if (operation === 'getAll') { + // ---------------------------------- + // dealActivity: getAll + // ---------------------------------- + + requestMethod = 'GET'; + const dealId = this.getNodeParameter('dealId', i) as string; + + returnAll = this.getNodeParameter('returnAll', i) as boolean; + + if (returnAll === false) { + qs.limit = this.getNodeParameter('limit', i) as number; + } + + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + + if (additionalFields.exclude) { + qs.exclude = (additionalFields.exclude as string); + } + + if (additionalFields.done) { + qs.done = parseInt(additionalFields.done as string); + } + + endpoint = `/deals/${dealId}/activities`; + + } } else if (resource === 'dealProduct') { if (operation === 'add') { @@ -4982,7 +5106,7 @@ export class Pipedrive implements INodeType { returnData.push(responseData.data as IDataObject); } } - } catch (error) { + } catch (error: any) { if (this.continueOnFail()) { if (resource === 'file' && operation === 'download') { items[i].json = { error: error.message }; From 389931da718a029bfa934f22af476afbcf069a74 Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Mon, 27 Sep 2021 18:47:39 -0400 Subject: [PATCH 002/290] :sparkles: Add binary data support to Telegram Node (#2249) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ Add binary upload for Telegram * :zap: Improvements to #2247 Co-authored-by: pemontto --- .../nodes/Telegram/GenericFunctions.ts | 14 +- .../nodes/Telegram/Telegram.node.ts | 240 ++++++++++++------ 2 files changed, 176 insertions(+), 78 deletions(-) diff --git a/packages/nodes-base/nodes/Telegram/GenericFunctions.ts b/packages/nodes-base/nodes/Telegram/GenericFunctions.ts index f3b6005677..4ed4eec279 100644 --- a/packages/nodes-base/nodes/Telegram/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Telegram/GenericFunctions.ts @@ -1,4 +1,5 @@ import { + BINARY_ENCODING, IExecuteFunctions, IHookFunctions, ILoadOptionsFunctions, @@ -10,7 +11,7 @@ import { } from 'request'; import { - IDataObject, NodeApiError, NodeOperationError, + IBinaryData, IDataObject, NodeApiError, NodeOperationError, } from 'n8n-workflow'; // Interface in n8n @@ -142,7 +143,7 @@ export function addAdditionalFields(this: IExecuteFunctions, body: IDataObject, * @param {object} body * @returns {Promise} */ -export async function apiRequest(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions, method: string, endpoint: string, body: object, query?: IDataObject, option: IDataObject = {}): Promise { // tslint:disable-line:no-any +export async function apiRequest(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions, method: string, endpoint: string, body: IDataObject, query?: IDataObject, option: IDataObject = {}): Promise { // tslint:disable-line:no-any const credentials = await this.getCredentials('telegramApi'); if (credentials === undefined) { @@ -152,12 +153,11 @@ export async function apiRequest(this: IHookFunctions | IExecuteFunctions | ILoa query = query || {}; const options: OptionsWithUri = { - headers: { - }, + headers: {}, method, + uri: `https://api.telegram.org/bot${credentials.accessToken}/${endpoint}`, body, qs: query, - uri: `https://api.telegram.org/bot${credentials.accessToken}/${endpoint}`, json: true, }; @@ -192,3 +192,7 @@ export function getImageBySize(photos: IDataObject[], size: string): IDataObject return photos[index]; } + +export function getPropertyName(operation: string) { + return operation.replace('send', '').toLowerCase(); +} diff --git a/packages/nodes-base/nodes/Telegram/Telegram.node.ts b/packages/nodes-base/nodes/Telegram/Telegram.node.ts index 017494d90b..3e1f9ad2fd 100644 --- a/packages/nodes-base/nodes/Telegram/Telegram.node.ts +++ b/packages/nodes-base/nodes/Telegram/Telegram.node.ts @@ -3,6 +3,7 @@ import { } from 'n8n-core'; import { + IBinaryData, ICredentialsDecrypted, ICredentialTestFunctions, IDataObject, @@ -16,6 +17,7 @@ import { import { addAdditionalFields, apiRequest, + getPropertyName, } from './GenericFunctions'; @@ -740,6 +742,61 @@ export class Telegram implements INodeType { required: true, description: 'Unique identifier for the target chat or username of the target
channel (in the format @channelusername). To find your chat id ask @get_id_bot.', }, + // ---------------------------------- + // message:sendAnimation/sendAudio/sendDocument/sendPhoto/sendSticker/sendVideo + // ---------------------------------- + + { + displayName: 'Binary Data', + name: 'binaryData', + type: 'boolean', + default: false, + required: true, + displayOptions: { + show: { + operation: [ + 'sendAnimation', + 'sendAudio', + 'sendDocument', + 'sendPhoto', + 'sendVideo', + 'sendSticker', + ], + resource: [ + 'message', + ], + }, + }, + description: 'If the data to upload should be taken from binary field.', + }, + { + displayName: 'Binary Property', + name: 'binaryPropertyName', + type: 'string', + default: 'data', + required: true, + displayOptions: { + show: { + operation: [ + 'sendAnimation', + 'sendAudio', + 'sendDocument', + 'sendPhoto', + 'sendVideo', + 'sendSticker', + ], + resource: [ + 'message', + ], + binaryData: [ + true, + ], + }, + }, + placeholder: '', + description: 'Name of the binary property that contains the data to upload', + }, + { displayName: 'Message ID', name: 'messageId', @@ -828,9 +885,12 @@ export class Telegram implements INodeType { resource: [ 'message', ], + binaryData: [ + false, + ], }, }, - description: 'Animation to send. Pass a file_id to send an animation that exists on the Telegram servers (recommended)
or pass an HTTP URL for Telegram to get an animation from the Internet.', + description: 'Animation to send. Pass a file_id to send an animation that exists on the Telegram servers (recommended)
, an HTTP URL for Telegram to get an animation from the Internet', }, @@ -851,9 +911,12 @@ export class Telegram implements INodeType { resource: [ 'message', ], + binaryData: [ + false, + ], }, }, - description: 'Audio file to send. Pass a file_id to send a file that exists on the Telegram servers (recommended)
or pass an HTTP URL for Telegram to get a file from the Internet.', + description: 'Audio file to send. Pass a file_id to send a file that exists on the Telegram servers (recommended)
, an HTTP URL for Telegram to get a file from the Internet', }, @@ -939,9 +1002,12 @@ export class Telegram implements INodeType { resource: [ 'message', ], + binaryData: [ + false, + ], }, }, - description: 'Document to send. Pass a file_id to send a file that exists on the Telegram servers (recommended)
or pass an HTTP URL for Telegram to get a file from the Internet.', + description: 'Document to send. Pass a file_id to send a file that exists on the Telegram servers (recommended)
, an HTTP URL for Telegram to get a file from the Internet', }, @@ -1131,9 +1197,12 @@ export class Telegram implements INodeType { resource: [ 'message', ], + binaryData: [ + false, + ], }, }, - description: 'Photo to send. Pass a file_id to send a photo that exists on the Telegram servers (recommended)
or pass an HTTP URL for Telegram to get a photo from the Internet.', + description: 'Photo to send. Pass a file_id to send a photo that exists on the Telegram servers (recommended)
, an HTTP URL for Telegram to get a photo from the Internet', }, @@ -1153,9 +1222,12 @@ export class Telegram implements INodeType { resource: [ 'message', ], + binaryData: [ + false, + ], }, }, - description: 'Sticker to send. Pass a file_id to send a file that exists on the Telegram servers (recommended)
or pass an HTTP URL for Telegram to get a .webp file from the Internet.', + description: 'Sticker to send. Pass a file_id to send a file that exists on the Telegram servers (recommended)
, an HTTP URL for Telegram to get a .webp file from the Internet', }, @@ -1175,12 +1247,14 @@ export class Telegram implements INodeType { resource: [ 'message', ], + binaryData: [ + false, + ], }, }, - description: 'Video file to send. Pass a file_id to send a file that exists on the Telegram servers (recommended)
or pass an HTTP URL for Telegram to get a file from the Internet.', + description: 'Video file to send. Pass a file_id to send a file that exists on the Telegram servers (recommended)
, an HTTP URL for Telegram to get a file from the Internet', }, - // ---------------------------------- // message:editMessageText/sendAnimation/sendAudio/sendLocation/sendMessage/sendPhoto/sendSticker/sendVideo // ---------------------------------- @@ -1756,7 +1830,7 @@ export class Telegram implements INodeType { message: 'Token is not valid.', }; } - } catch(err) { + } catch (err) { return { status: 'Error', message: `Token is not valid; ${err.message}`, @@ -1767,7 +1841,7 @@ export class Telegram implements INodeType { status: 'OK', message: 'Authentication successful!', }; - + }, }, }; @@ -1787,6 +1861,7 @@ export class Telegram implements INodeType { const operation = this.getNodeParameter('operation', 0) as string; const resource = this.getNodeParameter('resource', 0) as string; + const binaryData = this.getNodeParameter('binaryData', 0, false) as boolean; for (let i = 0; i < items.length; i++) { try { @@ -1897,147 +1972,146 @@ export class Telegram implements INodeType { // ---------------------------------- // message:editMessageText // ---------------------------------- - + endpoint = 'editMessageText'; - + const messageType = this.getNodeParameter('messageType', i) as string; - + if (messageType === 'inlineMessage') { body.inline_message_id = this.getNodeParameter('inlineMessageId', i) as string; } else { body.chat_id = this.getNodeParameter('chatId', i) as string; body.message_id = this.getNodeParameter('messageId', i) as string; } - + body.text = this.getNodeParameter('text', i) as string; - + // Add additional fields and replyMarkup addAdditionalFields.call(this, body, i); - + } else if (operation === 'deleteMessage') { // ---------------------------------- // message:deleteMessage // ---------------------------------- - + endpoint = 'deleteMessage'; - + body.chat_id = this.getNodeParameter('chatId', i) as string; body.message_id = this.getNodeParameter('messageId', i) as string; - + } else if (operation === 'pinChatMessage') { // ---------------------------------- // message:pinChatMessage // ---------------------------------- - + endpoint = 'pinChatMessage'; - + body.chat_id = this.getNodeParameter('chatId', i) as string; body.message_id = this.getNodeParameter('messageId', i) as string; - + const { disable_notification } = this.getNodeParameter('additionalFields', i) as IDataObject; if (disable_notification) { body.disable_notification = true; } - + } else if (operation === 'unpinChatMessage') { // ---------------------------------- // message:unpinChatMessage // ---------------------------------- - + endpoint = 'unpinChatMessage'; - + body.chat_id = this.getNodeParameter('chatId', i) as string; body.message_id = this.getNodeParameter('messageId', i) as string; - + } else if (operation === 'sendAnimation') { // ---------------------------------- // message:sendAnimation // ---------------------------------- - + endpoint = 'sendAnimation'; - + body.chat_id = this.getNodeParameter('chatId', i) as string; - body.animation = this.getNodeParameter('file', i) as string; - + body.animation = this.getNodeParameter('file', i, '') as string; + // Add additional fields and replyMarkup addAdditionalFields.call(this, body, i); - - + } else if (operation === 'sendAudio') { // ---------------------------------- // message:sendAudio // ---------------------------------- - + endpoint = 'sendAudio'; - + body.chat_id = this.getNodeParameter('chatId', i) as string; - body.audio = this.getNodeParameter('file', i) as string; - + body.audio = this.getNodeParameter('file', i, '') as string; + // Add additional fields and replyMarkup addAdditionalFields.call(this, body, i); - + } else if (operation === 'sendChatAction') { // ---------------------------------- // message:sendChatAction // ---------------------------------- - + endpoint = 'sendChatAction'; - + body.chat_id = this.getNodeParameter('chatId', i) as string; body.action = this.getNodeParameter('action', i) as string; - + } else if (operation === 'sendDocument') { // ---------------------------------- // message:sendDocument // ---------------------------------- - + endpoint = 'sendDocument'; - + body.chat_id = this.getNodeParameter('chatId', i) as string; - body.document = this.getNodeParameter('file', i) as string; - + body.document = this.getNodeParameter('file', i, '') as string; + // Add additional fields and replyMarkup addAdditionalFields.call(this, body, i); - + } else if (operation === 'sendLocation') { // ---------------------------------- // message:sendLocation // ---------------------------------- - + endpoint = 'sendLocation'; - + body.chat_id = this.getNodeParameter('chatId', i) as string; body.latitude = this.getNodeParameter('latitude', i) as string; body.longitude = this.getNodeParameter('longitude', i) as string; - + // Add additional fields and replyMarkup addAdditionalFields.call(this, body, i); - + } else if (operation === 'sendMessage') { // ---------------------------------- // message:sendMessage // ---------------------------------- - + endpoint = 'sendMessage'; - + body.chat_id = this.getNodeParameter('chatId', i) as string; body.text = this.getNodeParameter('text', i) as string; - + // Add additional fields and replyMarkup addAdditionalFields.call(this, body, i); - + } else if (operation === 'sendMediaGroup') { // ---------------------------------- // message:sendMediaGroup // ---------------------------------- - + endpoint = 'sendMediaGroup'; - + body.chat_id = this.getNodeParameter('chatId', i) as string; - + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; Object.assign(body, additionalFields); - + const mediaItems = this.getNodeParameter('media', i) as IDataObject; body.media = []; for (const mediaItem of mediaItems.media as IDataObject[]) { @@ -2047,52 +2121,72 @@ export class Telegram implements INodeType { } (body.media as IDataObject[]).push(mediaItem); } - + } else if (operation === 'sendPhoto') { // ---------------------------------- // message:sendPhoto // ---------------------------------- - + endpoint = 'sendPhoto'; - + body.chat_id = this.getNodeParameter('chatId', i) as string; - body.photo = this.getNodeParameter('file', i) as string; - + body.photo = this.getNodeParameter('file', i, '') as string; + // Add additional fields and replyMarkup addAdditionalFields.call(this, body, i); - + } else if (operation === 'sendSticker') { // ---------------------------------- // message:sendSticker // ---------------------------------- - + endpoint = 'sendSticker'; - + body.chat_id = this.getNodeParameter('chatId', i) as string; - body.sticker = this.getNodeParameter('file', i) as string; - + body.sticker = this.getNodeParameter('file', i, '') as string; + // Add additional fields and replyMarkup addAdditionalFields.call(this, body, i); - + } else if (operation === 'sendVideo') { // ---------------------------------- // message:sendVideo // ---------------------------------- - + endpoint = 'sendVideo'; - + body.chat_id = this.getNodeParameter('chatId', i) as string; - body.video = this.getNodeParameter('file', i) as string; - + body.video = this.getNodeParameter('file', i, '') as string; + // Add additional fields and replyMarkup addAdditionalFields.call(this, body, i); - } } else { throw new NodeOperationError(this.getNode(), `The resource "${resource}" is not known!`); } - const responseData = await apiRequest.call(this, requestMethod, endpoint, body, qs); + let responseData; + + if (binaryData === true) { + const binaryPropertyName = this.getNodeParameter('binaryPropertyName', 0) as string; + const binaryData = items[i].binary![binaryPropertyName] as IBinaryData; + const dataBuffer = await this.helpers.getBinaryDataBuffer(i, binaryPropertyName); + const propertyName = getPropertyName(operation); + + const formData = { + ...body, + [propertyName]: { + value: dataBuffer, + options: { + filename: binaryData.fileName, + contentType: binaryData.mimeType, + }, + }, + }; + responseData = await apiRequest.call(this, requestMethod, endpoint, {}, qs, { formData }); + } else { + responseData = await apiRequest.call(this, requestMethod, endpoint, body, qs); + } if (resource === 'file' && operation === 'get') { if (this.getNodeParameter('download', i, false) as boolean === true) { From 62d1d697102df3cc85de58a2977600fa5f2d14a6 Mon Sep 17 00:00:00 2001 From: Omar Ajoue Date: Tue, 28 Sep 2021 00:50:37 +0200 Subject: [PATCH 003/290] :bug: Fix n8n's behavior for empty response bodies (#2246) * Fixed n8n's behavior for empty response bodies * Correctly parsing empty bodies when expected output is a buffer --- packages/core/src/NodeExecuteFunctions.ts | 20 ++++++++++++++++++-- packages/core/src/WorkflowExecute.ts | 6 +++++- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index a0bef7ac0a..af9dd3ed56 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -387,15 +387,31 @@ async function proxyRequestToAxios( axios(axiosConfig) .then((response) => { if (configObject.resolveWithFullResponse === true) { + let body = response.data; + if (response.data === '') { + if (axiosConfig.responseType === 'arraybuffer') { + body = Buffer.alloc(0); + } else { + body = undefined; + } + } resolve({ - body: response.data, + body, headers: response.headers, statusCode: response.status, statusMessage: response.statusText, request: response.request, }); } else { - resolve(response.data); + let body = response.data; + if (response.data === '') { + if (axiosConfig.responseType === 'arraybuffer') { + body = Buffer.alloc(0); + } else { + body = undefined; + } + } + resolve(body); } }) .catch((error) => { diff --git a/packages/core/src/WorkflowExecute.ts b/packages/core/src/WorkflowExecute.ts index 7ebe4dcda7..df84915d75 100644 --- a/packages/core/src/WorkflowExecute.ts +++ b/packages/core/src/WorkflowExecute.ts @@ -896,7 +896,11 @@ export class WorkflowExecute { // the `error` property. for (const execution of nodeSuccessData!) { for (const lineResult of execution) { - if (lineResult.json.$error !== undefined && lineResult.json.$json !== undefined) { + if ( + lineResult.json !== undefined && + lineResult.json.$error !== undefined && + lineResult.json.$json !== undefined + ) { lineResult.error = lineResult.json.$error as NodeApiError | NodeOperationError; lineResult.json = { error: (lineResult.json.$error as NodeApiError | NodeOperationError).message, From b873eec2ad46a51bfb4508d9366a9b337cf60292 Mon Sep 17 00:00:00 2001 From: Omar Ajoue Date: Tue, 28 Sep 2021 14:41:12 +0200 Subject: [PATCH 004/290] Fixed refresh token to work correctly with Axios --- packages/core/src/Interfaces.ts | 6 +++++- packages/core/src/NodeExecuteFunctions.ts | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/core/src/Interfaces.ts b/packages/core/src/Interfaces.ts index 94f940ed0a..7548b4c944 100644 --- a/packages/core/src/Interfaces.ts +++ b/packages/core/src/Interfaces.ts @@ -106,7 +106,11 @@ export interface IPollFunctions extends IPollFunctionsBase { } export interface IResponseError extends Error { - statusCode?: number; + status?: number; // this is how the request library returns + response?: { + // this is how Axios returns + status: number; + }; } export interface ITriggerFunctions extends ITriggerFunctionsBase { diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index af9dd3ed56..48c7cb1c76 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -673,7 +673,7 @@ export async function requestOAuth2( ? 401 : oAuth2Options?.tokenExpiredStatusCode; - if (error.statusCode === statusCodeReturned) { + if (error.response?.status === statusCodeReturned || error.status === statusCodeReturned) { // Token is probably not valid anymore. So try refresh it. const tokenRefreshOptions: IDataObject = {}; From 694ec64330970bb995cefcad6cf973659cdb2932 Mon Sep 17 00:00:00 2001 From: Omar Ajoue Date: Tue, 28 Sep 2021 15:20:01 +0200 Subject: [PATCH 005/290] Changed the error handling location --- packages/core/src/Interfaces.ts | 6 +----- packages/core/src/NodeExecuteFunctions.ts | 3 ++- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/core/src/Interfaces.ts b/packages/core/src/Interfaces.ts index 7548b4c944..94f940ed0a 100644 --- a/packages/core/src/Interfaces.ts +++ b/packages/core/src/Interfaces.ts @@ -106,11 +106,7 @@ export interface IPollFunctions extends IPollFunctionsBase { } export interface IResponseError extends Error { - status?: number; // this is how the request library returns - response?: { - // this is how Axios returns - status: number; - }; + statusCode?: number; } export interface ITriggerFunctions extends ITriggerFunctionsBase { diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index 48c7cb1c76..09d29f76c4 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -415,6 +415,7 @@ async function proxyRequestToAxios( } }) .catch((error) => { + error.statusCode = error.response.status; reject(error); }); }); @@ -673,7 +674,7 @@ export async function requestOAuth2( ? 401 : oAuth2Options?.tokenExpiredStatusCode; - if (error.response?.status === statusCodeReturned || error.status === statusCodeReturned) { + if (error.statusCode === statusCodeReturned) { // Token is probably not valid anymore. So try refresh it. const tokenRefreshOptions: IDataObject = {}; From db134f0abe4c32d0bb9b3c924c32d01d6acb5806 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 28 Sep 2021 20:50:15 +0200 Subject: [PATCH 006/290] :sparkles: Add Splunk node (#2180) * :sparkles: Create Splunk node * :hammer: Move rejectUnauthorized to credentials * :hammer: Remove trailing slash * :hammer: Clarify 401 error * :fire: Remove unused params * :fire: Remove unused logic * :zap: Guard against code missing * :hammer: Refactor filter * :fire: Remove params with no effect * :fire: Remove superfluous description * :fire: Remove params for unimplemented resource * :fire: Remove param with no effect * :bug: Fix multiple roles in user create and upate * :fire: Remove logging * :zap: Simplify ID handling * :shirt: Fix lint * :zap: Add cred test * :art: Format import * :pencil2: Apply Product feedback * :bug: Make axiox errors compatible Co-authored-by: Jan Oberhauser --- packages/core/src/NodeExecuteFunctions.ts | 3 + .../credentials/SplunkApi.credentials.ts | 32 ++ .../nodes/Splunk/GenericFunctions.ts | 268 ++++++++++ .../nodes-base/nodes/Splunk/Splunk.node.ts | 497 ++++++++++++++++++ .../descriptions/FiredAlertDescription.ts | 27 + .../SearchConfigurationDescription.ts | 159 ++++++ .../descriptions/SearchJobDescription.ts | 418 +++++++++++++++ .../descriptions/SearchResultDescription.ts | 166 ++++++ .../Splunk/descriptions/UserDescription.ts | 300 +++++++++++ .../nodes/Splunk/descriptions/index.ts | 5 + packages/nodes-base/nodes/Splunk/splunk.svg | 3 + packages/nodes-base/nodes/Splunk/types.d.ts | 30 ++ packages/nodes-base/package.json | 2 + 13 files changed, 1910 insertions(+) create mode 100644 packages/nodes-base/credentials/SplunkApi.credentials.ts create mode 100644 packages/nodes-base/nodes/Splunk/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/Splunk/Splunk.node.ts create mode 100644 packages/nodes-base/nodes/Splunk/descriptions/FiredAlertDescription.ts create mode 100644 packages/nodes-base/nodes/Splunk/descriptions/SearchConfigurationDescription.ts create mode 100644 packages/nodes-base/nodes/Splunk/descriptions/SearchJobDescription.ts create mode 100644 packages/nodes-base/nodes/Splunk/descriptions/SearchResultDescription.ts create mode 100644 packages/nodes-base/nodes/Splunk/descriptions/UserDescription.ts create mode 100644 packages/nodes-base/nodes/Splunk/descriptions/index.ts create mode 100644 packages/nodes-base/nodes/Splunk/splunk.svg create mode 100644 packages/nodes-base/nodes/Splunk/types.d.ts diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index af9dd3ed56..f2ce64da55 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -415,6 +415,9 @@ async function proxyRequestToAxios( } }) .catch((error) => { + // The error-data was made available with request library via "error" but now on + // axios via "response.data" so copy information over to keep it compatible + error.error = error.response.data; reject(error); }); }); diff --git a/packages/nodes-base/credentials/SplunkApi.credentials.ts b/packages/nodes-base/credentials/SplunkApi.credentials.ts new file mode 100644 index 0000000000..99560572ad --- /dev/null +++ b/packages/nodes-base/credentials/SplunkApi.credentials.ts @@ -0,0 +1,32 @@ +import { + ICredentialType, + INodeProperties, +} from 'n8n-workflow'; + +export class SplunkApi implements ICredentialType { + name = 'splunkApi'; + displayName = 'Splunk API'; + documentationUrl = 'splunk'; + properties: INodeProperties[] = [ + { + displayName: 'Auth Token', + name: 'authToken', + type: 'string', + default: '', + }, + { + displayName: 'Base URL', + name: 'baseUrl', + type: 'string', + description: 'Protocol, domain and port', + placeholder: 'e.g. https://localhost:8089', + default: '', + }, + { + displayName: 'Allow Self-Signed Certificates', + name: 'allowUnauthorizedCerts', + type: 'boolean', + default: false, + }, + ]; +} diff --git a/packages/nodes-base/nodes/Splunk/GenericFunctions.ts b/packages/nodes-base/nodes/Splunk/GenericFunctions.ts new file mode 100644 index 0000000000..4be67818fa --- /dev/null +++ b/packages/nodes-base/nodes/Splunk/GenericFunctions.ts @@ -0,0 +1,268 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + IDataObject, + ILoadOptionsFunctions, + NodeApiError, +} from 'n8n-workflow'; + +import { + OptionsWithUri, +} from 'request'; + +import { + parseString, +} from 'xml2js'; + +import { + SplunkCredentials, + SplunkError, + SplunkFeedResponse, + SplunkResultResponse, + SplunkSearchResponse, +} from './types'; + +export async function splunkApiRequest( + this: IExecuteFunctions | ILoadOptionsFunctions, + method: string, + endpoint: string, + body: IDataObject = {}, + qs: IDataObject = {}, +) { + const { + authToken, + baseUrl, + allowUnauthorizedCerts, + } = await this.getCredentials('splunkApi') as SplunkCredentials; + + const options: OptionsWithUri = { + headers: { + 'Authorization': `Bearer ${authToken}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + method, + form: body, + qs, + uri: `${baseUrl}${endpoint}`, + json: true, + rejectUnauthorized: !allowUnauthorizedCerts, + useQuerystring: true, // serialize roles array as `roles=A&roles=B` + }; + + if (!Object.keys(body).length) { + delete options.body; + } + + if (!Object.keys(qs).length) { + delete options.qs; + } + + try { + return await this.helpers.request!(options).then(parseXml); + } catch (error) { + if (error?.cause?.code === 'ECONNREFUSED') { + throw new NodeApiError(this.getNode(), { ...error, code: 401 }); + } + + const rawError = await parseXml(error.error) as SplunkError; + error = extractErrorDescription(rawError); + + if ('fatal' in error) { + error = { error: error.fatal }; + } + + throw new NodeApiError(this.getNode(), error); + } +} + +// ---------------------------------------- +// utils +// ---------------------------------------- + +export function parseXml(xml: string) { + return new Promise((resolve, reject) => { + parseString(xml, { explicitArray: false }, (error, result) => { + error ? reject(error) : resolve(result); + }); + }); +} + +export function extractErrorDescription(rawError: SplunkError) { + const messages = rawError.response?.messages; + return messages + ? { [messages.msg.$.type.toLowerCase()]: messages.msg._ } + : rawError; +} + +export function toUnixEpoch(timestamp: string) { + return Date.parse(timestamp) /1000; +} + +// ---------------------------------------- +// search formatting +// ---------------------------------------- + +export function formatSearch(responseData: SplunkSearchResponse) { + const { entry: entries } = responseData; + + if (!entries) return []; + + return Array.isArray(entries) + ? entries.map(formatEntry) + : [formatEntry(entries)]; +} + +// ---------------------------------------- +// feed formatting +// ---------------------------------------- + +export function formatFeed(responseData: SplunkFeedResponse) { + const { entry: entries } = responseData.feed; + + if (!entries) return []; + + return Array.isArray(entries) + ? entries.map(formatEntry) + : [formatEntry(entries)]; +} + +// ---------------------------------------- +// result formatting +// ---------------------------------------- + +export function formatResults(responseData: SplunkResultResponse) { + const results = responseData.results.result; + if (!results) return []; + + return Array.isArray(results) + ? results.map(r => formatResult(r.field)) + : [formatResult(results.field)]; +} + +/* tslint:disable: no-any */ + +function formatResult(field: any): any { + return field.reduce((acc: any, cur: any) => { + acc = { ...acc, ...compactResult(cur) }; + return acc; + }, {}); +} + +function compactResult(splunkObject: any): any { + if (typeof splunkObject !== 'object') { + return {}; + } + + if ( + Array.isArray(splunkObject?.value) && + splunkObject?.value[0]?.text + ) { + return { + [splunkObject.$.k]: splunkObject.value + .map((v: { text: string }) => v.text) + .join(','), + }; + } + + if (!splunkObject?.$?.k || !splunkObject?.value?.text) { + return {}; + } + + return { + [splunkObject.$.k]: splunkObject.value.text, + }; +} + +// ---------------------------------------- +// entry formatting +// ---------------------------------------- + +function formatEntry(entry: any): any { + const { content, link, ...rest } = entry; + const formattedEntry = { ...rest, ...formatEntryContent(content) }; + + if (formattedEntry.id) { + formattedEntry.entryUrl = formattedEntry.id; + formattedEntry.id = formattedEntry.id.split('/').pop(); + } + + return formattedEntry; +} + +function formatEntryContent(content: any): any { + return content['s:dict']['s:key'].reduce((acc: any, cur: any) => { + acc = { ...acc, ...compactEntryContent(cur) }; + return acc; + }, {}); +} + +function compactEntryContent(splunkObject: any): any { + if (typeof splunkObject !== 'object') { + return {}; + } + + if (Array.isArray(splunkObject)) { + return splunkObject.reduce((acc, cur) => { + acc = { ...acc, ...compactEntryContent(cur) }; + return acc; + }, {}); + } + + if (splunkObject['s:dict']) { + const obj = splunkObject['s:dict']['s:key']; + return { [splunkObject.$.name]: compactEntryContent(obj) }; + } + + if (splunkObject['s:list']) { + const items = splunkObject['s:list']['s:item']; + return { [splunkObject.$.name]: items }; + } + + if (splunkObject._) { + return { + [splunkObject.$.name]: splunkObject._, + }; + } + + return { + [splunkObject.$.name]: '', + }; +} + +// ---------------------------------------- +// param loaders +// ---------------------------------------- + +/** + * Set count of entries to retrieve. + */ +export function setCount(this: IExecuteFunctions, qs: IDataObject) { + qs.count = this.getNodeParameter('returnAll', 0) + ? 0 + : this.getNodeParameter('limit', 0) as number; +} + +export function populate(source: IDataObject, destination: IDataObject) { + if (Object.keys(source).length) { + Object.assign(destination, source); + } +} + +/** + * Retrieve an ID, with tolerance when contained in an endpoint. + * The field `id` in Splunk API responses is a full link. + */ +export function getId( + this: IExecuteFunctions, + i: number, + idType: 'userId' | 'searchJobId' | 'searchConfigurationId', + endpoint: string, +) { + const id = this.getNodeParameter(idType, i) as string; + + return id.includes(endpoint) + ? id.split(endpoint).pop()! + : id; +} diff --git a/packages/nodes-base/nodes/Splunk/Splunk.node.ts b/packages/nodes-base/nodes/Splunk/Splunk.node.ts new file mode 100644 index 0000000000..00a37ea414 --- /dev/null +++ b/packages/nodes-base/nodes/Splunk/Splunk.node.ts @@ -0,0 +1,497 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + ICredentialsDecrypted, + ICredentialTestFunctions, + IDataObject, + ILoadOptionsFunctions, + INodeExecutionData, + INodeType, + INodeTypeDescription, + NodeCredentialTestResult, +} from 'n8n-workflow'; + +import { + formatFeed, + formatResults, + formatSearch, + getId, + populate, + setCount, + splunkApiRequest, + toUnixEpoch, +} from './GenericFunctions'; + +import { + firedAlertOperations, + searchConfigurationFields, + searchConfigurationOperations, + searchJobFields, + searchJobOperations, + searchResultFields, + searchResultOperations, + userFields, + userOperations, +} from './descriptions'; + +import { + SplunkCredentials, + SplunkFeedResponse, +} from './types'; + +import { + OptionsWithUri, +} from 'request'; + +export class Splunk implements INodeType { + description: INodeTypeDescription = { + displayName: 'Splunk', + name: 'splunk', + icon: 'file:splunk.svg', + group: ['transform'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Consume the Splunk Enterprise API', + defaults: { + name: 'Splunk', + color: '#e20082', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'splunkApi', + required: true, + testedBy: 'splunkApiTest', + }, + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Fired Alert', + value: 'firedAlert', + }, + { + name: 'Search Configuration', + value: 'searchConfiguration', + }, + { + name: 'Search Job', + value: 'searchJob', + }, + { + name: 'Search Result', + value: 'searchResult', + }, + { + name: 'User', + value: 'user', + }, + ], + default: 'searchJob', + }, + ...firedAlertOperations, + ...searchConfigurationOperations, + ...searchConfigurationFields, + ...searchJobOperations, + ...searchJobFields, + ...searchResultOperations, + ...searchResultFields, + ...userOperations, + ...userFields, + ], + }; + + methods = { + loadOptions: { + async getRoles(this: ILoadOptionsFunctions) { + const endpoint = '/services/authorization/roles'; + const responseData = await splunkApiRequest.call(this, 'GET', endpoint) as SplunkFeedResponse; + const { entry: entries } = responseData.feed; + + return Array.isArray(entries) + ? entries.map(entry => ({ name: entry.title, value: entry.title })) + : [{ name: entries.title, value: entries.title }]; + }, + }, + credentialTest: { + async splunkApiTest( + this: ICredentialTestFunctions, + credential: ICredentialsDecrypted, + ): Promise { + const { + authToken, + baseUrl, + allowUnauthorizedCerts, + } = credential.data as SplunkCredentials; + + const endpoint = '/services/alerts/fired_alerts'; + + const options: OptionsWithUri = { + headers: { + 'Authorization': `Bearer ${authToken}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + method: 'GET', + form: {}, + qs: {}, + uri: `${baseUrl}${endpoint}`, + json: true, + rejectUnauthorized: !allowUnauthorizedCerts, + }; + + try { + await this.helpers.request(options); + return { + status: 'OK', + message: 'Authentication successful', + }; + } catch (error) { + return { + status: 'Error', + message: error.message, + }; + } + }, + }, + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const returnData: IDataObject[] = []; + + const resource = this.getNodeParameter('resource', 0) as string; + const operation = this.getNodeParameter('operation', 0) as string; + + let responseData; + + for (let i = 0; i < items.length; i++) { + + try { + + if (resource === 'firedAlert') { + + // ********************************************************************** + // firedAlert + // ********************************************************************** + + if (operation === 'getReport') { + + // ---------------------------------------- + // firedAlert: getReport + // ---------------------------------------- + + // https://docs.splunk.com/Documentation/Splunk/latest/RESTREF/RESTsearch#alerts.2Ffired_alerts + + const endpoint = '/services/alerts/fired_alerts'; + responseData = await splunkApiRequest.call(this, 'GET', endpoint).then(formatFeed); + + } + + } else if (resource === 'searchConfiguration') { + + // ********************************************************************** + // searchConfiguration + // ********************************************************************** + + if (operation === 'delete') { + + // ---------------------------------------- + // searchConfiguration: delete + // ---------------------------------------- + + // https://docs.splunk.com/Documentation/Splunk/8.2.2/RESTREF/RESTsearch#saved.2Fsearches.2F.7Bname.7D + + const partialEndpoint = '/services/saved/searches/'; + const searchConfigurationId = getId.call( + this, i, 'searchConfigurationId', '/search/saved/searches/', + ); // id endpoint differs from operation endpoint + const endpoint = `${partialEndpoint}/${searchConfigurationId}`; + + responseData = await splunkApiRequest.call(this, 'DELETE', endpoint); + + } else if (operation === 'get') { + + // ---------------------------------------- + // searchConfiguration: get + // ---------------------------------------- + + // https://docs.splunk.com/Documentation/Splunk/8.2.2/RESTREF/RESTsearch#saved.2Fsearches.2F.7Bname.7D + + const partialEndpoint = '/services/saved/searches/'; + const searchConfigurationId = getId.call( + this, i, 'searchConfigurationId', '/search/saved/searches/', + ); // id endpoint differs from operation endpoint + const endpoint = `${partialEndpoint}/${searchConfigurationId}`; + + responseData = await splunkApiRequest.call(this, 'GET', endpoint).then(formatFeed); + + } else if (operation === 'getAll') { + + // ---------------------------------------- + // searchConfiguration: getAll + // ---------------------------------------- + + // https://docs.splunk.com/Documentation/Splunk/8.2.2/RESTREF/RESTsearch#saved.2Fsearches + + const qs = {} as IDataObject; + const options = this.getNodeParameter('options', i) as IDataObject; + + populate(options, qs); + setCount.call(this, qs); + + const endpoint = '/services/saved/searches'; + responseData = await splunkApiRequest.call(this, 'GET', endpoint, {}, qs).then(formatFeed); + + } + + } else if (resource === 'searchJob') { + + // ********************************************************************** + // searchJob + // ********************************************************************** + + if (operation === 'create') { + + // ---------------------------------------- + // searchJob: create + // ---------------------------------------- + + // https://docs.splunk.com/Documentation/Splunk/8.2.2/RESTREF/RESTsearch#search.2Fjobs + + const body = { + search: this.getNodeParameter('search', i), + } as IDataObject; + + const { + earliest_time, + latest_time, + index_earliest, + index_latest, + ...rest + } = this.getNodeParameter('additionalFields', i) as IDataObject & { + earliest_time?: string; + latest_time?: string; + index_earliest?: string, + index_latest?: string, + }; + + populate({ + ...earliest_time && { earliest_time: toUnixEpoch(earliest_time) }, + ...latest_time && { latest_time: toUnixEpoch(latest_time) }, + ...index_earliest && { index_earliest: toUnixEpoch(index_earliest) }, + ...index_latest && { index_latest: toUnixEpoch(index_latest) }, + ...rest, + }, body); + + const endpoint = '/services/search/jobs'; + responseData = await splunkApiRequest.call(this, 'POST', endpoint, body); + + const getEndpoint = `/services/search/jobs/${responseData.response.sid}`; + responseData = await splunkApiRequest.call(this, 'GET', getEndpoint).then(formatSearch); + + } else if (operation === 'delete') { + + // ---------------------------------------- + // searchJob: delete + // ---------------------------------------- + + // https://docs.splunk.com/Documentation/Splunk/8.2.2/RESTREF/RESTsearch#search.2Fjobs.2F.7Bsearch_id.7D + + const partialEndpoint = '/services/search/jobs/'; + const searchJobId = getId.call(this, i, 'searchJobId', partialEndpoint); + const endpoint = `${partialEndpoint}/${searchJobId}`; + responseData = await splunkApiRequest.call(this, 'DELETE', endpoint); + + } else if (operation === 'get') { + + // ---------------------------------------- + // searchJob: get + // ---------------------------------------- + + // https://docs.splunk.com/Documentation/Splunk/8.2.2/RESTREF/RESTsearch#search.2Fjobs.2F.7Bsearch_id.7D + + const partialEndpoint = '/services/search/jobs/'; + const searchJobId = getId.call(this, i, 'searchJobId', partialEndpoint); + const endpoint = `${partialEndpoint}/${searchJobId}`; + responseData = await splunkApiRequest.call(this, 'GET', endpoint).then(formatSearch); + + } else if (operation === 'getAll') { + + // ---------------------------------------- + // searchJob: getAll + // ---------------------------------------- + + // https://docs.splunk.com/Documentation/Splunk/8.2.2/RESTREF/RESTsearch#search.2Fjobs + + const qs = {} as IDataObject; + const options = this.getNodeParameter('options', i) as IDataObject; + + populate(options, qs); + setCount.call(this, qs); + + const endpoint = '/services/search/jobs'; + responseData = await splunkApiRequest.call(this, 'GET', endpoint, {}, qs) as SplunkFeedResponse; + responseData = formatFeed(responseData); + + } + + } else if (resource === 'searchResult') { + + // ********************************************************************** + // searchResult + // ********************************************************************** + + if (operation === 'getAll') { + + // ---------------------------------------- + // searchResult: getAll + // ---------------------------------------- + + // https://docs.splunk.com/Documentation/Splunk/latest/RESTREF/RESTsearch#search.2Fjobs.2F.7Bsearch_id.7D.2Fresults + + const searchJobId = this.getNodeParameter('searchJobId', i); + + const qs = {} as IDataObject; + const filters = this.getNodeParameter('filters', i) as IDataObject & { + keyValueMatch?: { keyValuePair?: { key: string; value: string; } } + }; + const options = this.getNodeParameter('options', i) as IDataObject; + + const keyValuePair = filters?.keyValueMatch?.keyValuePair; + + if (keyValuePair?.key && keyValuePair?.value) { + qs.search = `search ${keyValuePair.key}=${keyValuePair.value}`; + } + + populate(options, qs); + setCount.call(this, qs); + + const endpoint = `/services/search/jobs/${searchJobId}/results`; + responseData = await splunkApiRequest.call(this, 'GET', endpoint, {}, qs).then(formatResults); + + } + + } else if (resource === 'user') { + + // ********************************************************************** + // user + // ********************************************************************** + + if (operation === 'create') { + + // ---------------------------------------- + // user: create + // ---------------------------------------- + + // https://docs.splunk.com/Documentation/Splunk/8.2.2/RESTREF/RESTaccess#authentication.2Fusers + + const roles = this.getNodeParameter('roles', i) as string[]; + + const body = { + name: this.getNodeParameter('name', i), + roles, + password: this.getNodeParameter('password', i), + } as IDataObject; + + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + + populate(additionalFields, body); + + const endpoint = '/services/authentication/users'; + responseData = await splunkApiRequest.call(this, 'POST', endpoint, body) as SplunkFeedResponse; + responseData = formatFeed(responseData); + + } else if (operation === 'delete') { + + // ---------------------------------------- + // user: delete + // ---------------------------------------- + + // https://docs.splunk.com/Documentation/Splunk/8.2.2/RESTREF/RESTaccess#authentication.2Fusers.2F.7Bname.7D + + const partialEndpoint = '/services/authentication/users'; + const userId = getId.call(this, i, 'userId', partialEndpoint); + const endpoint = `${partialEndpoint}/${userId}`; + await splunkApiRequest.call(this, 'DELETE', endpoint); + responseData = { success: true }; + + } else if (operation === 'get') { + + // ---------------------------------------- + // user: get + // ---------------------------------------- + + // https://docs.splunk.com/Documentation/Splunk/8.2.2/RESTREF/RESTaccess#authentication.2Fusers.2F.7Bname.7D + + const partialEndpoint = '/services/authentication/users/'; + const userId = getId.call(this, i, 'userId', '/services/authentication/users/'); + const endpoint = `${partialEndpoint}/${userId}`; + responseData = await splunkApiRequest.call(this, 'GET', endpoint).then(formatFeed); + + } else if (operation === 'getAll') { + + // ---------------------------------------- + // user: getAll + // ---------------------------------------- + + // https://docs.splunk.com/Documentation/Splunk/8.2.2/RESTREF/RESTaccess#authentication.2Fusers + + const qs = {} as IDataObject; + setCount.call(this, qs); + + const endpoint = '/services/authentication/users'; + responseData = await splunkApiRequest.call(this, 'GET', endpoint, {}, qs).then(formatFeed); + + } else if (operation === 'update') { + + // ---------------------------------------- + // user: update + // ---------------------------------------- + + // https://docs.splunk.com/Documentation/Splunk/8.2.2/RESTREF/RESTaccess#authentication.2Fusers.2F.7Bname.7D + + const body = {} as IDataObject; + const { roles, ...rest } = this.getNodeParameter('updateFields', i) as IDataObject & { + roles: string[]; + }; + + populate({ + ...roles && { roles }, + ...rest, + }, body); + + const partialEndpoint = '/services/authentication/users/'; + const userId = getId.call(this, i, 'userId', partialEndpoint); + const endpoint = `${partialEndpoint}/${userId}`; + responseData = await splunkApiRequest.call(this, 'POST', endpoint, body).then(formatFeed); + + } + + } + + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ error: error.cause.error }); + continue; + } + + throw error; + } + + Array.isArray(responseData) + ? returnData.push(...responseData) + : returnData.push(responseData as IDataObject); + + } + + return [this.helpers.returnJsonArray(returnData)]; + } +} diff --git a/packages/nodes-base/nodes/Splunk/descriptions/FiredAlertDescription.ts b/packages/nodes-base/nodes/Splunk/descriptions/FiredAlertDescription.ts new file mode 100644 index 0000000000..b8e2598709 --- /dev/null +++ b/packages/nodes-base/nodes/Splunk/descriptions/FiredAlertDescription.ts @@ -0,0 +1,27 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const firedAlertOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: [ + 'firedAlert', + ], + }, + }, + options: [ + { + name: 'Get Report', + value: 'getReport', + description: 'Retrieve a fired alerts report', + }, + ], + default: 'getReport', + }, +]; diff --git a/packages/nodes-base/nodes/Splunk/descriptions/SearchConfigurationDescription.ts b/packages/nodes-base/nodes/Splunk/descriptions/SearchConfigurationDescription.ts new file mode 100644 index 0000000000..aff086c05c --- /dev/null +++ b/packages/nodes-base/nodes/Splunk/descriptions/SearchConfigurationDescription.ts @@ -0,0 +1,159 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const searchConfigurationOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: [ + 'searchConfiguration', + ], + }, + }, + options: [ + { + name: 'Delete', + value: 'delete', + description: 'Delete a search configuration', + }, + { + name: 'Get', + value: 'get', + description: 'Retrieve a search configuration', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Retrieve all search configurations', + }, + ], + default: 'delete', + }, +]; + +export const searchConfigurationFields: INodeProperties[] = [ + // ---------------------------------------- + // searchConfiguration: delete + // ---------------------------------------- + { + displayName: 'Search Configuration ID', + name: 'searchConfigurationId', + description: 'ID of the search configuration to delete', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'searchConfiguration', + ], + operation: [ + 'delete', + ], + }, + }, + }, + + // ---------------------------------------- + // searchConfiguration: get + // ---------------------------------------- + { + displayName: 'Search Configuration ID', + name: 'searchConfigurationId', + description: 'ID of the search configuration to retrieve', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'searchConfiguration', + ], + operation: [ + 'get', + ], + }, + }, + }, + + // ---------------------------------------- + // searchConfiguration: getAll + // ---------------------------------------- + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + default: false, + description: 'Whether to return all results or only up to a given limit', + displayOptions: { + show: { + resource: [ + 'searchConfiguration', + ], + operation: [ + 'getAll', + ], + }, + }, + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + default: 50, + description: 'Max number of results to return', + typeOptions: { + minValue: 1, + }, + displayOptions: { + show: { + resource: [ + 'searchConfiguration', + ], + operation: [ + 'getAll', + ], + returnAll: [ + false, + ], + }, + }, + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + displayOptions: { + show: { + resource: [ + 'searchConfiguration', + ], + operation: [ + 'getAll', + ], + }, + }, + options: [ + { + displayName: 'Add Orphan Field', + name: 'add_orphan_field', + description: 'Whether to include a boolean value for each saved search to show whether the search is orphaned, meaning that it has no valid owner', + type: 'boolean', + default: false, + }, + { + displayName: 'List Default Actions', + name: 'listDefaultActionArgs', + type: 'boolean', + default: false, + }, + ], + }, +]; diff --git a/packages/nodes-base/nodes/Splunk/descriptions/SearchJobDescription.ts b/packages/nodes-base/nodes/Splunk/descriptions/SearchJobDescription.ts new file mode 100644 index 0000000000..6a9890cb96 --- /dev/null +++ b/packages/nodes-base/nodes/Splunk/descriptions/SearchJobDescription.ts @@ -0,0 +1,418 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const searchJobOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: [ + 'searchJob', + ], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a search job', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete a search job', + }, + { + name: 'Get', + value: 'get', + description: 'Retrieve a search job', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Retrieve all search jobs', + }, + ], + default: 'create', + }, +]; + +export const searchJobFields: INodeProperties[] = [ + // ---------------------------------------- + // searchJob: create + // ---------------------------------------- + { + displayName: 'Query', + name: 'search', + description: 'Search language string to execute, in Splunk\'s Search Processing Language', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'searchJob', + ], + operation: [ + 'create', + ], + }, + }, + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'searchJob', + ], + operation: [ + 'create', + ], + }, + }, + options: [ + { + displayName: 'Ad Hoc Search Level', + name: 'adhoc_search_level', + type: 'options', + default: 'verbose', + options: [ + { + name: 'Fast', + value: 'fast', + }, + { + name: 'Smart', + value: 'smart', + }, + { + name: 'Verbose', + value: 'verbose', + }, + ], + }, + { + displayName: 'Auto-Cancel After (Seconds)', + name: 'auto_cancel', + type: 'number', + default: 0, + description: 'Seconds after which the search job automatically cancels', + }, + { + displayName: 'Auto-Finalize After (Num Events)', + name: 'auto_finalize_ec', + type: 'number', + default: 0, + description: 'Auto-finalize the search after at least this many events are processed', + }, + { + displayName: 'Auto Pause After (Seconds)', + name: 'auto_pause', + type: 'number', + default: 0, + description: 'Seconds of inactivity after which the search job automatically pauses', + }, + { + displayName: 'Earliest Index', + name: 'index_earliest', + type: 'dateTime', + default: '', + description: 'The earliest index time for the search (inclusive)', + }, + { + displayName: 'Earliest Time', + name: 'earliest_time', + type: 'dateTime', + default: '', + description: 'The earliest cut-off for the search (inclusive)', + }, + { + displayName: 'Exec Mode', + name: 'exec_mode', + type: 'options', + default: 'blocking', + options: [ + { + name: 'Blocking', + value: 'blocking', + }, + { + name: 'Normal', + value: 'normal', + }, + { + name: 'One Shot', + value: 'oneshot', + }, + ], + }, + { + displayName: 'Indexed Real Time Offset', + name: 'indexedRealtimeOffset', + type: 'number', + default: 0, + description: 'Seconds of disk sync delay for indexed real-time search', + }, + { + displayName: 'Latest Index', + name: 'index_latest', + type: 'dateTime', + default: '', + description: 'The latest index time for the search (inclusive)', + }, + { + displayName: 'Latest Time', + name: 'latest_time', + type: 'dateTime', + default: '', + description: 'The latest cut-off for the search (inclusive)', + }, + { + displayName: 'Max Time', + name: 'max_time', + type: 'number', + default: 0, + description: 'Number of seconds to run this search before finalizing. Enter 0 to never finalize.', + }, + { + displayName: 'Namespace', + name: 'namespace', + type: 'string', + default: '', + description: 'Application namespace in which to restrict searches', + }, + { + displayName: 'Reduce Frequency', + name: 'reduce_freq', + type: 'number', + default: 0, + description: 'How frequently to run the MapReduce reduce phase on accumulated map values', + }, + { + displayName: 'Remote Server List', + name: 'remote_server_list', + type: 'string', + default: '', + description: 'Comma-separated list of (possibly wildcarded) servers from which raw events should be pulled. This same server list is to be used in subsearches.', + }, + { + displayName: 'Reuse Limit (Seconds)', + name: 'reuse_max_seconds_ago', + type: 'number', + default: 0, + description: 'Number of seconds ago to check when an identical search is started and return the job\’s search ID instead of starting a new job', + }, + { + displayName: 'Required Field', + name: 'rf', + type: 'string', + default: '', + description: 'Name of a required field to add to the search. Even if not referenced or used directly by the search, a required field is still included in events and summary endpoints.', + }, + { + displayName: 'Search Mode', + name: 'search_mode', + type: 'options', + default: 'normal', + options: [ + { + name: 'Normal', + value: 'normal', + }, + { + name: 'Real Time', + value: 'realtime', + }, + ], + }, + { + displayName: 'Status Buckets', + name: 'status_buckets', + type: 'number', + default: 0, + description: 'The most status buckets to generate. Set 0 generate no timeline information.', + }, + { + displayName: 'Timeout', + name: 'timeout', + type: 'number', + default: 86400, + description: 'Number of seconds to keep this search after processing has stopped', + }, + { + displayName: 'Workload Pool', + name: 'workload_pool', + type: 'string', + default: '', + description: 'New workload pool where the existing running search should be placed', + }, + ], + }, + + // ---------------------------------------- + // searchJob: delete + // ---------------------------------------- + { + displayName: 'Search ID', + name: 'searchJobId', + description: 'ID of the search job to delete', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'searchJob', + ], + operation: [ + 'delete', + ], + }, + }, + }, + + // ---------------------------------------- + // searchJob: get + // ---------------------------------------- + { + displayName: 'Search ID', + name: 'searchJobId', + description: 'ID of the search job to retrieve', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'searchJob', + ], + operation: [ + 'get', + ], + }, + }, + }, + + // ---------------------------------------- + // searchJob: getAll + // ---------------------------------------- + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + default: false, + description: 'Whether to return all results or only up to a given limit', + displayOptions: { + show: { + resource: [ + 'searchJob', + ], + operation: [ + 'getAll', + ], + }, + }, + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + default: 50, + description: 'Max number of results to return', + typeOptions: { + minValue: 1, + }, + displayOptions: { + show: { + resource: [ + 'searchJob', + ], + operation: [ + 'getAll', + ], + returnAll: [ + false, + ], + }, + }, + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + displayOptions: { + show: { + resource: [ + 'searchJob', + ], + operation: [ + 'getAll', + ], + }, + }, + options: [ + { + displayName: 'Sort Direction', + name: 'sort_dir', + type: 'options', + options: [ + { + name: 'Ascending', + value: 'asc', + }, + { + name: 'Descending', + value: 'desc', + }, + ], + default: 'asc', + }, + { + displayName: 'Sort Key', + name: 'sort_key', + description: 'Key name to use for sorting', + type: 'string', + default: '', + }, + { + displayName: 'Sort Mode', + name: 'sort_mode', + type: 'options', + options: [ + { + name: 'Automatic', + value: 'auto', + description: 'If all field values are numeric, collate numerically. Otherwise, collate alphabetically.', + }, + { + name: 'Alphabetic', + value: 'alpha', + description: 'Collate alphabetically, case-insensitive', + }, + { + name: 'Alphabetic and Case-Sensitive', + value: 'alpha_case', + description: 'Collate alphabetically, case-sensitive', + }, + { + name: 'Numeric', + value: 'num', + description: 'Collate numerically', + }, + ], + default: 'auto', + }, + ], + }, +]; diff --git a/packages/nodes-base/nodes/Splunk/descriptions/SearchResultDescription.ts b/packages/nodes-base/nodes/Splunk/descriptions/SearchResultDescription.ts new file mode 100644 index 0000000000..df11c82adc --- /dev/null +++ b/packages/nodes-base/nodes/Splunk/descriptions/SearchResultDescription.ts @@ -0,0 +1,166 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const searchResultOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: [ + 'searchResult', + ], + }, + }, + options: [ + { + name: 'Get All', + value: 'getAll', + description: 'Retrieve all search results for a search job', + }, + ], + default: 'getAll', + }, +]; + +export const searchResultFields: INodeProperties[] = [ + // ---------------------------------------- + // searchResult: getAll + // ---------------------------------------- + { + displayName: 'Search ID', + name: 'searchJobId', + description: 'ID of the search whose results to retrieve', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'searchResult', + ], + operation: [ + 'getAll', + ], + }, + }, + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + default: false, + description: 'Whether to return all results or only up to a given limit', + displayOptions: { + show: { + resource: [ + 'searchResult', + ], + operation: [ + 'getAll', + ], + }, + }, + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + default: 50, + description: 'Max number of results to return', + typeOptions: { + minValue: 1, + }, + displayOptions: { + show: { + resource: [ + 'searchResult', + ], + operation: [ + 'getAll', + ], + returnAll: [ + false, + ], + }, + }, + }, + { + displayName: 'Filters', + name: 'filters', + type: 'collection', + placeholder: 'Add Filter', + default: {}, + displayOptions: { + show: { + resource: [ + 'searchResult', + ], + operation: [ + 'getAll', + ], + }, + }, + options: [ + { + displayName: 'Key-Value Match', + name: 'keyValueMatch', + description: 'Key-value pair to match against. Example: if "Key" is set to user and "Field" is set to john, only the results where user is john will be returned. ', + type: 'fixedCollection', + default: {}, + placeholder: 'Add Key-Value Pair', + options: [ + { + displayName: 'Key-Value Pair', + name: 'keyValuePair', + values: [ + { + displayName: 'Key', + name: 'key', + description: 'Key to match against', + type: 'string', + default: '', + }, + { + displayName: 'Value', + name: 'value', + description: 'value to match against', + type: 'string', + default: '', + }, + ], + }, + ], + }, + ], + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + displayOptions: { + show: { + resource: [ + 'searchResult', + ], + operation: [ + 'getAll', + ], + }, + }, + options: [ + { + displayName: 'Add Summary to Metadata', + name: 'add_summary_to_metadata', + description: 'Whether to include field summary statistics in the response', + type: 'boolean', + default: false, + }, + ], + }, +]; diff --git a/packages/nodes-base/nodes/Splunk/descriptions/UserDescription.ts b/packages/nodes-base/nodes/Splunk/descriptions/UserDescription.ts new file mode 100644 index 0000000000..cfffe78572 --- /dev/null +++ b/packages/nodes-base/nodes/Splunk/descriptions/UserDescription.ts @@ -0,0 +1,300 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const userOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: [ + 'user', + ], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create an user', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete an user', + }, + { + name: 'Get', + value: 'get', + description: 'Retrieve an user', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Retrieve all users', + }, + { + name: 'Update', + value: 'update', + description: 'Update an user', + }, + ], + default: 'create', + }, +]; + +export const userFields: INodeProperties[] = [ + // ---------------------------------------- + // user: create + // ---------------------------------------- + { + displayName: 'Name', + name: 'name', + description: 'Login name of the user', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'user', + ], + operation: [ + 'create', + ], + }, + }, + }, + { + displayName: 'Roles', + name: 'roles', + type: 'multiOptions', + description: 'Comma-separated list of roles to assign to the user', + required: true, + default: [], + typeOptions: { + loadOptionsMethod: 'getRoles', + }, + displayOptions: { + show: { + resource: [ + 'user', + ], + operation: [ + 'create', + ], + }, + }, + }, + { + displayName: 'Password', + name: 'password', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'user', + ], + operation: [ + 'create', + ], + }, + }, + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'user', + ], + operation: [ + 'create', + ], + }, + }, + options: [ + { + displayName: 'Email', + name: 'email', + type: 'string', + default: '', + }, + { + displayName: 'Full Name', + name: 'realname', + type: 'string', + default: '', + description: 'Full name of the user', + }, + ], + }, + + // ---------------------------------------- + // user: delete + // ---------------------------------------- + { + displayName: 'User ID', + name: 'userId', + description: 'ID of the user to delete', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'user', + ], + operation: [ + 'delete', + ], + }, + }, + }, + + // ---------------------------------------- + // user: get + // ---------------------------------------- + { + displayName: 'User ID', + name: 'userId', + description: 'ID of the user to retrieve', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'user', + ], + operation: [ + 'get', + ], + }, + }, + }, + + // ---------------------------------------- + // user: getAll + // ---------------------------------------- +{ + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + default: false, + description: 'Whether to return all results or only up to a given limit', + displayOptions: { + show: { + resource: [ + 'user', + ], + operation: [ + 'getAll', + ], + }, + }, + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + default: 50, + description: 'Max number of results to return', + typeOptions: { + minValue: 1, + }, + displayOptions: { + show: { + resource: [ + 'user', + ], + operation: [ + 'getAll', + ], + returnAll: [ + false, + ], + }, + }, + }, + + // ---------------------------------------- + // user: update + // ---------------------------------------- + { + displayName: 'User ID', + name: 'userId', + description: 'ID of the user to update', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'user', + ], + operation: [ + 'update', + ], + }, + }, + }, + { + displayName: 'Update Fields', + name: 'updateFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'user', + ], + operation: [ + 'update', + ], + }, + }, + options: [ + { + displayName: 'Email', + name: 'email', + type: 'string', + default: '', + }, + { + displayName: 'Full Name', + name: 'realname', + type: 'string', + default: '', + description: 'Full name of the user', + }, + { + displayName: 'Password', + name: 'password', + type: 'string', + default: '', + }, + { + displayName: 'Roles', + name: 'roles', + type: 'multiOptions', + description: 'Comma-separated list of roles to assign to the user', + required: true, + default: [], + typeOptions: { + loadOptionsMethod: 'getRoles', + }, + }, + ], + }, +]; diff --git a/packages/nodes-base/nodes/Splunk/descriptions/index.ts b/packages/nodes-base/nodes/Splunk/descriptions/index.ts new file mode 100644 index 0000000000..69565d677b --- /dev/null +++ b/packages/nodes-base/nodes/Splunk/descriptions/index.ts @@ -0,0 +1,5 @@ +export * from './FiredAlertDescription'; +export * from './SearchConfigurationDescription'; +export * from './SearchJobDescription'; +export * from './SearchResultDescription'; +export * from './UserDescription'; diff --git a/packages/nodes-base/nodes/Splunk/splunk.svg b/packages/nodes-base/nodes/Splunk/splunk.svg new file mode 100644 index 0000000000..808255d4fb --- /dev/null +++ b/packages/nodes-base/nodes/Splunk/splunk.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/nodes-base/nodes/Splunk/types.d.ts b/packages/nodes-base/nodes/Splunk/types.d.ts new file mode 100644 index 0000000000..fd8cd218bb --- /dev/null +++ b/packages/nodes-base/nodes/Splunk/types.d.ts @@ -0,0 +1,30 @@ +export type SplunkCredentials = { + authToken: string; + baseUrl: string; + allowUnauthorizedCerts: boolean; +}; + +export type SplunkFeedResponse = { + feed: { + entry: { title: string }; + }; +}; + +export type SplunkSearchResponse = { + entry: { title: string }; +}; + +export type SplunkResultResponse = { + results: { result: Array<{ field: string }> } | { result: { field: string } }; +}; + +export type SplunkError = { + response?: { + messages?: { + msg: { + $: { type: string }; + _: string; + } + } + } +}; diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 0e09038a09..a202828a8d 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -255,6 +255,7 @@ "dist/credentials/SshPrivateKey.credentials.js", "dist/credentials/Sftp.credentials.js", "dist/credentials/Signl4Api.credentials.js", + "dist/credentials/SplunkApi.credentials.js", "dist/credentials/SpontitApi.credentials.js", "dist/credentials/SpotifyOAuth2Api.credentials.js", "dist/credentials/StoryblokContentApi.credentials.js", @@ -559,6 +560,7 @@ "dist/nodes/Sms77/Sms77.node.js", "dist/nodes/Snowflake/Snowflake.node.js", "dist/nodes/SplitInBatches.node.js", + "dist/nodes/Splunk/Splunk.node.js", "dist/nodes/Spontit/Spontit.node.js", "dist/nodes/Spotify/Spotify.node.js", "dist/nodes/SpreadsheetFile.node.js", From 3a0b96bf32bc2c60cce71d58acf63c5b764344fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 28 Sep 2021 21:23:57 +0200 Subject: [PATCH 007/290] :bug: Fix TypeError in nodes panel text selection (#2258) --- packages/editor-ui/src/components/NodeCreator/MainPanel.vue | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/editor-ui/src/components/NodeCreator/MainPanel.vue b/packages/editor-ui/src/components/NodeCreator/MainPanel.vue index 98c00f8ff0..33bb7aede5 100644 --- a/packages/editor-ui/src/components/NodeCreator/MainPanel.vue +++ b/packages/editor-ui/src/components/NodeCreator/MainPanel.vue @@ -214,11 +214,11 @@ export default mixins(externalHooks).extend({ this.activeIndex = Math.max(this.activeIndex, 0); } else if (e.key === 'Enter' && activeNodeType) { this.selected(activeNodeType); - } else if (e.key === 'ArrowRight' && activeNodeType.type === 'subcategory') { + } else if (e.key === 'ArrowRight' && activeNodeType && activeNodeType.type === 'subcategory') { this.selected(activeNodeType); - } else if (e.key === 'ArrowRight' && activeNodeType.type === 'category' && !activeNodeType.properties.expanded) { + } else if (e.key === 'ArrowRight' && activeNodeType && activeNodeType.type === 'category' && !activeNodeType.properties.expanded) { this.selected(activeNodeType); - } else if (e.key === 'ArrowLeft' && activeNodeType.type === 'category' && activeNodeType.properties.expanded) { + } else if (e.key === 'ArrowLeft' && activeNodeType && activeNodeType.type === 'category' && activeNodeType.properties.expanded) { this.selected(activeNodeType); } }, From 013c630090ddf0c61c04904f5ebfe2228c7c5ab7 Mon Sep 17 00:00:00 2001 From: Gabriel <83644514+gpene@users.noreply.github.com> Date: Tue, 28 Sep 2021 22:25:54 +0300 Subject: [PATCH 008/290] :books: Update Webhook parameters (#2251) * :books: Update Webhook parameters Capitalization inconsistent, + spelling and minor changes * update Wait description --- packages/nodes-base/nodes/Wait.node.ts | 2 +- packages/nodes-base/nodes/Webhook.node.ts | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/nodes-base/nodes/Wait.node.ts b/packages/nodes-base/nodes/Wait.node.ts index 33077036f3..05a7c14d7b 100644 --- a/packages/nodes-base/nodes/Wait.node.ts +++ b/packages/nodes-base/nodes/Wait.node.ts @@ -283,7 +283,7 @@ export class Wait implements INodeType { description: 'The HTTP Response code to return', }, { - displayName: 'Respond when', + displayName: 'Respond When', name: 'responseMode', type: 'options', displayOptions: { diff --git a/packages/nodes-base/nodes/Webhook.node.ts b/packages/nodes-base/nodes/Webhook.node.ts index 30cfde43e1..a6064398cf 100644 --- a/packages/nodes-base/nodes/Webhook.node.ts +++ b/packages/nodes-base/nodes/Webhook.node.ts @@ -132,7 +132,7 @@ export class Webhook implements INodeType { }, ], default: 'GET', - description: 'The HTTP method to liste to.', + description: 'The HTTP method to listen to.', }, { displayName: 'Path', @@ -155,7 +155,7 @@ export class Webhook implements INodeType { description: 'The HTTP Response code to return', }, { - displayName: 'Respond when', + displayName: 'Respond When', name: 'responseMode', type: 'options', options: [ @@ -202,7 +202,7 @@ export class Webhook implements INodeType { }, ], default: 'firstEntryJson', - description: 'What data should be returned. If it should return
all the itemsas array or only the first item as object.', + description: 'What data should be returned. If it should return
all items as an array or only the first item as object.', }, { displayName: 'Property Name', @@ -253,8 +253,8 @@ export class Webhook implements INodeType { ], }, }, - description: `Name of the binary property to which to write the data of
- the received file. If the data gets received via "Form-Data Multipart"
+ description: `Name of the binary property to write the data of
+ the received file to. If the data gets received via "Form-Data Multipart"
it will be the prefix and a number starting with 0 will be attached to it.`, }, { From abfadae68aa793edcdafb4bd2d186798ec599385 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Tue, 28 Sep 2021 14:32:16 -0500 Subject: [PATCH 009/290] :zap: Add SECURITY.md file --- SECURITY.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 SECURITY.md diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000000..7c60b728d9 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,4 @@ +## Reporting a Vulnerability + +Please report (suspected) security vulnerabilities to **[security@n8n.io](mailto:security@n8n.io)**. You will receive a response from +us within 48 hours. If the issue is confirmed, we will release a patch as soon as possible depending on complexity but historically within a few days. From 6c6ae5d60724b908f6e91e2e9878311855f52dfa Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Tue, 28 Sep 2021 22:25:10 +0000 Subject: [PATCH 010/290] =?UTF-8?q?:bookmark:=20Release=C2=A0n8n-core@0.85?= =?UTF-8?q?.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/core/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/package.json b/packages/core/package.json index 2f9cf99d5b..f13c3af2a8 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "n8n-core", - "version": "0.84.0", + "version": "0.85.0", "description": "Core functionality of n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", From 48f48d2bf965e15b50c7d6a8dc6b8348185a6de4 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Tue, 28 Sep 2021 22:25:17 +0000 Subject: [PATCH 011/290] :arrow_up: Set n8n-core@0.85.0 on n8n-node-dev --- packages/node-dev/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/node-dev/package.json b/packages/node-dev/package.json index 72d12b3088..e91058147d 100644 --- a/packages/node-dev/package.json +++ b/packages/node-dev/package.json @@ -60,7 +60,7 @@ "change-case": "^4.1.1", "copyfiles": "^2.1.1", "inquirer": "^7.0.1", - "n8n-core": "~0.84.0", + "n8n-core": "~0.85.0", "n8n-workflow": "~0.70.0", "oauth-1.0a": "^2.2.6", "replace-in-file": "^6.0.0", From fdc223a7792d23ee38452fe52e3cc7ed2a9e29e3 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Tue, 28 Sep 2021 22:25:17 +0000 Subject: [PATCH 012/290] =?UTF-8?q?:bookmark:=20Release=C2=A0n8n-node-dev@?= =?UTF-8?q?0.25.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/node-dev/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/node-dev/package.json b/packages/node-dev/package.json index e91058147d..6223a62818 100644 --- a/packages/node-dev/package.json +++ b/packages/node-dev/package.json @@ -1,6 +1,6 @@ { "name": "n8n-node-dev", - "version": "0.24.0", + "version": "0.25.0", "description": "CLI to simplify n8n credentials/node development", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", From b9a198df5e650c10834ac1466f4d9aabb1231028 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Tue, 28 Sep 2021 22:25:25 +0000 Subject: [PATCH 013/290] :arrow_up: Set n8n-core@0.85.0 on n8n-nodes-base --- packages/nodes-base/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index a202828a8d..9ac92e2159 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -694,7 +694,7 @@ "mqtt": "4.2.6", "mssql": "^6.2.0", "mysql2": "~2.3.0", - "n8n-core": "~0.84.0", + "n8n-core": "~0.85.0", "node-ssh": "^11.0.0", "nodemailer": "^6.5.0", "pdf-parse": "^1.1.1", From 2c778affc10d1c321306dd37d1554071b4dcea6a Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Tue, 28 Sep 2021 22:25:25 +0000 Subject: [PATCH 014/290] =?UTF-8?q?:bookmark:=20Release=C2=A0n8n-nodes-bas?= =?UTF-8?q?e@0.137.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/nodes-base/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 9ac92e2159..0e41de7a59 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -1,6 +1,6 @@ { "name": "n8n-nodes-base", - "version": "0.136.0", + "version": "0.137.0", "description": "Base nodes of n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", From e0cca284d83a5ab30707e3cf03cf138bd477da45 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Tue, 28 Sep 2021 22:25:58 +0000 Subject: [PATCH 015/290] =?UTF-8?q?:bookmark:=20Release=C2=A0n8n-editor-ui?= =?UTF-8?q?@0.108.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/editor-ui/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index 01b9397d23..b847832baa 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -1,6 +1,6 @@ { "name": "n8n-editor-ui", - "version": "0.107.1", + "version": "0.108.0", "description": "Workflow Editor UI for n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", From dfce6476e3c04bcd8155b4a9a0421d8bdf6fed1d Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Tue, 28 Sep 2021 22:26:19 +0000 Subject: [PATCH 016/290] :arrow_up: Set n8n-core@0.85.0, n8n-editor-ui@0.108.0 and n8n-nodes-base@0.137.0 on n8n --- packages/cli/package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 82be50a398..f60131dd14 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -109,9 +109,9 @@ "localtunnel": "^2.0.0", "lodash.get": "^4.4.2", "mysql2": "~2.3.0", - "n8n-core": "~0.84.0", - "n8n-editor-ui": "~0.107.1", - "n8n-nodes-base": "~0.136.0", + "n8n-core": "~0.85.0", + "n8n-editor-ui": "~0.108.0", + "n8n-nodes-base": "~0.137.0", "n8n-workflow": "~0.70.0", "oauth-1.0a": "^2.2.6", "open": "^7.0.0", From 4db91d55dd44a8639612512c0b81781874f517b7 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Tue, 28 Sep 2021 22:26:19 +0000 Subject: [PATCH 017/290] =?UTF-8?q?:bookmark:=20Release=C2=A0n8n@0.140.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index f60131dd14..2bbe994c0c 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "0.139.1", + "version": "0.140.0", "description": "n8n Workflow Automation Tool", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", From a144a8e315b6f619d5651495e16fa4f480adb994 Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Wed, 29 Sep 2021 19:28:27 -0400 Subject: [PATCH 018/290] :sparkles: Add SeaTable node and trigger (#2240) * Add SeaTable node Node for SeaTable, initial credentials, trigger- and standard-node. Contribution-by: SeaTable GmbH Signed-off-by: Tom Klingenberg * :zap: Improvements * :zap: Improvements * :zap: Fix node and method names and table parameter * :zap: Change display name for now again Co-authored-by: Tom Klingenberg Co-authored-by: Jan Oberhauser --- .../credentials/SeaTableApi.credentials.ts | 48 +++ .../nodes/SeaTable/GenericFunctions.ts | 294 +++++++++++++++ .../nodes-base/nodes/SeaTable/Interfaces.ts | 102 +++++ .../nodes/SeaTable/RowDescription.ts | 348 ++++++++++++++++++ packages/nodes-base/nodes/SeaTable/Schema.ts | 49 +++ .../nodes/SeaTable/SeaTable.node.json | 72 ++++ .../nodes/SeaTable/SeaTable.node.ts | 319 ++++++++++++++++ .../nodes/SeaTable/SeaTableTrigger.node.json | 20 + .../nodes/SeaTable/SeaTableTrigger.node.ts | 158 ++++++++ .../nodes-base/nodes/SeaTable/seaTable.svg | 1 + packages/nodes-base/nodes/SeaTable/types.d.ts | 69 ++++ packages/nodes-base/package.json | 3 + 12 files changed, 1483 insertions(+) create mode 100644 packages/nodes-base/credentials/SeaTableApi.credentials.ts create mode 100644 packages/nodes-base/nodes/SeaTable/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/SeaTable/Interfaces.ts create mode 100644 packages/nodes-base/nodes/SeaTable/RowDescription.ts create mode 100644 packages/nodes-base/nodes/SeaTable/Schema.ts create mode 100644 packages/nodes-base/nodes/SeaTable/SeaTable.node.json create mode 100644 packages/nodes-base/nodes/SeaTable/SeaTable.node.ts create mode 100644 packages/nodes-base/nodes/SeaTable/SeaTableTrigger.node.json create mode 100644 packages/nodes-base/nodes/SeaTable/SeaTableTrigger.node.ts create mode 100644 packages/nodes-base/nodes/SeaTable/seaTable.svg create mode 100644 packages/nodes-base/nodes/SeaTable/types.d.ts diff --git a/packages/nodes-base/credentials/SeaTableApi.credentials.ts b/packages/nodes-base/credentials/SeaTableApi.credentials.ts new file mode 100644 index 0000000000..e521cc6e0c --- /dev/null +++ b/packages/nodes-base/credentials/SeaTableApi.credentials.ts @@ -0,0 +1,48 @@ +import { + ICredentialType, + INodeProperties, +} from 'n8n-workflow'; + +export class SeaTableApi implements ICredentialType { + name = 'seaTableApi'; + displayName = 'SeaTable API'; + documentationUrl = 'seaTable'; + properties: INodeProperties[] = [ + { + displayName: 'Environment', + name: 'environment', + type: 'options', + default: 'cloudHosted', + options: [ + { + name: 'Cloud-hosted', + value: 'cloudHosted', + }, + { + name: 'Self-hosted', + value: 'selfHosted', + }, + ], + }, + { + displayName: 'Self-hosted domain', + name: 'domain', + type: 'string', + default: '', + placeholder: 'https://www.mydomain.com', + displayOptions: { + show: { + environment: [ + 'selfHosted', + ], + }, + }, + }, + { + displayName: 'API Token (of a Base)', + name: 'token', + type: 'string', + default: '', + }, + ]; +} diff --git a/packages/nodes-base/nodes/SeaTable/GenericFunctions.ts b/packages/nodes-base/nodes/SeaTable/GenericFunctions.ts new file mode 100644 index 0000000000..267e9390f1 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/GenericFunctions.ts @@ -0,0 +1,294 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + OptionsWithUri, +} from 'request'; + +import { + IDataObject, + ILoadOptionsFunctions, + IPollFunctions, + NodeApiError, +} from 'n8n-workflow'; + +import { + TDtableMetadataColumns, + TDtableViewColumns, + TEndpointResolvedExpr, + TEndpointVariableName, +} from './types'; + +import { + schema, +} from './Schema'; + +import { + ICredential, + ICtx, + IDtableMetadataColumn, + IEndpointVariables, + IName, + IRow, + IRowObject, +} from './Interfaces'; + +import * as _ from 'lodash'; + +export async function seaTableApiRequest(this: IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions, ctx: ICtx, method: string, endpoint: string, body: any = {}, qs: IDataObject = {}, url: string | undefined = undefined, option: IDataObject = {}): Promise { // tslint:disable-line:no-any + + const credentials = await this.getCredentials('seaTableApi'); + + ctx.credentials = credentials as unknown as ICredential; + + await getBaseAccessToken.call(this, ctx); + + const options: OptionsWithUri = { + headers: { + Authorization: `Token ${ctx?.base?.access_token}`, + }, + method, + qs, + body, + uri: url || `${resolveBaseUri(ctx)}${endpointCtxExpr(ctx, endpoint)}`, + json: true, + }; + + if (Object.keys(body).length === 0) { + delete options.body; + } + + if (Object.keys(option).length !== 0) { + Object.assign(options, option); + } + + try { + //@ts-ignore + return await this.helpers.request!(options); + } catch (error) { + throw new NodeApiError(this.getNode(), error); + } +} + +export async function setableApiRequestAllItems(this: IExecuteFunctions | IPollFunctions, ctx: ICtx, propertyName: string, method: string, endpoint: string, body: IDataObject, query?: IDataObject): Promise { // tslint:disable-line:no-any + + if (query === undefined) { + query = {}; + } + const segment = schema.rowFetchSegmentLimit; + query.start = 0; + query.limit = segment; + + const returnData: IDataObject[] = []; + + let responseData; + + do { + responseData = await seaTableApiRequest.call(this, ctx, method, endpoint, body, query) as unknown as IRow[]; + //@ts-ignore + returnData.push.apply(returnData, responseData[propertyName]); + query.start = +query.start + segment; + } while (responseData && responseData.length > segment - 1); + + return returnData; +} + + +export async function getTableColumns(this: ILoadOptionsFunctions | IExecuteFunctions | IPollFunctions, tableName: string, ctx: ICtx = {}): Promise { + const { metadata: { tables } } = await seaTableApiRequest.call(this, ctx, 'GET', `/dtable-server/api/v1/dtables/{{dtable_uuid}}/metadata`); + for (const table of tables) { + if (table.name === tableName) { + return table.columns; + } + } + return []; +} + +export async function getTableViews(this: ILoadOptionsFunctions | IExecuteFunctions, tableName: string, ctx: ICtx = {}): Promise { + const { views } = await seaTableApiRequest.call(this, ctx, 'GET', `/dtable-server/api/v1/dtables/{{dtable_uuid}}/views`, {}, { table_name: tableName }); + return views; +} + +export async function getBaseAccessToken(this: IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions, ctx: ICtx) { + + if (ctx?.base?.access_token !== undefined) { + return; + } + + const options: OptionsWithUri = { + headers: { + Authorization: `Token ${ctx?.credentials?.token}`, + }, + uri: `${resolveBaseUri(ctx)}/api/v2.1/dtable/app-access-token/`, + json: true, + }; + + ctx.base = await this.helpers.request!(options); +} + +export function resolveBaseUri(ctx: ICtx) { + return (ctx?.credentials?.environment === 'cloudHosted') + ? 'https://cloud.seatable.io' : ctx?.credentials?.domain; +} + +export function simplify(data: { results: IRow[] }, metadata: IDataObject) { + return data.results.map((row: IDataObject) => { + for (const key of Object.keys(row)) { + if (!key.startsWith('_')) { + row[metadata[key] as string] = row[key]; + delete row[key]; + } + } + return row; + }); +} + +export function getColumns(data: { metadata: [{ key: string, name: string }] }) { + return data.metadata.reduce((obj, value) => Object.assign(obj, { [`${value.key}`]: value.name }), {}); +} + +export function getDownloadableColumns(data: { metadata: [{ key: string, name: string, type: string }] }) { + return data.metadata.filter(row => (['image', 'file'].includes(row.type))).map(row => row.name); +} + +const uniquePredicate = (current: string, index: number, all: string[]) => all.indexOf(current) === index; +const nonInternalPredicate = (name: string) => !Object.keys(schema.internalNames).includes(name); +const namePredicate = (name: string) => (named: IName) => named.name === name; +export const nameOfPredicate = (names: ReadonlyArray) => (name: string) => names.find(namePredicate(name)); + +export function columnNamesToArray(columnNames: string): string[] { + return columnNames + ? split(columnNames) + .filter(nonInternalPredicate) + .filter(uniquePredicate) + : [] + ; +} + +export function columnNamesGlob(columnNames: string[], dtableColumns: TDtableMetadataColumns): string[] { + const buffer: string[] = []; + const names: string[] = dtableColumns.map(c => c.name).filter(nonInternalPredicate); + columnNames.forEach(columnName => { + if (columnName !== '*') { + buffer.push(columnName); + return; + } + buffer.push(...names); + }); + return buffer.filter(uniquePredicate); +} + +/** + * sequence rows on _seq + */ +export function rowsSequence(rows: IRow[]) { + const l = rows.length; + if (l) { + const [first] = rows; + if (first && first._seq !== undefined) { + return; + } + } + for (let i = 0; i < l;) { + rows[i]._seq = ++i; + } +} + +export function rowDeleteInternalColumns(row: IRow): IRow { + Object.keys(schema.internalNames).forEach(columnName => delete row[columnName]); + return row; +} + +export function rowsDeleteInternalColumns(rows: IRow[]) { + rows = rows.map(rowDeleteInternalColumns); +} + +function rowFormatColumn(input: unknown): boolean | number | string | string[] | null { + if (null === input || undefined === input) { + return null; + } + + if (typeof input === 'boolean' || typeof input === 'number' || typeof input === 'string') { + return input; + } + + if (Array.isArray(input) && input.every(i => (typeof i === 'string'))) { + return input; + } + + return null; +} + +export function rowFormatColumns(row: IRow, columnNames: string[]): IRow { + const outRow = {} as IRow; + columnNames.forEach((c) => (outRow[c] = rowFormatColumn(row[c]))); + return outRow; +} + +export function rowsFormatColumns(rows: IRow[], columnNames: string[]) { + rows = rows.map((row) => rowFormatColumns(row, columnNames)); +} + +export function rowMapKeyToName(row: IRow, columns: TDtableMetadataColumns): IRow { + const mappedRow = {} as IRow; + + // move internal columns first + Object.keys(schema.internalNames).forEach((key) => { + if (row[key]) { + mappedRow[key] = row[key]; + delete row[key]; + } + }); + + // pick each by its key for name + Object.keys(row).forEach(key => { + const column = columns.find(c => c.key === key); + if (column) { + mappedRow[column.name] = row[key]; + } + }); + + return mappedRow; +} + +export function rowExport(row: IRowObject, columns: TDtableMetadataColumns): IRowObject { + for (const columnName of Object.keys(columns)) { + if (!columns.find(namePredicate(columnName))) { + delete row[columnName]; + } + } + return row; +} + +export const dtableSchemaIsColumn = (column: IDtableMetadataColumn): boolean => + !!schema.columnTypes[column.type]; + +const dtableSchemaIsUpdateAbleColumn = (column: IDtableMetadataColumn): boolean => + !!schema.columnTypes[column.type] && !schema.nonUpdateAbleColumnTypes[column.type]; + +export const dtableSchemaColumns = (columns: TDtableMetadataColumns): TDtableMetadataColumns => + columns.filter(dtableSchemaIsColumn); + +export const updateAble = (columns: TDtableMetadataColumns): TDtableMetadataColumns => + columns.filter(dtableSchemaIsUpdateAbleColumn); + +function endpointCtxExpr(this: void, ctx: ICtx, endpoint: string): string { + const endpointVariables: IEndpointVariables = {}; + endpointVariables.access_token = ctx?.base?.access_token; + endpointVariables.dtable_uuid = ctx?.base?.dtable_uuid; + + return endpoint.replace(/({{ *(access_token|dtable_uuid|server) *}})/g, (match: string, expr: string, name: TEndpointVariableName) => { + return endpointVariables[name] || match; + }) as TEndpointResolvedExpr; +} + + +const normalize = (subject: string): string => subject ? subject.normalize() : ''; + +export const split = (subject: string): string[] => + normalize(subject) + .split(/\s*((?:[^\\,]*?(?:\\[\s\S])*)*?)\s*(?:,|$)/) + .filter(s => s.length) + .map(s => s.replace(/\\([\s\S])/gm, ($0, $1) => $1)) + ; diff --git a/packages/nodes-base/nodes/SeaTable/Interfaces.ts b/packages/nodes-base/nodes/SeaTable/Interfaces.ts new file mode 100644 index 0000000000..8d8357476b --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/Interfaces.ts @@ -0,0 +1,102 @@ +import { + TColumnType, + TColumnValue, + TDtableMetadataColumns, + TDtableMetadataTables, + TSeaTableServerEdition, + TSeaTableServerVersion, +} from './types'; + +export interface IApi { + server: string; + token: string; + appAccessToken?: IAppAccessToken; + info?: IServerInfo; +} + +export interface IServerInfo { + version: TSeaTableServerVersion; + edition: TSeaTableServerEdition; +} + +export interface IAppAccessToken { + app_name: string; + access_token: string; + dtable_uuid: string; + dtable_server: string; + dtable_socket: string; + workspace_id: number; + dtable_name: string; +} + +export interface IDtableMetadataColumn { + key: string; + name: string; + type: TColumnType; + editable: boolean; +} + +export interface TDtableViewColumn { + _id: string; + name: string; +} + +export interface IDtableMetadataTable { + _id: string; + name: string; + columns: TDtableMetadataColumns; +} + +export interface IDtableMetadata { + tables: TDtableMetadataTables; + version: string; + format_version: string; +} + +export interface IEndpointVariables { + [name: string]: string | undefined; +} + +export interface IRowObject { + [name: string]: TColumnValue; +} + +export interface IRow extends IRowObject { + _id: string; + _ctime: string; + _mtime: string; + _seq?: number; +} + +export interface IName { + name: string; +} + + +type TOperation = 'cloudHosted' | 'selfHosted'; + +export interface ICredential { + token: string; + domain: string; + environment: TOperation; +} + +interface IBase { + dtable_uuid: string; + access_token: string; +} + +export interface ICtx { + base?: IBase; + credentials?: ICredential; +} + +export interface IRowResponse{ + metadata: [ + { + key: string, + name: string + } + ]; + results: IRow[]; +} diff --git a/packages/nodes-base/nodes/SeaTable/RowDescription.ts b/packages/nodes-base/nodes/SeaTable/RowDescription.ts new file mode 100644 index 0000000000..0601585318 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/RowDescription.ts @@ -0,0 +1,348 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const rowOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a row', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete a row', + }, + { + name: 'Get', + value: 'get', + description: 'Get a row', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Get all rows', + }, + { + name: 'Update', + value: 'update', + description: 'Update a row', + }, + ], + default: 'create', + description: 'The operation being performed', + }, +] as INodeProperties[]; + +export const rowFields = [ + // ---------------------------------- + // shared + // ---------------------------------- + + { + displayName: 'Table Name/ID', + name: 'tableName', + type: 'options', + placeholder: 'Name of table', + required: true, + typeOptions: { + loadOptionsMethod: 'getTableNames', + }, + displayOptions: { + hide: { + operation: [ + 'get', + ], + }, + }, + default: '', + description: 'The name of SeaTable table to access', + }, + { + displayName: 'Table Name/ID', + name: 'tableId', + type: 'options', + placeholder: 'Name of table', + required: true, + typeOptions: { + loadOptionsMethod: 'getTableIds', + }, + displayOptions: { + show: { + operation: [ + 'get', + ], + }, + }, + default: '', + description: 'The name of SeaTable table to access', + }, + + // ---------------------------------- + // update + // ---------------------------------- + { + displayName: 'Row ID', + name: 'rowId', + type: 'string', + displayOptions: { + show: { + operation: [ + 'update', + ], + }, + }, + default: '', + }, + + // ---------------------------------- + // create + // ---------------------------------- + { + displayName: 'Data to Send', + name: 'fieldsToSend', + type: 'options', + options: [ + { + name: 'Auto-Map Input Data to Columns', + value: 'autoMapInputData', + description: 'Use when node input properties match destination column names', + }, + { + name: 'Define Below for Each Column', + value: 'defineBelow', + description: 'Set the value for each destination column', + }, + ], + displayOptions: { + show: { + operation: [ + 'create', + 'update', + ], + }, + }, + default: 'defineBelow', + description: 'Whether to insert the input data this node receives in the new row', + }, + { + displayName: 'Inputs to Ignore', + name: 'inputsToIgnore', + type: 'string', + displayOptions: { + show: { + operation: [ + 'create', + 'update', + ], + fieldsToSend: [ + 'autoMapInputData', + ], + }, + }, + default: '', + description: 'List of input properties to avoid sending, separated by commas. Leave empty to send all properties.', + placeholder: 'Enter properties...', + }, + { + displayName: 'Columns to Send', + name: 'columnsUi', + placeholder: 'Add Column', + type: 'fixedCollection', + typeOptions: { + multipleValueButtonText: 'Add Column to Send', + multipleValues: true, + }, + options: [ + { + displayName: 'Column', + name: 'columnValues', + values: [ + { + displayName: 'Column Name', + name: 'columnName', + type: 'options', + typeOptions: { + loadOptionsDependsOn: [ + 'table', + ], + loadOptionsMethod: 'getTableUpdateAbleColumns', + }, + default: '', + }, + { + displayName: 'Column Value', + name: 'columnValue', + type: 'string', + default: '', + }, + ], + }, + ], + displayOptions: { + show: { + operation: [ + 'create', + 'update', + ], + fieldsToSend: [ + 'defineBelow', + ], + }, + }, + default: {}, + description: 'Add destination column with its value', + }, + // ---------------------------------- + // delete + // ---------------------------------- + { + displayName: 'Row ID', + name: 'rowId', + type: 'string', + displayOptions: { + show: { + operation: [ + 'delete', + ], + }, + }, + default: '', + }, + + // ---------------------------------- + // get + // ---------------------------------- + { + displayName: 'Row ID', + name: 'rowId', + type: 'string', + displayOptions: { + show: { + operation: [ + 'get', + ], + }, + }, + default: '', + }, + + // ---------------------------------- + // getAll + // ---------------------------------- + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + }, + }, + default: true, + description: 'Whether to return all results or only up to a given limit', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 100, + }, + default: 50, + description: 'How many results to return', + }, + { + displayName: 'Filters', + name: 'filters', + type: 'collection', + placeholder: 'Add Filter', + default: {}, + displayOptions: { + show: { + operation: [ + 'getAll', + ], + }, + }, + options: [ + { + displayName: 'View Name', + name: 'view_name', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getViews', + }, + default: '', + }, + ], + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + displayOptions: { + show: { + operation: [ + 'getAll', + ], + }, + }, + options: [ + { + displayName: 'Convert Link ID', + name: 'convert_link_id', + type: 'boolean', + default: false, + description: `Whether the link column in the returned row is the ID of the linked row or the name of the linked row`, + }, + { + displayName: 'Direction', + name: 'direction', + type: 'options', + options: [ + { + name: 'Ascending', + value: 'asc', + }, + { + name: 'Descending', + value: 'desc', + }, + ], + default: 'asc', + description: `The direction of the sort, ascending (asc) or descending (desc)`, + }, + { + displayName: 'Order By', + name: 'order_by', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getAllSortableColumns', + }, + default: '', + description: `A column's name or ID, use this column to sort the rows`, + }, + ], + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/SeaTable/Schema.ts b/packages/nodes-base/nodes/SeaTable/Schema.ts new file mode 100644 index 0000000000..50eec9d26e --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/Schema.ts @@ -0,0 +1,49 @@ +import {TColumnType, TDateTimeFormat, TInheritColumnKey} from './types'; + +export type ColumnType = keyof typeof schema.columnTypes; + +export const schema = { + rowFetchSegmentLimit: 1000, + dateTimeFormat: 'YYYY-MM-DDTHH:mm:ss.SSSZ', + internalNames: { + '_id': 'text', + '_creator': 'creator', + '_ctime': 'ctime', + '_last_modifier': 'last-modifier', + '_mtime': 'mtime', + '_seq': 'auto-number', + }, + columnTypes: { + text: 'Text', + 'long-text': 'Long Text', + number: 'Number', + collaborator: 'Collaborator', + date: 'Date', + duration: 'Duration', + 'single-select': 'Single Select', + 'multiple-select': 'Multiple Select', + email: 'Email', + url: 'URL', + 'rate': 'Rating', + checkbox: 'Checkbox', + formula: 'Formula', + creator: 'Creator', + ctime: 'Created time', + 'last-modifier': 'Last Modifier', + mtime: 'Last modified time', + 'auto-number': 'Auto number', + }, + nonUpdateAbleColumnTypes: { + 'creator': 'creator', + 'ctime': 'ctime', + 'last-modifier': 'last-modifier', + 'mtime': 'mtime', + 'auto-number': 'auto-number', + }, +} as { + rowFetchSegmentLimit: number, + dateTimeFormat: TDateTimeFormat, + internalNames: { [key in TInheritColumnKey]: ColumnType } + columnTypes: { [key in TColumnType]: string } + nonUpdateAbleColumnTypes: { [key in ColumnType]: ColumnType } +}; diff --git a/packages/nodes-base/nodes/SeaTable/SeaTable.node.json b/packages/nodes-base/nodes/SeaTable/SeaTable.node.json new file mode 100644 index 0000000000..75e6a5d681 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/SeaTable.node.json @@ -0,0 +1,72 @@ +{ + "node": "n8n-nodes-base.seaTable", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": [ + "Data & Storage" + ], + "resources": { + "credentialDocumentation": [ + { + "url": "https://docs.n8n.io/credentials/seaTable" + } + ], + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/nodes/n8n-nodes-base.seaTable/" + } + ], + "generic": [ + { + "label": "2021 Goals: Level Up Your Vocabulary With Vonage and n8n", + "icon": "🎯", + "url": "https://n8n.io/blog/2021-goals-level-up-your-vocabulary-with-vonage-and-n8n/" + }, + { + "label": "2021: The Year to Automate the New You with n8n", + "icon": "☀️", + "url": "https://n8n.io/blog/2021-the-year-to-automate-the-new-you-with-n8n/" + }, + { + "label": "15 Google apps you can combine and automate to increase productivity", + "icon": "💡", + "url": "https://n8n.io/blog/automate-google-apps-for-productivity/" + }, + { + "label": "Building an expense tracking app in 10 minutes", + "icon": "📱", + "url": "https://n8n.io/blog/building-an-expense-tracking-app-in-10-minutes/" + }, + { + "label": "Why this Product Manager loves workflow automation with n8n", + "icon": "🧠", + "url": "https://n8n.io/blog/why-this-product-manager-loves-workflow-automation-with-n8n/" + }, + { + "label": "Learn to Build Powerful API Endpoints Using Webhooks", + "icon": "🧰", + "url": "https://n8n.io/blog/learn-to-build-powerful-api-endpoints-using-webhooks/" + }, + { + "label": "Sending SMS the Low-Code Way with SeaTable, Twilio Programmable SMS, and n8n", + "icon": "📱", + "url": "https://n8n.io/blog/sending-sms-the-low-code-way-with-seatable-twilio-programmable-sms-and-n8n/" + }, + { + "label": "Automating Conference Organization Processes with n8n", + "icon": "🙋‍♀️", + "url": "https://n8n.io/blog/automating-conference-organization-processes-with-n8n/" + }, + { + "label": "Benefits of automation and n8n: An interview with HubSpot's Hugh Durkin", + "icon": "🎖", + "url": "https://n8n.io/blog/benefits-of-automation-and-n8n-an-interview-with-hubspots-hugh-durkin/" + }, + { + "label": "How Goomer automated their operations with over 200 n8n workflows", + "icon": "🛵", + "url": "https://n8n.io/blog/how-goomer-automated-their-operations-with-over-200-n8n-workflows/" + } + ] + } +} diff --git a/packages/nodes-base/nodes/SeaTable/SeaTable.node.ts b/packages/nodes-base/nodes/SeaTable/SeaTable.node.ts new file mode 100644 index 0000000000..baea6596e0 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/SeaTable.node.ts @@ -0,0 +1,319 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + IDataObject, + ILoadOptionsFunctions, + INodeExecutionData, + INodePropertyOptions, + INodeType, + INodeTypeDescription, + NodeOperationError, +} from 'n8n-workflow'; + +import { + getTableColumns, + getTableViews, + rowExport, + rowFormatColumns, + rowMapKeyToName, + seaTableApiRequest, + setableApiRequestAllItems, + split, + updateAble, +} from './GenericFunctions'; + +import { + rowFields, + rowOperations, +} from './RowDescription'; + +import { + TColumnsUiValues, + TColumnValue, +} from './types'; + +import { + ICtx, + IRow, + IRowObject, +} from './Interfaces'; + +export class SeaTable implements INodeType { + description: INodeTypeDescription = { + displayName: 'SeaTable', + name: 'seaTable', + icon: 'file:seaTable.svg', + group: ['input'], + version: 1, + subtitle: '={{$parameter["resource"] + ": " + $parameter["operation"]}}', + description: 'Consume the SeaTable API', + defaults: { + name: 'SeaTable', + color: '#FF8000', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'seaTableApi', + required: true, + }, + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: 'Row', + value: 'row', + }, + ], + default: 'row', + description: 'The resource to operate on', + }, + ...rowOperations, + ...rowFields, + ], + }; + + methods = { + loadOptions: { + async getTableNames(this: ILoadOptionsFunctions) { + const returnData: INodePropertyOptions[] = []; + const { metadata: { tables } } = await seaTableApiRequest.call(this, {}, 'GET', `/dtable-server/api/v1/dtables/{{dtable_uuid}}/metadata`); + for (const table of tables) { + returnData.push({ + name: table.name, + value: table.name, + }); + } + return returnData; + }, + async getTableIds(this: ILoadOptionsFunctions) { + const returnData: INodePropertyOptions[] = []; + const { metadata: { tables } } = await seaTableApiRequest.call(this, {}, 'GET', `/dtable-server/api/v1/dtables/{{dtable_uuid}}/metadata`); + for (const table of tables) { + returnData.push({ + name: table.name, + value: table._id, + }); + } + return returnData; + }, + + async getTableUpdateAbleColumns(this: ILoadOptionsFunctions) { + const tableName = this.getNodeParameter('tableName') as string; + const columns = await getTableColumns.call(this, tableName,); + return columns.filter(column => column.editable).map(column => ({ name: column.name, value: column.name })); + }, + async getAllSortableColumns(this: ILoadOptionsFunctions) { + const tableName = this.getNodeParameter('tableName') as string; + const columns = await getTableColumns.call(this, tableName); + return columns.filter(column => !['file', 'image', 'url', 'collaborator', 'long-text'].includes(column.type)).map(column => ({ name: column.name, value: column.name })); + }, + async getViews(this: ILoadOptionsFunctions) { + const tableName = this.getNodeParameter('tableName') as string; + const views = await getTableViews.call(this, tableName); + return views.map(view => ({ name: view.name, value: view.name })); + }, + }, + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const returnData: IDataObject[] = []; + let responseData; + + const resource = this.getNodeParameter('resource', 0) as string; + const operation = this.getNodeParameter('operation', 0) as string; + + const body: IDataObject = {}; + const qs: IDataObject = {}; + const ctx: ICtx = {}; + + if (resource === 'row') { + if (operation === 'create') { + // ---------------------------------- + // row:create + // ---------------------------------- + + const tableName = this.getNodeParameter('tableName', 0) as string; + const tableColumns = await getTableColumns.call(this, tableName); + + body.table_name = tableName; + + const fieldsToSend = this.getNodeParameter('fieldsToSend', 0) as 'defineBelow' | 'autoMapInputData'; + let rowInput: IRowObject = {}; + + for (let i = 0; i < items.length; i++) { + rowInput = {} as IRowObject; + try { + if (fieldsToSend === 'autoMapInputData') { + const incomingKeys = Object.keys(items[i].json); + const inputDataToIgnore = split(this.getNodeParameter('inputsToIgnore', i, '') as string); + for (const key of incomingKeys) { + if (inputDataToIgnore.includes(key)) continue; + rowInput[key] = items[i].json[key] as TColumnValue; + } + } else { + const columns = this.getNodeParameter('columnsUi.columnValues', i, []) as TColumnsUiValues; + for (const column of columns) { + rowInput[column.columnName] = column.columnValue; + } + } + body.row = rowExport(rowInput, updateAble(tableColumns)); + + responseData = await seaTableApiRequest.call(this, ctx, 'POST', `/dtable-server/api/v1/dtables/{{dtable_uuid}}/rows/`, body); + + const { _id: insertId } = responseData; + if (insertId === undefined) { + throw new NodeOperationError(this.getNode(), 'SeaTable: No identity after appending row.'); + } + + const newRowInsertData = rowMapKeyToName(responseData, tableColumns); + + qs.table_name = tableName; + qs.convert = true; + const newRow = await seaTableApiRequest.call(this, ctx, 'GET', `/dtable-server/api/v1/dtables/{{dtable_uuid}}/rows/${encodeURIComponent(insertId)}/`, body, qs); + + if (newRow._id === undefined) { + throw new NodeOperationError(this.getNode(), 'SeaTable: No identity for appended row.'); + } + + const row = rowFormatColumns({ ...newRowInsertData, ...newRow }, tableColumns.map(({ name }) => name).concat(['_id', '_ctime', '_mtime'])); + + returnData.push(row); + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ error: error.message }); + continue; + } + throw error; + } + } + } else if (operation === 'get') { + for (let i = 0; i < items.length; i++) { + try { + const tableId = this.getNodeParameter('tableId', 0) as string; + const rowId = this.getNodeParameter('rowId', i) as string; + const response = await seaTableApiRequest.call(this, ctx, 'GET', `/dtable-server/api/v1/dtables/{{dtable_uuid}}/rows/${rowId}`, {}, { table_id: tableId, convert: true }) as IDataObject; + returnData.push(response); + + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ error: error.message }); + continue; + } + throw error; + } + } + } else if (operation === 'getAll') { + // ---------------------------------- + // row:getAll + // ---------------------------------- + + const tableName = this.getNodeParameter('tableName', 0) as string; + const tableColumns = await getTableColumns.call(this, tableName); + + try { + for (let i = 0; i < items.length; i++) { + const endpoint = `/dtable-server/api/v1/dtables/{{dtable_uuid}}/rows/`; + qs.table_name = tableName; + const filters = this.getNodeParameter('filters', i) as IDataObject; + const options = this.getNodeParameter('options', i) as IDataObject; + const returnAll = this.getNodeParameter('returnAll', 0) as boolean; + + Object.assign(qs, filters, options); + + if (returnAll) { + responseData = await setableApiRequestAllItems.call(this, ctx, 'rows', 'GET', endpoint, body, qs); + } else { + qs.limit = this.getNodeParameter('limit', 0) as number; + responseData = await seaTableApiRequest.call(this, ctx, 'GET', endpoint, body, qs); + responseData = responseData.rows; + } + + const rows = responseData.map((row: IRow) => rowFormatColumns({ ...row }, tableColumns.map(({ name }) => name).concat(['_id', '_ctime', '_mtime']))); + + returnData.push(...rows); + } + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ error: error.message }); + } + throw error; + } + } else if (operation === 'delete') { + for (let i = 0; i < items.length; i++) { + try { + const tableName = this.getNodeParameter('tableName', 0) as string; + const rowId = this.getNodeParameter('rowId', i) as string; + const body: IDataObject = { + table_name: tableName, + row_id: rowId, + }; + const response = await seaTableApiRequest.call(this, ctx, 'DELETE', `/dtable-server/api/v1/dtables/{{dtable_uuid}}/rows/`, body, qs) as IDataObject; + returnData.push(response); + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ error: error.message }); + continue; + } + throw error; + } + } + } else if (operation === 'update') { + // ---------------------------------- + // row:update + // ---------------------------------- + + const tableName = this.getNodeParameter('tableName', 0) as string; + const tableColumns = await getTableColumns.call(this, tableName); + + body.table_name = tableName; + + const fieldsToSend = this.getNodeParameter('fieldsToSend', 0) as 'defineBelow' | 'autoMapInputData'; + let rowInput: IRowObject = {}; + + for (let i = 0; i < items.length; i++) { + const rowId = this.getNodeParameter('rowId', i) as string; + rowInput = {} as IRowObject; + try { + if (fieldsToSend === 'autoMapInputData') { + const incomingKeys = Object.keys(items[i].json); + const inputDataToIgnore = split(this.getNodeParameter('inputsToIgnore', i, '') as string); + for (const key of incomingKeys) { + if (inputDataToIgnore.includes(key)) continue; + rowInput[key] = items[i].json[key] as TColumnValue; + } + } else { + const columns = this.getNodeParameter('columnsUi.columnValues', i, []) as TColumnsUiValues; + for (const column of columns) { + rowInput[column.columnName] = column.columnValue; + } + } + body.row = rowExport(rowInput, updateAble(tableColumns)); + body.table_name = tableName; + body.row_id = rowId; + responseData = await seaTableApiRequest.call(this, ctx, 'PUT', `/dtable-server/api/v1/dtables/{{dtable_uuid}}/rows/`, body); + + returnData.push(responseData); + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ error: error.message }); + continue; + } + throw error; + } + } + } else { + throw new NodeOperationError(this.getNode(), `The operation "${operation}" is not known!`); + } + } + return [this.helpers.returnJsonArray(returnData)]; + } +} diff --git a/packages/nodes-base/nodes/SeaTable/SeaTableTrigger.node.json b/packages/nodes-base/nodes/SeaTable/SeaTableTrigger.node.json new file mode 100644 index 0000000000..9799673776 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/SeaTableTrigger.node.json @@ -0,0 +1,20 @@ +{ + "node": "n8n-nodes-base.seaTableTrigger", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": [ + "Data & Storage" + ], + "resources": { + "credentialDocumentation": [ + { + "url": "https://docs.n8n.io/credentials/seaTable" + } + ], + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/nodes/n8n-nodes-base.seaTableTrigger/" + } + ] + } +} diff --git a/packages/nodes-base/nodes/SeaTable/SeaTableTrigger.node.ts b/packages/nodes-base/nodes/SeaTable/SeaTableTrigger.node.ts new file mode 100644 index 0000000000..7390162114 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/SeaTableTrigger.node.ts @@ -0,0 +1,158 @@ +import { + IPollFunctions, +} from 'n8n-core'; + +import { + ILoadOptionsFunctions, + INodeExecutionData, + INodePropertyOptions, + INodeType, + INodeTypeDescription, +} from 'n8n-workflow'; + +import { + getColumns, + rowFormatColumns, + seaTableApiRequest, + simplify, +} from './GenericFunctions'; + +import { + ICtx, + IRow, + IRowResponse, +} from './Interfaces'; + +import * as moment from 'moment'; + +export class SeaTableTrigger implements INodeType { + description: INodeTypeDescription = { + displayName: 'SeaTable Trigger', + name: 'seaTableTrigger', + icon: 'file:seaTable.svg', + group: ['trigger'], + version: 1, + description: 'Starts the workflow when SeaTable events occur', + subtitle: '={{$parameter["event"]}}', + defaults: { + name: 'SeaTable Trigger', + color: '#FF8000', + }, + credentials: [ + { + name: 'seaTableApi', + required: true, + }, + ], + polling: true, + inputs: [], + outputs: ['main'], + properties: [ + { + displayName: 'Table', + name: 'tableName', + type: 'options', + required: true, + typeOptions: { + loadOptionsMethod: 'getTableNames', + }, + default: '', + description: 'The name of SeaTable table to access', + }, + { + displayName: 'Event', + name: 'event', + type: 'options', + options: [ + { + name: 'Row Created', + value: 'rowCreated', + description: 'Trigger on newly created rows', + }, + // { + // name: 'Row Modified', + // value: 'rowModified', + // description: 'Trigger has recently modified rows', + // }, + ], + default: 'rowCreated', + }, + { + displayName: 'Simplify Response', + name: 'simple', + type: 'boolean', + default: true, + description: 'Return a simplified version of the response instead of the raw data', + }, + ], + }; + + methods = { + loadOptions: { + async getTableNames(this: ILoadOptionsFunctions) { + const returnData: INodePropertyOptions[] = []; + const { metadata: { tables } } = await seaTableApiRequest.call(this, {}, 'GET', `/dtable-server/api/v1/dtables/{{dtable_uuid}}/metadata`); + for (const table of tables) { + returnData.push({ + name: table.name, + value: table.name, + }); + } + return returnData; + }, + }, + }; + + async poll(this: IPollFunctions): Promise { + const webhookData = this.getWorkflowStaticData('node'); + const tableName = this.getNodeParameter('tableName') as string; + const simple = this.getNodeParameter('simple') as boolean; + const event = this.getNodeParameter('event') as string; + const ctx: ICtx = {}; + + const now = moment().utc().format(); + + const startDate = webhookData.lastTimeChecked as string || now; + + const endDate = now; + + webhookData.lastTimeChecked = endDate; + + let rows; + + const filterField = (event === 'rowCreated') ? '_ctime' : '_mtime'; + + const endpoint = `/dtable-db/api/v1/query/{{dtable_uuid}}/`; + + if (this.getMode() === 'manual') { + rows = await seaTableApiRequest.call(this, ctx, 'POST', endpoint, { sql: `SELECT * FROM ${tableName} LIMIT 1` }) as IRowResponse; + } else { + rows = await seaTableApiRequest.call(this, ctx, 'POST', endpoint, + { sql: `SELECT * FROM ${tableName} WHERE ${filterField} BETWEEN "${moment(startDate).utc().format('YYYY-MM-D HH:mm:ss')}" AND "${moment(endDate).utc().format('YYYY-MM-D HH:mm:ss')}"` }) as IRowResponse; + } + + let response; + + if (rows.metadata && rows.results) { + const columns = getColumns(rows); + if (simple === true) { + response = simplify(rows, columns); + } else { + response = rows.results; + } + + const allColumns = rows.metadata.map((meta) => meta.name); + + response = response + //@ts-ignore + .map((row: IRow) => rowFormatColumns(row, allColumns)) + .map((row: IRow) => ({ json: row })); + } + + if (Array.isArray(response) && response.length) { + return [response]; + } + + return null; + } +} diff --git a/packages/nodes-base/nodes/SeaTable/seaTable.svg b/packages/nodes-base/nodes/SeaTable/seaTable.svg new file mode 100644 index 0000000000..472598576d --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/seaTable.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/nodes-base/nodes/SeaTable/types.d.ts b/packages/nodes-base/nodes/SeaTable/types.d.ts new file mode 100644 index 0000000000..f76460ce26 --- /dev/null +++ b/packages/nodes-base/nodes/SeaTable/types.d.ts @@ -0,0 +1,69 @@ +// ---------------------------------- +// sea-table +// ---------------------------------- + +type TSeaTableServerVersion = '2.0.6'; +type TSeaTableServerEdition = 'enterprise edition'; + +// ---------------------------------- +// dtable +// ---------------------------------- + +import {IDtableMetadataColumn, IDtableMetadataTable, TDtableViewColumn} from './Interfaces'; +import {ICredentialDataDecryptedObject} from 'n8n-workflow'; + +type TInheritColumnTypeTime = 'ctime' | 'mtime'; +type TInheritColumnTypeUser = 'creator' | 'last-modifier'; +type TColumnType = 'text' | 'long-text' | 'number' + | 'collaborator' + | 'date' | 'duration' | 'single-select' | 'multiple-select' | 'email' | 'url' | 'rate' + | 'checkbox' | 'formula' + | TInheritColumnTypeTime | TInheritColumnTypeUser | 'auto-number'; + + +type TImplementInheritColumnKey = '_seq'; +type TInheritColumnKey = '_id' | '_creator' | '_ctime' | '_last_modifier' | '_mtime' | TImplementInheritColumnKey; + +type TColumnValue = undefined | boolean | number | string | string[] | null; +type TColumnKey = TInheritColumnKey | string; + +export type TDtableMetadataTables = ReadonlyArray; +export type TDtableMetadataColumns = ReadonlyArray; +export type TDtableViewColumns = ReadonlyArray; + +// ---------------------------------- +// api +// ---------------------------------- + +type TEndpointVariableName = 'access_token' | 'dtable_uuid' | 'server'; + +// Template Literal Types requires-ts-4.1.5 -- deferred +type TMethod = 'GET' | 'POST'; +type TDeferredEndpoint = string; +type TDeferredEndpointExpr = string; +type TEndpoint = + '/api/v2.1/dtable/app-access-token/' + | '/dtable-server/api/v1/dtables/{{dtable_uuid}}/rows/' + | TDeferredEndpoint; +type TEndpointExpr = TEndpoint | TDeferredEndpointExpr; +type TEndpointResolvedExpr = TEndpoint | string; /* deferred: but already in use for header values, e.g. authentication */ + +type TDateTimeFormat = 'YYYY-MM-DDTHH:mm:ss.SSSZ' /* moment.js */; + +// ---------------------------------- +// node +// ---------------------------------- + +type TCredentials = ICredentialDataDecryptedObject | undefined; + +type TTriggerOperation = 'create' | 'update'; + +type TOperation = 'append' | 'list' | 'metadata'; + +type TLoadedResource = { + name: string; +}; +export type TColumnsUiValues = Array<{ + columnName: string; + columnValue: string; +}>; diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 0e41de7a59..e8d5d27196 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -230,6 +230,7 @@ "dist/credentials/SalesforceJwtApi.credentials.js", "dist/credentials/SalesforceOAuth2Api.credentials.js", "dist/credentials/SalesmateApi.credentials.js", + "dist/credentials/SeaTableApi.credentials.js", "dist/credentials/SecurityScorecardApi.credentials.js", "dist/credentials/SegmentApi.credentials.js", "dist/credentials/SendGridApi.credentials.js", @@ -548,6 +549,8 @@ "dist/nodes/Rundeck/Rundeck.node.js", "dist/nodes/S3/S3.node.js", "dist/nodes/Salesforce/Salesforce.node.js", + "dist/nodes/SeaTable/SeaTable.node.js", + "dist/nodes/SeaTable/SeaTableTrigger.node.js", "dist/nodes/SecurityScorecard/SecurityScorecard.node.js", "dist/nodes/Set.node.js", "dist/nodes/SentryIo/SentryIo.node.js", From 4bce33a5306585d0b767b0db8dbf0ae0ac0f8836 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 30 Sep 2021 02:10:39 +0200 Subject: [PATCH 019/290] :sparkles: Add Grist node (#2158) * Implement Grist node with List/Append/Update/Delete operations * :hammer: Refactor Grist node * :hammer: Make API key required * :hammer: Complete create/upate operations * :hammer: Fix item index in docId and tableId * :hammer: Simplify continueOnFail item * :shirt: Nodelinter pass * :shirt: Fix lint * :shirt: Resort imports * :zap: Improvements * :hammer: Simplify with optional access operator * :hammer: Simplify row ID processing in deletion * :construction: Add stub for cred test Pending change to core * :zap: Add workaround for cred test * :fire: Remove excess items check * :pencil2: Rename fields * :bug: Fix numeric filter * :pencil2: Add feedback from Product * :fire: Remove superfluous key * :zap: Small change * :zap: Fix subtitle and improve how data gets returned Co-authored-by: Alex Hall Co-authored-by: ricardo Co-authored-by: Jan Oberhauser --- .../credentials/GristApi.credentials.ts | 50 +++ .../nodes/Grist/GenericFunctions.ts | 109 ++++++ .../nodes-base/nodes/Grist/Grist.node.json | 20 ++ packages/nodes-base/nodes/Grist/Grist.node.ts | 280 +++++++++++++++ .../nodes/Grist/OperationDescription.ts | 338 ++++++++++++++++++ packages/nodes-base/nodes/Grist/grist.svg | 1 + packages/nodes-base/nodes/Grist/types.d.ts | 46 +++ packages/nodes-base/package.json | 2 + 8 files changed, 846 insertions(+) create mode 100644 packages/nodes-base/credentials/GristApi.credentials.ts create mode 100644 packages/nodes-base/nodes/Grist/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/Grist/Grist.node.json create mode 100644 packages/nodes-base/nodes/Grist/Grist.node.ts create mode 100644 packages/nodes-base/nodes/Grist/OperationDescription.ts create mode 100644 packages/nodes-base/nodes/Grist/grist.svg create mode 100644 packages/nodes-base/nodes/Grist/types.d.ts diff --git a/packages/nodes-base/credentials/GristApi.credentials.ts b/packages/nodes-base/credentials/GristApi.credentials.ts new file mode 100644 index 0000000000..9ac52c09fb --- /dev/null +++ b/packages/nodes-base/credentials/GristApi.credentials.ts @@ -0,0 +1,50 @@ +import { + ICredentialType, + INodeProperties, +} from 'n8n-workflow'; + +export class GristApi implements ICredentialType { + name = 'gristApi'; + displayName = 'Grist API'; + documentationUrl = 'grist'; + properties: INodeProperties[] = [ + { + displayName: 'API Key', + name: 'apiKey', + type: 'string', + default: '', + required: true, + }, + { + displayName: 'Plan Type', + name: 'planType', + type: 'options', + default: 'free', + options: [ + { + name: 'Free', + value: 'free', + }, + { + name: 'Paid', + value: 'paid', + }, + ], + }, + { + displayName: 'Custom Subdomain', + name: 'customSubdomain', + type: 'string', + default: '', + required: true, + description: 'Custom subdomain of your team', + displayOptions: { + show: { + planType: [ + 'paid', + ], + }, + }, + }, + ]; +} diff --git a/packages/nodes-base/nodes/Grist/GenericFunctions.ts b/packages/nodes-base/nodes/Grist/GenericFunctions.ts new file mode 100644 index 0000000000..f7ef0768f1 --- /dev/null +++ b/packages/nodes-base/nodes/Grist/GenericFunctions.ts @@ -0,0 +1,109 @@ +import { + IExecuteFunctions, + ILoadOptionsFunctions, +} from 'n8n-core'; + +import { + OptionsWithUri, +} from 'request'; + +import { + IDataObject, + NodeApiError, + NodeOperationError, +} from 'n8n-workflow'; + +import { + GristCredentials, + GristDefinedFields, + GristFilterProperties, + GristSortProperties, +} from './types'; + +export async function gristApiRequest( + this: IExecuteFunctions | ILoadOptionsFunctions, + method: string, + endpoint: string, + body: IDataObject | number[] = {}, + qs: IDataObject = {}, +) { + const { + apiKey, + planType, + customSubdomain, + } = await this.getCredentials('gristApi') as GristCredentials; + + const subdomain = planType === 'free' ? 'docs' : customSubdomain; + + const options: OptionsWithUri = { + headers: { + Authorization: `Bearer ${apiKey}`, + }, + method, + uri: `https://${subdomain}.getgrist.com/api${endpoint}`, + qs, + body, + json: true, + }; + + if (!Object.keys(body).length) { + delete options.body; + } + + if (!Object.keys(qs).length) { + delete options.qs; + } + + try { + return await this.helpers.request!(options); + } catch (error) { + throw new NodeApiError(this.getNode(), error); + } +} + +export function parseSortProperties(sortProperties: GristSortProperties) { + return sortProperties.reduce((acc, cur, curIdx) => { + if (cur.direction === 'desc') acc += '-'; + acc += cur.field; + if (curIdx !== sortProperties.length - 1) acc += ','; + return acc; + }, ''); +} + +export function parseFilterProperties(filterProperties: GristFilterProperties) { + return filterProperties.reduce<{ [key: string]: Array; }>((acc, cur) => { + acc[cur.field] = acc[cur.field] ?? []; + const values = isNaN(Number(cur.values)) ? cur.values : Number(cur.values); + acc[cur.field].push(values); + return acc; + }, {}); +} + +export function parseDefinedFields(fieldsToSendProperties: GristDefinedFields) { + return fieldsToSendProperties.reduce<{ [key: string]: string; }>((acc, cur) => { + acc[cur.fieldId] = cur.fieldValue; + return acc; + }, {}); +} + +export function parseAutoMappedInputs( + incomingKeys: string[], + inputsToIgnore: string[], + item: any, // tslint:disable-line:no-any +) { + return incomingKeys.reduce<{ [key: string]: any; }>((acc, curKey) => { // tslint:disable-line:no-any + if (inputsToIgnore.includes(curKey)) return acc; + acc = { ...acc, [curKey]: item[curKey] }; + return acc; + }, {}); +} + +export function throwOnZeroDefinedFields(this: IExecuteFunctions, fields: GristDefinedFields) { + if (!fields?.length) { + throw new NodeOperationError( + this.getNode(), + 'No defined data found. Please specify the data to send in \'Fields to Send\'.', + ); + } +} + diff --git a/packages/nodes-base/nodes/Grist/Grist.node.json b/packages/nodes-base/nodes/Grist/Grist.node.json new file mode 100644 index 0000000000..4771346cb9 --- /dev/null +++ b/packages/nodes-base/nodes/Grist/Grist.node.json @@ -0,0 +1,20 @@ +{ + "node": "n8n-nodes-base.grist", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": [ + "Data & Storage" + ], + "resources": { + "credentialDocumentation": [ + { + "url": "https://docs.n8n.io/credentials/grist" + } + ], + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/nodes/n8n-nodes-base.grist/" + } + ] + } +} diff --git a/packages/nodes-base/nodes/Grist/Grist.node.ts b/packages/nodes-base/nodes/Grist/Grist.node.ts new file mode 100644 index 0000000000..50f4b070f9 --- /dev/null +++ b/packages/nodes-base/nodes/Grist/Grist.node.ts @@ -0,0 +1,280 @@ +import { + IExecuteFunctions +} from 'n8n-core'; + +import { + ICredentialsDecrypted, + ICredentialTestFunctions, + IDataObject, + ILoadOptionsFunctions, + INodeExecutionData, + INodeType, + INodeTypeDescription, + NodeCredentialTestResult, +} from 'n8n-workflow'; + +import { + OptionsWithUri, +} from 'request'; + +import { + gristApiRequest, + parseAutoMappedInputs, + parseDefinedFields, + parseFilterProperties, + parseSortProperties, + throwOnZeroDefinedFields, +} from './GenericFunctions'; + +import { + operationFields, +} from './OperationDescription'; + +import { + FieldsToSend, + GristColumns, + GristCreateRowPayload, + GristCredentials, + GristGetAllOptions, + GristUpdateRowPayload, + SendingOptions, +} from './types'; + +export class Grist implements INodeType { + description: INodeTypeDescription = { + displayName: 'Grist', + name: 'grist', + icon: 'file:grist.svg', + subtitle: '={{$parameter["operation"]}}', + group: ['input'], + version: 1, + description: 'Consume the Grist API', + defaults: { + name: 'Grist', + color: '#394650', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'gristApi', + required: true, + testedBy: 'gristApiTest', + }, + ], + properties: operationFields, + }; + + methods = { + loadOptions: { + async getTableColumns(this: ILoadOptionsFunctions) { + const docId = this.getNodeParameter('docId', 0) as string; + const tableId = this.getNodeParameter('tableId', 0) as string; + const endpoint = `/docs/${docId}/tables/${tableId}/columns`; + + const { columns } = await gristApiRequest.call(this, 'GET', endpoint) as GristColumns; + return columns.map(({ id }) => ({ name: id, value: id })); + }, + }, + + credentialTest: { + async gristApiTest( + this: ICredentialTestFunctions, + credential: ICredentialsDecrypted, + ): Promise { + const { + apiKey, + planType, + customSubdomain, + } = credential.data as GristCredentials; + + const subdomain = planType === 'free' ? 'docs' : customSubdomain; + + const endpoint = '/orgs'; + + const options: OptionsWithUri = { + headers: { + Authorization: `Bearer ${apiKey}`, + }, + method: 'GET', + uri: `https://${subdomain}.getgrist.com/api${endpoint}`, + qs: { limit: 1 }, + json: true, + }; + + try { + await this.helpers.request(options); + return { + status: 'OK', + message: 'Authentication successful', + }; + } catch (error) { + return { + status: 'Error', + message: error.message, + }; + } + }, + }, + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + let responseData; + const returnData: IDataObject[] = []; + + const operation = this.getNodeParameter('operation', 0) as string; + + for (let i = 0; i < items.length; i++) { + + try { + + if (operation === 'create') { + + // ---------------------------------- + // create + // ---------------------------------- + + // https://support.getgrist.com/api/#tag/records/paths/~1docs~1{docId}~1tables~1{tableId}~1records/post + + const body = { records: [] } as GristCreateRowPayload; + + const dataToSend = this.getNodeParameter('dataToSend', 0) as SendingOptions; + + if (dataToSend === 'autoMapInputs') { + + const incomingKeys = Object.keys(items[i].json); + const rawInputsToIgnore = this.getNodeParameter('inputsToIgnore', i) as string; + const inputsToIgnore = rawInputsToIgnore.split(',').map(c => c.trim()); + const fields = parseAutoMappedInputs(incomingKeys, inputsToIgnore, items[i].json); + body.records.push({ fields }); + + } else if (dataToSend === 'defineInNode') { + + const { properties } = this.getNodeParameter('fieldsToSend', i, []) as FieldsToSend; + throwOnZeroDefinedFields.call(this, properties); + body.records.push({ fields: parseDefinedFields(properties) }); + + } + + const docId = this.getNodeParameter('docId', 0) as string; + const tableId = this.getNodeParameter('tableId', 0) as string; + const endpoint = `/docs/${docId}/tables/${tableId}/records`; + + responseData = await gristApiRequest.call(this, 'POST', endpoint, body); + responseData = { + id: responseData.records[0].id, + ...body.records[0].fields, + }; + + } else if (operation === 'delete') { + + // ---------------------------------- + // delete + // ---------------------------------- + + // https://support.getgrist.com/api/#tag/data/paths/~1docs~1{docId}~1tables~1{tableId}~1data~1delete/post + + const docId = this.getNodeParameter('docId', 0) as string; + const tableId = this.getNodeParameter('tableId', 0) as string; + const endpoint = `/docs/${docId}/tables/${tableId}/data/delete`; + + const rawRowIds = (this.getNodeParameter('rowId', i) as string).toString(); + const body = rawRowIds.split(',').map(c => c.trim()).map(Number); + + await gristApiRequest.call(this, 'POST', endpoint, body); + responseData = { success: true }; + + } else if (operation === 'update') { + + // ---------------------------------- + // update + // ---------------------------------- + + // https://support.getgrist.com/api/#tag/records/paths/~1docs~1{docId}~1tables~1{tableId}~1records/patch + + const body = { records: [] } as GristUpdateRowPayload; + + const rowId = this.getNodeParameter('rowId', i) as string; + const dataToSend = this.getNodeParameter('dataToSend', 0) as SendingOptions; + + if (dataToSend === 'autoMapInputs') { + + const incomingKeys = Object.keys(items[i].json); + const rawInputsToIgnore = this.getNodeParameter('inputsToIgnore', i) as string; + const inputsToIgnore = rawInputsToIgnore.split(',').map(c => c.trim()); + const fields = parseAutoMappedInputs(incomingKeys, inputsToIgnore, items[i].json); + body.records.push({ id: Number(rowId), fields }); + + } else if (dataToSend === 'defineInNode') { + + const { properties } = this.getNodeParameter('fieldsToSend', i, []) as FieldsToSend; + throwOnZeroDefinedFields.call(this, properties); + const fields = parseDefinedFields(properties); + body.records.push({ id: Number(rowId), fields }); + + } + + const docId = this.getNodeParameter('docId', 0) as string; + const tableId = this.getNodeParameter('tableId', 0) as string; + const endpoint = `/docs/${docId}/tables/${tableId}/records`; + + await gristApiRequest.call(this, 'PATCH', endpoint, body); + responseData = { + id: rowId, + ...body.records[0].fields, + }; + + } else if (operation === 'getAll') { + + // ---------------------------------- + // getAll + // ---------------------------------- + + // https://support.getgrist.com/api/#tag/records + + const docId = this.getNodeParameter('docId', 0) as string; + const tableId = this.getNodeParameter('tableId', 0) as string; + const endpoint = `/docs/${docId}/tables/${tableId}/records`; + + const qs: IDataObject = {}; + + const returnAll = this.getNodeParameter('returnAll', i) as boolean; + + if (!returnAll) { + qs.limit = this.getNodeParameter('limit', i) as number; + } + + const { sort, filter } = this.getNodeParameter('additionalOptions', i) as GristGetAllOptions; + + if (sort?.sortProperties.length) { + qs.sort = parseSortProperties(sort.sortProperties); + } + + if (filter?.filterProperties.length) { + const parsed = parseFilterProperties(filter.filterProperties); + qs.filter = JSON.stringify(parsed); + } + + responseData = await gristApiRequest.call(this, 'GET', endpoint, {}, qs); + responseData = responseData.records.map((data: IDataObject) => { + return { id: data.id, ...(data.fields as object) }; + }); + } + + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ error: error.message }); + continue; + } + throw error; + } + + Array.isArray(responseData) + ? returnData.push(...responseData) + : returnData.push(responseData); + } + + return [this.helpers.returnJsonArray(returnData)]; + } +} diff --git a/packages/nodes-base/nodes/Grist/OperationDescription.ts b/packages/nodes-base/nodes/Grist/OperationDescription.ts new file mode 100644 index 0000000000..65faf4f5cb --- /dev/null +++ b/packages/nodes-base/nodes/Grist/OperationDescription.ts @@ -0,0 +1,338 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const operationFields: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Create Row', + value: 'create', + description: 'Create rows in a table', + }, + { + name: 'Delete Row', + value: 'delete', + description: 'Delete rows from a table', + }, + { + name: 'Get All Rows', + value: 'getAll', + description: 'Read rows from a table', + }, + { + name: 'Update Row', + value: 'update', + description: 'Update rows in a table', + }, + ], + default: 'getAll', + }, + + // ---------------------------------- + // shared + // ---------------------------------- + { + displayName: 'Document ID', + name: 'docId', + type: 'string', + default: '', + required: true, + description: 'In your document, click your profile icon, then Document Settings, then copy the value under "This document\'s ID"', + }, + { + displayName: 'Table ID', + name: 'tableId', + type: 'string', + default: '', + required: true, + description: 'ID of table to operate on. If unsure, look at the Code View.', + }, + + // ---------------------------------- + // delete + // ---------------------------------- + { + displayName: 'Row ID', + name: 'rowId', + type: 'string', + displayOptions: { + show: { + operation: [ + 'delete', + ], + }, + }, + default: '', + description: 'ID of the row to delete, or comma-separated list of row IDs to delete', + required: true, + }, + + // ---------------------------------- + // getAll + // ---------------------------------- + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + default: false, + description: 'Whether to return all results or only up to a given limit', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + }, + }, + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + typeOptions: { + minValue: 1, + }, + default: 50, + description: 'Max number of results to return', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + returnAll: [ + false, + ], + }, + }, + }, + { + displayName: 'Additional Options', + name: 'additionalOptions', + type: 'collection', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + }, + }, + default: {}, + placeholder: 'Add Option', + options: [ + { + displayName: 'Filter', + name: 'filter', + placeholder: 'Add Filter', + description: 'Only return rows matching all of the given filters. For complex filters, create a formula column and filter for the value "true".', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + default: {}, + options: [ + { + displayName: 'Filter Properties', + name: 'filterProperties', + values: [ + { + displayName: 'Column', + name: 'field', + type: 'options', + typeOptions: { + loadOptionsDependsOn: [ + 'docId', + 'tableId', + ], + loadOptionsMethod: 'getTableColumns', + }, + default: '', + description: 'Column to apply the filter in', + required: true, + }, + { + displayName: 'Values', + name: 'values', + type: 'string', + default: '', + description: 'Comma-separated list of values to search for in the filtered column', + }, + ], + }, + ], + }, + { + displayName: 'Sort Order', + name: 'sort', + placeholder: 'Add Field', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + default: {}, + options: [ + { + displayName: 'Sort Properties', + name: 'sortProperties', + values: [ + { + displayName: 'Column', + name: 'field', + type: 'options', + typeOptions: { + loadOptionsDependsOn: [ + 'docId', + 'tableId', + ], + loadOptionsMethod: 'getTableColumns', + }, + default: '', + required: true, + description: 'Column to sort on', + }, + { + displayName: 'Direction', + name: 'direction', + type: 'options', + options: [ + { + name: 'Ascending', + value: 'asc', + }, + { + name: 'Descending', + value: 'desc', + }, + ], + default: 'asc', + description: 'Direction to sort in', + }, + ], + }, + ], + }, + ], + }, + + // ---------------------------------- + // update + // ---------------------------------- + { + displayName: 'Row ID', + name: 'rowId', + type: 'string', + displayOptions: { + show: { + operation: [ + 'update', + ], + }, + }, + default: '', + description: 'ID of the row to update', + required: true, + }, + + // ---------------------------------- + // create + update + // ---------------------------------- + { + displayName: 'Data to Send', + name: 'dataToSend', + type: 'options', + options: [ + { + name: 'Auto-Map Input Data to Columns', + value: 'autoMapInputs', + description: 'Use when node input properties match destination column names', + }, + { + name: 'Define Below for Each Column', + value: 'defineInNode', + description: 'Set the value for each destination column', + }, + ], + displayOptions: { + show: { + operation: [ + 'create', + 'update', + ], + }, + }, + default: 'defineInNode', + description: 'Whether to insert the input data this node receives in the new row', + }, + { + displayName: 'Inputs to Ignore', + name: 'inputsToIgnore', + type: 'string', + displayOptions: { + show: { + operation: [ + 'create', + 'update', + ], + dataToSend: [ + 'autoMapInputs', + ], + }, + }, + default: '', + required: false, + description: 'List of input properties to avoid sending, separated by commas. Leave empty to send all properties.', + placeholder: 'Enter properties...', + }, + { + displayName: 'Fields to Send', + name: 'fieldsToSend', + placeholder: 'Add Field', + type: 'fixedCollection', + typeOptions: { + multipleValueButtonText: 'Add Field to Send', + multipleValues: true, + }, + displayOptions: { + show: { + operation: [ + 'create', + 'update', + ], + dataToSend: [ + 'defineInNode', + ], + }, + }, + default: {}, + options: [ + { + displayName: 'Properties', + name: 'properties', + values: [ + { + displayName: 'Column Name/ID', + name: 'fieldId', + description: 'Choose from the list, or specify an ID using an expression', + type: 'options', + typeOptions: { + loadOptionsDependsOn: [ + 'tableId', + ], + loadOptionsMethod: 'getTableColumns', + }, + default: '', + }, + { + displayName: 'Field Value', + name: 'fieldValue', + type: 'string', + default: '', + }, + ], + }, + ], + }, +]; diff --git a/packages/nodes-base/nodes/Grist/grist.svg b/packages/nodes-base/nodes/Grist/grist.svg new file mode 100644 index 0000000000..180f36b827 --- /dev/null +++ b/packages/nodes-base/nodes/Grist/grist.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/nodes-base/nodes/Grist/types.d.ts b/packages/nodes-base/nodes/Grist/types.d.ts new file mode 100644 index 0000000000..d45afae160 --- /dev/null +++ b/packages/nodes-base/nodes/Grist/types.d.ts @@ -0,0 +1,46 @@ +export type GristCredentials = { + apiKey: string; + planType: 'free' | 'paid'; + customSubdomain?: string; +} + +export type GristColumns = { + columns: Array<{ id: string }>; +}; + +export type GristSortProperties = Array<{ + field: string; + direction: 'asc' | 'desc'; +}>; + +export type GristFilterProperties = Array<{ + field: string; + values: string; +}>; + +export type GristGetAllOptions = { + sort?: { sortProperties: GristSortProperties }; + filter?: { filterProperties: GristFilterProperties }; +}; + +export type GristDefinedFields = Array<{ + fieldId: string; + fieldValue: string; +}>; + +export type GristCreateRowPayload = { + records: Array<{ + fields: { [key: string]: any }; + }> +}; + +export type GristUpdateRowPayload = { + records: Array<{ + id: number; + fields: { [key: string]: any }; + }> +} + +export type SendingOptions = 'defineInNode' | 'autoMapInputs'; + +export type FieldsToSend = { properties: GristDefinedFields; }; diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index e8d5d27196..fc00fea912 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -126,6 +126,7 @@ "dist/credentials/GoogleTranslateOAuth2Api.credentials.js", "dist/credentials/GotifyApi.credentials.js", "dist/credentials/GoToWebinarOAuth2Api.credentials.js", + "dist/credentials/GristApi.credentials.js", "dist/credentials/YouTubeOAuth2Api.credentials.js", "dist/credentials/GumroadApi.credentials.js", "dist/credentials/HarvestApi.credentials.js", @@ -430,6 +431,7 @@ "dist/nodes/Gotify/Gotify.node.js", "dist/nodes/GoToWebinar/GoToWebinar.node.js", "dist/nodes/GraphQL/GraphQL.node.js", + "dist/nodes/Grist/Grist.node.js", "dist/nodes/Gumroad/GumroadTrigger.node.js", "dist/nodes/HackerNews/HackerNews.node.js", "dist/nodes/Harvest/Harvest.node.js", From 973c4f86d2fe7f9d159fae6270918a78472144b0 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Wed, 29 Sep 2021 19:24:34 -0500 Subject: [PATCH 020/290] :zap: Return id on SeaTable update --- packages/nodes-base/nodes/SeaTable/SeaTable.node.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nodes-base/nodes/SeaTable/SeaTable.node.ts b/packages/nodes-base/nodes/SeaTable/SeaTable.node.ts index baea6596e0..efa89cbe86 100644 --- a/packages/nodes-base/nodes/SeaTable/SeaTable.node.ts +++ b/packages/nodes-base/nodes/SeaTable/SeaTable.node.ts @@ -301,7 +301,7 @@ export class SeaTable implements INodeType { body.row_id = rowId; responseData = await seaTableApiRequest.call(this, ctx, 'PUT', `/dtable-server/api/v1/dtables/{{dtable_uuid}}/rows/`, body); - returnData.push(responseData); + returnData.push({ _id: rowId, ... responseData }); } catch (error) { if (this.continueOnFail()) { returnData.push({ error: error.message }); From ad55298d1bf6b691347dc62df2ff2862d59685be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 30 Sep 2021 18:58:30 +0200 Subject: [PATCH 021/290] :sparkles: Add Urlscan.io node (#2266) * :sparkles: Create urlscan.io node * :zap: Change default visibility to private Co-authored-by: ricardo --- .../credentials/UrlScanIoApi.credentials.ts | 19 ++ .../nodes/UrlScanIo/GenericFunctions.ts | 85 +++++++ .../nodes/UrlScanIo/UrlScanIo.node.ts | 212 +++++++++++++++++ .../UrlScanIo/descriptions/ScanDescription.ts | 218 ++++++++++++++++++ .../nodes/UrlScanIo/descriptions/index.ts | 1 + .../nodes-base/nodes/UrlScanIo/urlScanIo.svg | 1 + packages/nodes-base/package.json | 2 + 7 files changed, 538 insertions(+) create mode 100644 packages/nodes-base/credentials/UrlScanIoApi.credentials.ts create mode 100644 packages/nodes-base/nodes/UrlScanIo/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/UrlScanIo/UrlScanIo.node.ts create mode 100644 packages/nodes-base/nodes/UrlScanIo/descriptions/ScanDescription.ts create mode 100644 packages/nodes-base/nodes/UrlScanIo/descriptions/index.ts create mode 100644 packages/nodes-base/nodes/UrlScanIo/urlScanIo.svg diff --git a/packages/nodes-base/credentials/UrlScanIoApi.credentials.ts b/packages/nodes-base/credentials/UrlScanIoApi.credentials.ts new file mode 100644 index 0000000000..df29c66cf8 --- /dev/null +++ b/packages/nodes-base/credentials/UrlScanIoApi.credentials.ts @@ -0,0 +1,19 @@ +import { + ICredentialType, + INodeProperties, +} from 'n8n-workflow'; + +export class UrlScanIoApi implements ICredentialType { + name = 'urlScanIoApi'; + displayName = 'urlscan.io API'; + documentationUrl = 'urlScanIo'; + properties: INodeProperties[] = [ + { + displayName: 'API Key', + name: 'apiKey', + type: 'string', + default: '', + required: true, + }, + ]; +} diff --git a/packages/nodes-base/nodes/UrlScanIo/GenericFunctions.ts b/packages/nodes-base/nodes/UrlScanIo/GenericFunctions.ts new file mode 100644 index 0000000000..8a3d5deaf6 --- /dev/null +++ b/packages/nodes-base/nodes/UrlScanIo/GenericFunctions.ts @@ -0,0 +1,85 @@ +import { + OptionsWithUri, +} from 'request'; + +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + IDataObject, + NodeApiError, +} from 'n8n-workflow'; + +export async function urlScanIoApiRequest( + this: IExecuteFunctions, + method: 'GET' | 'POST', + endpoint: string, + body: IDataObject = {}, + qs: IDataObject = {}, +) { + const { apiKey } = await this.getCredentials('urlScanIoApi') as { apiKey: string }; + + const options: OptionsWithUri = { + headers: { + 'API-KEY': apiKey, + }, + method, + body, + qs, + uri: `https://urlscan.io/api/v1${endpoint}`, + json: true, + }; + + if (!Object.keys(body).length) { + delete options.body; + } + + if (!Object.keys(qs).length) { + delete options.qs; + } + + try { + return await this.helpers.request(options); + } catch (error) { + throw new NodeApiError(this.getNode(), error); + } +} + + +export async function handleListing( + this: IExecuteFunctions, + endpoint: string, + qs: IDataObject = {}, +): Promise { + const returnData: IDataObject[] = []; + let responseData; + + qs.size = 100; + + const returnAll = this.getNodeParameter('returnAll', 0, false) as boolean; + const limit = this.getNodeParameter('limit', 0, 0) as number; + + do { + responseData = await urlScanIoApiRequest.call(this, 'GET', endpoint, {}, qs); + returnData.push(...responseData.results); + + if (!returnAll && returnData.length > limit) { + return returnData.slice(0, limit); + } + + if (responseData.results.length) { + const lastResult = responseData.results[responseData.results.length -1]; + qs.search_after = lastResult.sort; + } + + } while (responseData.total > returnData.length); + + return returnData; +} + +export const normalizeId = ({ _id, uuid, ...rest }: IDataObject) => { + if (_id) return ({ scanId: _id, ...rest }); + if (uuid) return ({ scanId: uuid, ...rest }); + return rest; +}; diff --git a/packages/nodes-base/nodes/UrlScanIo/UrlScanIo.node.ts b/packages/nodes-base/nodes/UrlScanIo/UrlScanIo.node.ts new file mode 100644 index 0000000000..cc6d956e9f --- /dev/null +++ b/packages/nodes-base/nodes/UrlScanIo/UrlScanIo.node.ts @@ -0,0 +1,212 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + ICredentialsDecrypted, + ICredentialTestFunctions, + IDataObject, + INodeExecutionData, + INodeType, + INodeTypeDescription, + NodeCredentialTestResult, + NodeOperationError, +} from 'n8n-workflow'; + +import { + OptionsWithUri, +} from 'request'; + +import { + scanFields, + scanOperations, +} from './descriptions'; + +import { + handleListing, + normalizeId, + urlScanIoApiRequest, +} from './GenericFunctions'; + +export class UrlScanIo implements INodeType { + description: INodeTypeDescription = { + displayName: 'urlscan.io', + name: 'urlScanIo', + icon: 'file:urlScanIo.svg', + group: ['transform'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Consume the urlscan.io API', + defaults: { + name: 'urlscan.io', + color: '#f3d337', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'urlScanIoApi', + required: true, + testedBy: 'urlScanIoApiTest', + }, + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + noDataExpression: true, + type: 'options', + options: [ + { + name: 'Scan', + value: 'scan', + }, + ], + default: 'scan', + }, + ...scanOperations, + ...scanFields, + ], + }; + + methods = { + credentialTest: { + async urlScanIoApiTest( + this: ICredentialTestFunctions, + credentials: ICredentialsDecrypted, + ): Promise { + const { apiKey } = credentials.data as { apiKey: string }; + + const options: OptionsWithUri = { + headers: { + 'API-KEY': apiKey, + }, + method: 'GET', + uri: 'https://urlscan.io/user/quotas', + json: true, + }; + + try { + await this.helpers.request(options); + return { + status: 'OK', + message: 'Authentication successful', + }; + } catch (error) { + return { + status: 'Error', + message: error.message, + }; + } + }, + }, + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const returnData: IDataObject[] = []; + + const resource = this.getNodeParameter('resource', 0) as 'scan'; + const operation = this.getNodeParameter('operation', 0) as 'perform' | 'get' | 'getAll'; + + let responseData; + + for (let i = 0; i < items.length; i++) { + + try { + + if (resource === 'scan') { + + // ********************************************************************** + // scan + // ********************************************************************** + + if (operation === 'get') { + + // ---------------------------------------- + // scan: get + // ---------------------------------------- + + const scanId = this.getNodeParameter('scanId', i) as string; + responseData = await urlScanIoApiRequest.call(this, 'GET', `/result/${scanId}`); + + } else if (operation === 'getAll') { + + // ---------------------------------------- + // scan: getAll + // ---------------------------------------- + + // https://urlscan.io/docs/search + + const filters = this.getNodeParameter('filters', i) as { query?: string }; + + const qs: IDataObject = {}; + + if (filters?.query) { + qs.q = filters.query; + } + + responseData = await handleListing.call(this, '/search', qs); + responseData = responseData.map(normalizeId); + + } else if (operation === 'perform') { + + // ---------------------------------------- + // scan: perform + // ---------------------------------------- + + // https://urlscan.io/docs/search + + const { + tags: rawTags, + ...rest + } = this.getNodeParameter('additionalFields', i) as { + customAgent?: string; + visibility?: 'public' | 'private' | 'unlisted'; + tags?: string; + referer?: string; + overrideSafety: string; + }; + + const body: IDataObject = { + url: this.getNodeParameter('url', i) as string, + ...rest, + }; + + if (rawTags) { + const tags = rawTags.split(',').map(tag => tag.trim()); + + if (tags.length > 10) { + throw new NodeOperationError( + this.getNode(), + 'Please enter at most 10 tags', + ); + } + + body.tags = tags; + } + + responseData = await urlScanIoApiRequest.call(this, 'POST', '/scan', body); + responseData = normalizeId(responseData); + + } + + } + + Array.isArray(responseData) + ? returnData.push(...responseData) + : returnData.push(responseData); + + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ error: error.message }); + continue; + } + throw error; + } + + } + + return [this.helpers.returnJsonArray(returnData)]; + } +} diff --git a/packages/nodes-base/nodes/UrlScanIo/descriptions/ScanDescription.ts b/packages/nodes-base/nodes/UrlScanIo/descriptions/ScanDescription.ts new file mode 100644 index 0000000000..e8dde789e1 --- /dev/null +++ b/packages/nodes-base/nodes/UrlScanIo/descriptions/ScanDescription.ts @@ -0,0 +1,218 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const scanOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: [ + 'scan', + ], + }, + }, + options: [ + { + name: 'Get', + value: 'get', + }, + { + name: 'Get All', + value: 'getAll', + }, + { + name: 'Perform', + value: 'perform', + }, + ], + default: 'perform', + }, +]; + +export const scanFields: INodeProperties[] = [ + // ---------------------------------------- + // scan: get + // ---------------------------------------- + { + displayName: 'Scan ID', + name: 'scanId', + type: 'string', + default: '', + description: 'ID of the scan to retrieve', + displayOptions: { + show: { + resource: [ + 'scan', + ], + operation: [ + 'get', + ], + }, + }, + }, + + // ---------------------------------------- + // scan: getAll + // ---------------------------------------- + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + default: false, + description: 'Whether to return all results or only up to a given limit', + displayOptions: { + show: { + resource: [ + 'scan', + ], + operation: [ + 'getAll', + ], + }, + }, + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + default: 50, + description: 'Max number of results to return', + typeOptions: { + minValue: 1, + }, + displayOptions: { + show: { + resource: [ + 'scan', + ], + operation: [ + 'getAll', + ], + returnAll: [ + false, + ], + }, + }, + }, + { + displayName: 'Filters', + name: 'filters', + type: 'collection', + placeholder: 'Add Filter', + default: {}, + displayOptions: { + show: { + resource: [ + 'scan', + ], + operation: [ + 'getAll', + ], + }, + }, + options: [ + { + displayName: 'Query', + name: 'query', + type: 'string', + description: 'Query using the Elastic Search Query String syntax. See supported fields in the documentation.', + default: '', + placeholder: 'domain:n8n.io', + }, + ], + }, + + // ---------------------------------------- + // scan: perform + // ---------------------------------------- + { + displayName: 'URL', + name: 'url', + type: 'string', + default: '', + placeholder: 'https://n8n.io', + description: 'URL to scan', + displayOptions: { + show: { + resource: [ + 'scan', + ], + operation: [ + 'perform', + ], + }, + }, + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'scan', + ], + operation: [ + 'perform', + ], + }, + }, + options: [ + { + displayName: 'Custom Agent', + name: 'customAgent', + description: 'User-Agent header to set for this scan. Defaults to n8n', + type: 'string', + default: '', + }, + { + displayName: 'Override Safety', + name: 'overrideSafety', + description: 'Disable reclassification of URLs with potential PII in them', + type: 'string', + default: '', + }, + { + displayName: 'Referer', + name: 'referer', + description: 'HTTP referer to set for this scan', + type: 'string', + default: '', + }, + { + displayName: 'Tags', + name: 'tags', + description: 'Comma-separated list of user-defined tags to add to this scan. Limited to 10 tags.', + placeholder: 'phishing, malicious', + type: 'string', + default: '', + }, + { + displayName: 'Visibility', + name: 'visibility', + type: 'options', + default: 'private', + options: [ + { + name: 'Private', + value: 'private', + }, + { + name: 'Public', + value: 'public', + }, + { + name: 'Unlisted', + value: 'unlisted', + }, + ], + }, + ], + }, +]; diff --git a/packages/nodes-base/nodes/UrlScanIo/descriptions/index.ts b/packages/nodes-base/nodes/UrlScanIo/descriptions/index.ts new file mode 100644 index 0000000000..ee9d97583b --- /dev/null +++ b/packages/nodes-base/nodes/UrlScanIo/descriptions/index.ts @@ -0,0 +1 @@ +export * from './ScanDescription'; diff --git a/packages/nodes-base/nodes/UrlScanIo/urlScanIo.svg b/packages/nodes-base/nodes/UrlScanIo/urlScanIo.svg new file mode 100644 index 0000000000..e361a718c5 --- /dev/null +++ b/packages/nodes-base/nodes/UrlScanIo/urlScanIo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index fc00fea912..ba3551c9c1 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -286,6 +286,7 @@ "dist/credentials/UpleadApi.credentials.js", "dist/credentials/UProcApi.credentials.js", "dist/credentials/UptimeRobotApi.credentials.js", + "dist/credentials/UrlScanIoApi.credentials.js", "dist/credentials/VeroApi.credentials.js", "dist/credentials/VonageApi.credentials.js", "dist/credentials/WebflowApi.credentials.js", @@ -606,6 +607,7 @@ "dist/nodes/Uplead/Uplead.node.js", "dist/nodes/UProc/UProc.node.js", "dist/nodes/UptimeRobot/UptimeRobot.node.js", + "dist/nodes/UrlScanIo/UrlScanIo.node.js", "dist/nodes/Vero/Vero.node.js", "dist/nodes/Vonage/Vonage.node.js", "dist/nodes/Wait.node.js", From 04a043616ec141e9c294bdc8c6461348c6a7888b Mon Sep 17 00:00:00 2001 From: Mutasem Aldmour <4711238+mutdmour@users.noreply.github.com> Date: Thu, 30 Sep 2021 20:26:29 +0200 Subject: [PATCH 022/290] :bug: Remove backdrop filter to speed up editor-UI (#2268) --- packages/design-system/theme/src/common/popup.scss | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/design-system/theme/src/common/popup.scss b/packages/design-system/theme/src/common/popup.scss index da91b42151..d82c1246a0 100644 --- a/packages/design-system/theme/src/common/popup.scss +++ b/packages/design-system/theme/src/common/popup.scss @@ -12,20 +12,16 @@ @keyframes v-modal-in { 0% { opacity: 0; - backdrop-filter: blur(4px) opacity(0); } 100% { - backdrop-filter: blur(4px) opacity(1); } } @keyframes v-modal-out { 0% { - backdrop-filter: blur(4px) opacity(1); } 100% { opacity: 0; - backdrop-filter: blur(4px) opacity(0); } } @@ -36,7 +32,6 @@ width: 100%; height: 100%; background-color: var.$popup-modal-background-color; - backdrop-filter: blur(4px) opacity(1); } @include mixins.b(popup-parent) { From 4a3d3cd3318e601dc39b86c847f7f1db4164a1a0 Mon Sep 17 00:00:00 2001 From: Omar Ajoue Date: Thu, 30 Sep 2021 21:10:56 +0200 Subject: [PATCH 023/290] :bug: Improve Axios compatibility (#2262) * Improved the error object returned by axios to make it more compatible with request * Fixed multipart/form-data payload creation * :bug: Remove issue with circular references Co-authored-by: Jan Oberhauser --- packages/core/src/NodeExecuteFunctions.ts | 98 ++++++++++++----------- 1 file changed, 53 insertions(+), 45 deletions(-) diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index eb980476f9..5f2ffe499b 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -91,6 +91,33 @@ const requestPromiseWithDefaults = requestPromise.defaults({ timeout: 300000, // 5 minutes }); +const pushFormDataValue = (form: FormData, key: string, value: any) => { + if (value?.hasOwnProperty('value') && value.hasOwnProperty('options')) { + // @ts-ignore + form.append(key, value.value, value.options); + } else { + form.append(key, value); + } +}; + +const createFormDataObject = (data: object) => { + const formData = new FormData(); + const keys = Object.keys(data); + keys.forEach((key) => { + // @ts-ignore + const formField = data[key]; + + if (formField instanceof Array) { + formField.forEach((item) => { + pushFormDataValue(formData, key, item); + }); + } else { + pushFormDataValue(formData, key, formField); + } + }); + return formData; +}; + async function parseRequestObject(requestObject: IDataObject) { // This function is a temporary implementation // That translates all http requests done via @@ -139,28 +166,12 @@ async function parseRequestObject(requestObject: IDataObject) { if (requestObject.formData !== undefined && requestObject.formData instanceof FormData) { axiosConfig.data = requestObject.formData; } else { - const allData = Object.assign(requestObject.body || {}, requestObject.formData || {}); + const allData = { + ...(requestObject.body as object | undefined), + ...(requestObject.formData as object | undefined), + }; - const objectKeys = Object.keys(allData); - if (objectKeys.length > 0) { - // Should be a standard object. We must convert to formdata - const form = new FormData(); - - objectKeys.forEach((key) => { - const formField = (allData as IDataObject)[key] as IDataObject; - if (formField.hasOwnProperty('value') && formField.value instanceof Buffer) { - let filename; - // @ts-ignore - if (!!formField.options && formField.options.filename !== undefined) { - filename = (formField.options as IDataObject).filename as string; - } - form.append(key, formField.value, filename); - } else { - form.append(key, formField); - } - }); - axiosConfig.data = form; - } + axiosConfig.data = createFormDataObject(allData); } // replace the existing header with a new one that // contains the boundary property. @@ -197,26 +208,7 @@ async function parseRequestObject(requestObject: IDataObject) { if (requestObject.formData instanceof FormData) { axiosConfig.data = requestObject.formData; } else { - const objectKeys = Object.keys(requestObject.formData as object); - if (objectKeys.length > 0) { - // Should be a standard object. We must convert to formdata - const form = new FormData(); - - objectKeys.forEach((key) => { - const formField = (requestObject.formData as IDataObject)[key] as IDataObject; - if (formField.hasOwnProperty('value') && formField.value instanceof Buffer) { - let filename; - // @ts-ignore - if (!!formField.options && formField.options.filename !== undefined) { - filename = (formField.options as IDataObject).filename as string; - } - form.append(key, formField.value, filename); - } else { - form.append(key, formField); - } - }); - axiosConfig.data = form; - } + axiosConfig.data = createFormDataObject(requestObject.formData as object); } // Mix in headers as FormData creates the boundary. const headers = axiosConfig.data.getHeaders(); @@ -415,10 +407,26 @@ async function proxyRequestToAxios( } }) .catch((error) => { - // The error-data was made available with request library via "error" but now on - // axios via "response.data" so copy information over to keep it compatible - error.error = error.response.data; - error.statusCode = error.response.status; + Logger.debug('Request proxied to Axios failed', { error }); + // Axios hydrates the original error with more data. We extract them. + // https://github.com/axios/axios/blob/master/lib/core/enhanceError.js + // Note: `code` is ignored as it's an expected part of the errorData. + const { request, response, isAxiosError, toJSON, config, ...errorData } = error; + error.cause = errorData; + error.error = error.response?.data || errorData; + error.statusCode = error.response?.status; + error.options = config; + + // Remove not needed data and so also remove circular references + error.request = undefined; + error.config = undefined; + error.options.adapter = undefined; + error.options.httpsAgent = undefined; + error.options.paramsSerializer = undefined; + error.options.transformRequest = undefined; + error.options.transformResponse = undefined; + error.options.validateStatus = undefined; + reject(error); }); }); From b16e2eff0c64182b251296bf97fbd777253b3af6 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Thu, 30 Sep 2021 19:21:20 +0000 Subject: [PATCH 024/290] =?UTF-8?q?:bookmark:=20Release=C2=A0n8n-core@0.86?= =?UTF-8?q?.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/core/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/package.json b/packages/core/package.json index f13c3af2a8..80d266c48d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "n8n-core", - "version": "0.85.0", + "version": "0.86.0", "description": "Core functionality of n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", From 9c66323b16b25662d84832a3246d40f5993e70c4 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Thu, 30 Sep 2021 19:21:40 +0000 Subject: [PATCH 025/290] :arrow_up: Set n8n-core@0.86.0 on n8n-node-dev --- packages/node-dev/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/node-dev/package.json b/packages/node-dev/package.json index 6223a62818..32cf6cca20 100644 --- a/packages/node-dev/package.json +++ b/packages/node-dev/package.json @@ -60,7 +60,7 @@ "change-case": "^4.1.1", "copyfiles": "^2.1.1", "inquirer": "^7.0.1", - "n8n-core": "~0.85.0", + "n8n-core": "~0.86.0", "n8n-workflow": "~0.70.0", "oauth-1.0a": "^2.2.6", "replace-in-file": "^6.0.0", From 72c5ab9cb467d647d3ac042837012c2c4a14e710 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Thu, 30 Sep 2021 19:21:40 +0000 Subject: [PATCH 026/290] =?UTF-8?q?:bookmark:=20Release=C2=A0n8n-node-dev@?= =?UTF-8?q?0.26.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/node-dev/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/node-dev/package.json b/packages/node-dev/package.json index 32cf6cca20..aefe1457eb 100644 --- a/packages/node-dev/package.json +++ b/packages/node-dev/package.json @@ -1,6 +1,6 @@ { "name": "n8n-node-dev", - "version": "0.25.0", + "version": "0.26.0", "description": "CLI to simplify n8n credentials/node development", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", From 8376b5c5368becb6c2e5622a901a639e68794936 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Thu, 30 Sep 2021 19:22:01 +0000 Subject: [PATCH 027/290] :arrow_up: Set n8n-core@0.86.0 on n8n-nodes-base --- packages/nodes-base/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index ba3551c9c1..f1e05b9f39 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -701,7 +701,7 @@ "mqtt": "4.2.6", "mssql": "^6.2.0", "mysql2": "~2.3.0", - "n8n-core": "~0.85.0", + "n8n-core": "~0.86.0", "node-ssh": "^11.0.0", "nodemailer": "^6.5.0", "pdf-parse": "^1.1.1", From d1c166dd8feb5d5d9fcce040dfc54595356c0107 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Thu, 30 Sep 2021 19:22:01 +0000 Subject: [PATCH 028/290] =?UTF-8?q?:bookmark:=20Release=C2=A0n8n-nodes-bas?= =?UTF-8?q?e@0.138.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/nodes-base/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index f1e05b9f39..341eff0860 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -1,6 +1,6 @@ { "name": "n8n-nodes-base", - "version": "0.137.0", + "version": "0.138.0", "description": "Base nodes of n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", From 2e6e10e4eb8da08e3b0a483f9ae78092897d81ad Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Thu, 30 Sep 2021 19:22:43 +0000 Subject: [PATCH 029/290] =?UTF-8?q?:bookmark:=20Release=C2=A0n8n-design-sy?= =?UTF-8?q?stem@0.4.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/design-system/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/design-system/package.json b/packages/design-system/package.json index 409132cfd6..5825418f72 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -1,6 +1,6 @@ { "name": "n8n-design-system", - "version": "0.3.0", + "version": "0.4.0", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", "author": { From 70c5795564a155e1b3374c79ccb85240b368adab Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Thu, 30 Sep 2021 19:22:52 +0000 Subject: [PATCH 030/290] :arrow_up: Set n8n-design-system@0.4.0 on n8n-editor-ui --- packages/editor-ui/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index b847832baa..a0d491accc 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -26,7 +26,7 @@ }, "dependencies": { "@fontsource/open-sans": "^4.5.0", - "n8n-design-system": "~0.3.0", + "n8n-design-system": "~0.4.0", "timeago.js": "^4.0.2", "v-click-outside": "^3.1.2", "vue-fragment": "^1.5.2" From 02100a3daf007ac879336db6ab8e321fcb71159d Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Thu, 30 Sep 2021 19:22:52 +0000 Subject: [PATCH 031/290] =?UTF-8?q?:bookmark:=20Release=C2=A0n8n-editor-ui?= =?UTF-8?q?@0.109.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/editor-ui/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index a0d491accc..36918156b2 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -1,6 +1,6 @@ { "name": "n8n-editor-ui", - "version": "0.108.0", + "version": "0.109.0", "description": "Workflow Editor UI for n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", From a675faffd585d7d475183b1e652bd285a8a255df Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Thu, 30 Sep 2021 19:23:13 +0000 Subject: [PATCH 032/290] :arrow_up: Set n8n-core@0.86.0, n8n-editor-ui@0.109.0 and n8n-nodes-base@0.138.0 on n8n --- packages/cli/package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 2bbe994c0c..87a53596ad 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -109,9 +109,9 @@ "localtunnel": "^2.0.0", "lodash.get": "^4.4.2", "mysql2": "~2.3.0", - "n8n-core": "~0.85.0", - "n8n-editor-ui": "~0.108.0", - "n8n-nodes-base": "~0.137.0", + "n8n-core": "~0.86.0", + "n8n-editor-ui": "~0.109.0", + "n8n-nodes-base": "~0.138.0", "n8n-workflow": "~0.70.0", "oauth-1.0a": "^2.2.6", "open": "^7.0.0", From 0243fc68d9351f3af4a31f0a3959c7cd25d7fce4 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Thu, 30 Sep 2021 19:23:13 +0000 Subject: [PATCH 033/290] =?UTF-8?q?:bookmark:=20Release=C2=A0n8n@0.141.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 87a53596ad..21da879618 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "0.140.0", + "version": "0.141.0", "description": "n8n Workflow Automation Tool", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", From 5c206a3da55171b43406772c3a94f6c22c9b9e11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 1 Oct 2021 16:21:28 +0200 Subject: [PATCH 034/290] :zap: Change Urlscan.io description (#2270) --- packages/nodes-base/nodes/UrlScanIo/UrlScanIo.node.ts | 2 +- .../nodes-base/nodes/UrlScanIo/descriptions/ScanDescription.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/nodes-base/nodes/UrlScanIo/UrlScanIo.node.ts b/packages/nodes-base/nodes/UrlScanIo/UrlScanIo.node.ts index cc6d956e9f..42971c57d3 100644 --- a/packages/nodes-base/nodes/UrlScanIo/UrlScanIo.node.ts +++ b/packages/nodes-base/nodes/UrlScanIo/UrlScanIo.node.ts @@ -36,7 +36,7 @@ export class UrlScanIo implements INodeType { group: ['transform'], version: 1, subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', - description: 'Consume the urlscan.io API', + description: 'Provides various utilities for monitoring websites like health checks or screenshots', defaults: { name: 'urlscan.io', color: '#f3d337', diff --git a/packages/nodes-base/nodes/UrlScanIo/descriptions/ScanDescription.ts b/packages/nodes-base/nodes/UrlScanIo/descriptions/ScanDescription.ts index e8dde789e1..9918126dc3 100644 --- a/packages/nodes-base/nodes/UrlScanIo/descriptions/ScanDescription.ts +++ b/packages/nodes-base/nodes/UrlScanIo/descriptions/ScanDescription.ts @@ -183,6 +183,7 @@ export const scanFields: INodeProperties[] = [ name: 'referer', description: 'HTTP referer to set for this scan', type: 'string', + placeholder: 'https://n8n.io', default: '', }, { From 3253a81318fbfdcfac5302fe4cca44b220088e37 Mon Sep 17 00:00:00 2001 From: Omar Ajoue Date: Fri, 1 Oct 2021 17:43:50 +0200 Subject: [PATCH 035/290] :bug: Fix body formatting for x-form-www-urlencoded (#2269) --- packages/core/src/NodeExecuteFunctions.ts | 34 ++++++++++++----------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index 5f2ffe499b..8a128db12d 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -118,6 +118,16 @@ const createFormDataObject = (data: object) => { return formData; }; +function searchForHeader(headers: IDataObject, headerName: string) { + if (headers === undefined) { + return undefined; + } + + const headerNames = Object.keys(headers); + headerName = headerName.toLowerCase(); + return headerNames.find((thisHeader) => thisHeader.toLowerCase() === headerName); +} + async function parseRequestObject(requestObject: IDataObject) { // This function is a temporary implementation // That translates all http requests done via @@ -183,13 +193,15 @@ async function parseRequestObject(requestObject: IDataObject) { // When using the `form` property it means the content should be x-www-form-urlencoded. if (requestObject.form !== undefined && requestObject.body === undefined) { // If we have only form - axiosConfig.data = new URLSearchParams(requestObject.form as Record); + axiosConfig.data = + typeof requestObject.form === 'string' + ? stringify(requestObject.form, { format: 'RFC3986' }) + : stringify(requestObject.form).toString(); if (axiosConfig.headers !== undefined) { - // remove possibly existing content-type headers - const headers = Object.keys(axiosConfig.headers); - headers.forEach((header) => - header.toLowerCase() === 'content-type' ? delete axiosConfig.headers[header] : null, - ); + const headerName = searchForHeader(axiosConfig.headers, 'content-type'); + if (headerName) { + delete axiosConfig.headers[headerName]; + } axiosConfig.headers['Content-Type'] = 'application/x-www-form-urlencoded'; } else { axiosConfig.headers = { @@ -432,16 +444,6 @@ async function proxyRequestToAxios( }); } -function searchForHeader(headers: IDataObject, headerName: string) { - if (headers === undefined) { - return undefined; - } - - const headerNames = Object.keys(headers); - headerName = headerName.toLowerCase(); - return headerNames.find((thisHeader) => thisHeader.toLowerCase() === headerName); -} - function convertN8nRequestToAxios(n8nRequest: IHttpRequestOptions): AxiosRequestConfig { // Destructure properties with the same name first. const { headers, method, timeout, auth, proxy, url } = n8nRequest; From 332724bbec809a25f85c9ca3d9bb362bea8348d9 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Fri, 1 Oct 2021 10:46:32 -0500 Subject: [PATCH 036/290] =?UTF-8?q?:bookmark:=20Release=C2=A0n8n-core@0.86?= =?UTF-8?q?.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/core/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/package.json b/packages/core/package.json index 80d266c48d..5cb4e2e0aa 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "n8n-core", - "version": "0.86.0", + "version": "0.86.1", "description": "Core functionality of n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", From 5002ebfabe295dab478f9ab3fe0f919187b86c63 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Fri, 1 Oct 2021 10:47:40 -0500 Subject: [PATCH 037/290] :arrow_up: Set n8n-core@0.86.1 on n8n --- packages/cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 21da879618..63a51af1dc 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -109,7 +109,7 @@ "localtunnel": "^2.0.0", "lodash.get": "^4.4.2", "mysql2": "~2.3.0", - "n8n-core": "~0.86.0", + "n8n-core": "~0.86.1", "n8n-editor-ui": "~0.109.0", "n8n-nodes-base": "~0.138.0", "n8n-workflow": "~0.70.0", From a077c8e41669cff76c7f895320b69d378fbd9331 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Fri, 1 Oct 2021 10:48:09 -0500 Subject: [PATCH 038/290] =?UTF-8?q?:bookmark:=20Release=C2=A0n8n@0.141.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 63a51af1dc..c041a62c09 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "0.141.0", + "version": "0.141.1", "description": "n8n Workflow Automation Tool", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", From 3488edad7bcba725a28f97ba081094ea0e424711 Mon Sep 17 00:00:00 2001 From: Tom Klingenberg Date: Mon, 4 Oct 2021 12:10:39 +0200 Subject: [PATCH 039/290] :zap: Fix whitespace editorconfig highlights for seaTable node --- packages/nodes-base/nodes/SeaTable/Interfaces.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/nodes-base/nodes/SeaTable/Interfaces.ts b/packages/nodes-base/nodes/SeaTable/Interfaces.ts index 8d8357476b..4d8269edb2 100644 --- a/packages/nodes-base/nodes/SeaTable/Interfaces.ts +++ b/packages/nodes-base/nodes/SeaTable/Interfaces.ts @@ -93,9 +93,9 @@ export interface ICtx { export interface IRowResponse{ metadata: [ - { + { key: string, - name: string + name: string } ]; results: IRow[]; From a04ec2102ccfda500a3952bf5fe5e259843a8044 Mon Sep 17 00:00:00 2001 From: Tom Klingenberg Date: Mon, 4 Oct 2021 13:49:16 +0200 Subject: [PATCH 040/290] :zap: Fix common user-error Given a domain (the base-URI) in credentials terminated by a slash "/" is a common user-error when entering such data. Pass through userBaseUri() to trim slashes from the end of the string. --- .../nodes-base/nodes/SeaTable/GenericFunctions.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/nodes-base/nodes/SeaTable/GenericFunctions.ts b/packages/nodes-base/nodes/SeaTable/GenericFunctions.ts index 267e9390f1..4f39075bbd 100644 --- a/packages/nodes-base/nodes/SeaTable/GenericFunctions.ts +++ b/packages/nodes-base/nodes/SeaTable/GenericFunctions.ts @@ -129,7 +129,7 @@ export async function getBaseAccessToken(this: IExecuteFunctions | ILoadOptionsF export function resolveBaseUri(ctx: ICtx) { return (ctx?.credentials?.environment === 'cloudHosted') - ? 'https://cloud.seatable.io' : ctx?.credentials?.domain; + ? 'https://cloud.seatable.io' : userBaseUri(ctx?.credentials?.domain); } export function simplify(data: { results: IRow[] }, metadata: IDataObject) { @@ -292,3 +292,12 @@ export const split = (subject: string): string[] => .filter(s => s.length) .map(s => s.replace(/\\([\s\S])/gm, ($0, $1) => $1)) ; + +const userBaseUri = (str?: string) => { + if (str === undefined) { + return str; + } + let end = str.length; + for (; end > 0 && str[end - 1] === '/'; --end) {} + return end < str.length ? str.substring(0, end) : str; +}; From a4374e235e25a860456f8e2a4847d43b96946437 Mon Sep 17 00:00:00 2001 From: Mutasem Aldmour <4711238+mutdmour@users.noreply.github.com> Date: Tue, 5 Oct 2021 20:28:37 +0200 Subject: [PATCH 041/290] :zap: Add open event (#2261) --- .../src/components/CredentialEdit/CredentialEdit.vue | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/editor-ui/src/components/CredentialEdit/CredentialEdit.vue b/packages/editor-ui/src/components/CredentialEdit/CredentialEdit.vue index a3b899f0d8..4fba543573 100644 --- a/packages/editor-ui/src/components/CredentialEdit/CredentialEdit.vue +++ b/packages/editor-ui/src/components/CredentialEdit/CredentialEdit.vue @@ -202,6 +202,12 @@ export default mixins(showMessage, nodeHelpers).extend({ } } + this.$externalHooks().run('credentialsEdit.credentialModalOpened', { + credentialType: this.credentialTypeName, + isEditingCredential: this.mode === 'edit', + activeNode: this.$store.getters.activeNode, + }); + if (this.credentialId) { if (!this.requiredPropertiesFilled) { this.showValidationWarning = true; From 582f9a1e6168fa653d5a4fdc48cb88efdb5d62f9 Mon Sep 17 00:00:00 2001 From: Mutasem Aldmour <4711238+mutdmour@users.noreply.github.com> Date: Tue, 5 Oct 2021 20:33:25 +0200 Subject: [PATCH 042/290] :bug: Remove overlap on mobile between buttons (#2233) * remove overlap on mobile between buttons * update breakpoints --- packages/editor-ui/src/n8n-theme-variables.scss | 2 ++ packages/editor-ui/src/views/NodeView.vue | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/packages/editor-ui/src/n8n-theme-variables.scss b/packages/editor-ui/src/n8n-theme-variables.scss index b414edca4b..3bb3da63f8 100644 --- a/packages/editor-ui/src/n8n-theme-variables.scss +++ b/packages/editor-ui/src/n8n-theme-variables.scss @@ -59,6 +59,7 @@ $--gift-notification-outer-color: #fff; $--tags-manager-min-height: 300px; // based on element.io breakpoints +$--breakpoint-2xs: 600px; $--breakpoint-xs: 768px; $--breakpoint-sm: 992px; $--breakpoint-md: 1200px; @@ -110,3 +111,4 @@ $--version-card-description-text-color: #7d7d87; $--version-card-release-date-text-color: #909399; $--version-card-box-shadow-color: rgba(109, 48, 40, 0.07); + diff --git a/packages/editor-ui/src/views/NodeView.vue b/packages/editor-ui/src/views/NodeView.vue index 12a2a1b2d3..23e200fe90 100644 --- a/packages/editor-ui/src/views/NodeView.vue +++ b/packages/editor-ui/src/views/NodeView.vue @@ -2308,6 +2308,10 @@ export default mixins( color: #444; padding-right: 5px; + @media (max-width: $--breakpoint-2xs) { + bottom: 90px; + } + &.expanded { left: $--sidebar-expanded-width + $--zoom-menu-margin; } From 3195e997bafbc63cc7dd02729190f8df8e521484 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Wed, 6 Oct 2021 11:51:58 -0500 Subject: [PATCH 043/290] :bug: Fix issue part-workflow execution wait-node being the last --- packages/core/src/WorkflowExecute.ts | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/core/src/WorkflowExecute.ts b/packages/core/src/WorkflowExecute.ts index df84915d75..82418016d5 100644 --- a/packages/core/src/WorkflowExecute.ts +++ b/packages/core/src/WorkflowExecute.ts @@ -918,6 +918,19 @@ export class WorkflowExecute { this.runExecutionData.resultData.runData[executionNode.name].push(taskData); + if (this.runExecutionData.waitTill!) { + await this.executeHook('nodeExecuteAfter', [ + executionNode.name, + taskData, + this.runExecutionData, + ]); + + // Add the node back to the stack that the workflow can start to execute again from that node + this.runExecutionData.executionData!.nodeExecutionStack.unshift(executionData); + + break; + } + if ( this.runExecutionData.startData && this.runExecutionData.startData.destinationNode && @@ -935,19 +948,6 @@ export class WorkflowExecute { continue; } - if (this.runExecutionData.waitTill!) { - await this.executeHook('nodeExecuteAfter', [ - executionNode.name, - taskData, - this.runExecutionData, - ]); - - // Add the node back to the stack that the workflow can start to execute again from that node - this.runExecutionData.executionData!.nodeExecutionStack.unshift(executionData); - - break; - } - // Add the nodes to which the current node has an output connection to that they can // be executed next if (workflow.connectionsBySourceNode.hasOwnProperty(executionNode.name)) { From 9dbf6e5f6d03c062cc0c42da3e37f0973b5a9e0b Mon Sep 17 00:00:00 2001 From: Jan Date: Wed, 6 Oct 2021 12:00:38 -0500 Subject: [PATCH 044/290] :bug: Fix issue with none-json requests (#2283) --- packages/core/src/NodeExecuteFunctions.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index 8a128db12d..6786c10ea5 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -337,6 +337,7 @@ async function parseRequestObject(requestObject: IDataObject) { axiosConfig.headers = Object.assign(axiosConfig.headers || {}, { accept: '*/*' }); } if ( + requestObject.json !== false && axiosConfig.data !== undefined && !(axiosConfig.data instanceof Buffer) && !allHeaders.some((headerKey) => headerKey.toLowerCase() === 'content-type') From f7148bdd77a3fe6d608609c3849e70a3e9d5d9fa Mon Sep 17 00:00:00 2001 From: Jan Date: Wed, 6 Oct 2021 12:02:31 -0500 Subject: [PATCH 045/290] Display node-error only on executing nodes (#2274) * :zap: Dot not display errors on disconnected nodes * :zap: Fix some more inconsistencies --- packages/core/src/WorkflowExecute.ts | 9 ++++- .../src/components/mixins/workflowHelpers.ts | 39 +++++++++++++++++-- .../src/components/mixins/workflowRun.ts | 11 ++++-- packages/editor-ui/src/constants.ts | 1 + packages/workflow/src/Workflow.ts | 20 +++++++++- 5 files changed, 70 insertions(+), 10 deletions(-) diff --git a/packages/core/src/WorkflowExecute.ts b/packages/core/src/WorkflowExecute.ts index 82418016d5..65361586fe 100644 --- a/packages/core/src/WorkflowExecute.ts +++ b/packages/core/src/WorkflowExecute.ts @@ -582,7 +582,14 @@ export class WorkflowExecute { const startedAt = new Date(); - const workflowIssues = workflow.checkReadyForExecution(); + const startNode = this.runExecutionData.executionData!.nodeExecutionStack[0].node.name; + + let destinationNode: string | undefined; + if (this.runExecutionData.startData && this.runExecutionData.startData.destinationNode) { + destinationNode = this.runExecutionData.startData.destinationNode; + } + + const workflowIssues = workflow.checkReadyForExecution({ startNode, destinationNode }); if (workflowIssues !== null) { throw new Error( 'The workflow has issues and can for that reason not be executed. Please fix them first.', diff --git a/packages/editor-ui/src/components/mixins/workflowHelpers.ts b/packages/editor-ui/src/components/mixins/workflowHelpers.ts index 23c7e53f4b..907f5bac02 100644 --- a/packages/editor-ui/src/components/mixins/workflowHelpers.ts +++ b/packages/editor-ui/src/components/mixins/workflowHelpers.ts @@ -1,6 +1,9 @@ import { + ERROR_TRIGGER_NODE_NAME, PLACEHOLDER_FILLED_AT_EXECUTION_TIME, PLACEHOLDER_EMPTY_WORKFLOW_ID, + START_NODE_TYPE, + WEBHOOK_NODE_NAME, } from '@/constants'; import { @@ -144,13 +147,39 @@ export const workflowHelpers = mixins( }, // Checks if everything in the workflow is complete and ready to be executed - checkReadyForExecution (workflow: Workflow) { + checkReadyForExecution (workflow: Workflow, lastNodeName?: string) { let node: INode; let nodeType: INodeType | undefined; let nodeIssues: INodeIssues | null = null; const workflowIssues: IWorfklowIssues = {}; - for (const nodeName of Object.keys(workflow.nodes)) { + let checkNodes = Object.keys(workflow.nodes); + if (lastNodeName) { + checkNodes = workflow.getParentNodes(lastNodeName); + checkNodes.push(lastNodeName); + } else { + // As webhook nodes always take presidence check first + // if there are any + let checkWebhook: string[] = []; + for (const nodeName of Object.keys(workflow.nodes)) { + if (workflow.nodes[nodeName].disabled !== true && workflow.nodes[nodeName].type === WEBHOOK_NODE_NAME) { + checkWebhook = [nodeName, ...checkWebhook, ...workflow.getChildNodes(nodeName)]; + } + } + + if (checkWebhook.length) { + checkNodes = checkWebhook; + } else { + // If no webhook nodes got found try to find another trigger node + const startNode = workflow.getStartNode(); + if (startNode !== undefined) { + checkNodes = workflow.getChildNodes(startNode.name); + checkNodes.push(startNode.name); + } + } + } + + for (const nodeName of checkNodes) { nodeIssues = null; node = workflow.nodes[nodeName]; @@ -214,6 +243,10 @@ export const workflowHelpers = mixins( return { description: nodeTypeDescription, + // As we do not have the trigger/poll functions available in the frontend + // we use the information available to figure out what are trigger nodes + // @ts-ignore + trigger: ![ERROR_TRIGGER_NODE_NAME, START_NODE_TYPE].includes(nodeType) && nodeTypeDescription.inputs.length === 0 && !nodeTypeDescription.webhooks || undefined, }; }, }; @@ -498,7 +531,7 @@ export const workflowHelpers = mixins( } as IUpdateInformation; this.$store.commit('setNodeValue', changes); }); - + const createdTags = (workflowData.tags || []) as ITag[]; const tagIds = createdTags.map((tag: ITag): string => tag.id); this.$store.commit('setWorkflowTagIds', tagIds); diff --git a/packages/editor-ui/src/components/mixins/workflowRun.ts b/packages/editor-ui/src/components/mixins/workflowRun.ts index ff5b0af998..5196ba573b 100644 --- a/packages/editor-ui/src/components/mixins/workflowRun.ts +++ b/packages/editor-ui/src/components/mixins/workflowRun.ts @@ -55,7 +55,7 @@ export const workflowRun = mixins( return response; }, - async runWorkflow (nodeName: string, source?: string): Promise { + async runWorkflow (nodeName?: string, source?: string): Promise { if (this.$store.getters.isActionActive('workflowRunning') === true) { return; } @@ -70,7 +70,7 @@ export const workflowRun = mixins( const issuesExist = this.$store.getters.nodesIssuesExist; if (issuesExist === true) { // If issues exist get all of the issues of all nodes - const workflowIssues = this.checkReadyForExecution(workflow); + const workflowIssues = this.checkReadyForExecution(workflow, nodeName); if (workflowIssues !== null) { const errorMessages = []; let nodeIssues: string[]; @@ -94,7 +94,10 @@ export const workflowRun = mixins( } // Get the direct parents of the node - const directParentNodes = workflow.getParentNodes(nodeName, 'main', 1); + let directParentNodes: string[] = []; + if (nodeName !== undefined) { + directParentNodes = workflow.getParentNodes(nodeName, 'main', 1); + } const runData = this.$store.getters.getWorkflowRunData; @@ -133,7 +136,7 @@ export const workflowRun = mixins( } } - if (startNodes.length === 0) { + if (startNodes.length === 0 && nodeName !== undefined) { startNodes.push(nodeName); } diff --git a/packages/editor-ui/src/constants.ts b/packages/editor-ui/src/constants.ts index fff33d7822..8bcd3d4d5f 100644 --- a/packages/editor-ui/src/constants.ts +++ b/packages/editor-ui/src/constants.ts @@ -54,6 +54,7 @@ export const ALL_NODE_FILTER = 'All'; export const UNCATEGORIZED_CATEGORY = 'Miscellaneous'; export const UNCATEGORIZED_SUBCATEGORY = 'Helpers'; export const HIDDEN_NODES = ['n8n-nodes-base.start']; +export const ERROR_TRIGGER_NODE_NAME = 'n8n-nodes-base.errorTrigger'; export const WEBHOOK_NODE_NAME = 'n8n-nodes-base.webhook'; export const HTTP_REQUEST_NODE_NAME = 'n8n-nodes-base.httpRequest'; export const REQUEST_NODE_FORM_URL = 'https://n8n-community.typeform.com/to/K1fBVTZ3'; diff --git a/packages/workflow/src/Workflow.ts b/packages/workflow/src/Workflow.ts index 36ed377bb4..d0b2e1efa4 100644 --- a/packages/workflow/src/Workflow.ts +++ b/packages/workflow/src/Workflow.ts @@ -231,13 +231,29 @@ export class Workflow { * @returns {(IWorfklowIssues | null)} * @memberof Workflow */ - checkReadyForExecution(): IWorfklowIssues | null { + checkReadyForExecution(inputData: { + startNode?: string; + destinationNode?: string; + }): IWorfklowIssues | null { let node: INode; let nodeType: INodeType | undefined; let nodeIssues: INodeIssues | null = null; const workflowIssues: IWorfklowIssues = {}; - for (const nodeName of Object.keys(this.nodes)) { + let checkNodes: string[] = []; + if (inputData.destinationNode) { + // If a destination node is given we have to check all the nodes + // leading up to it + checkNodes = this.getParentNodes(inputData.destinationNode); + checkNodes.push(inputData.destinationNode); + } else if (inputData.startNode) { + // If a start node is given we have to check all nodes which + // come after it + checkNodes = this.getChildNodes(inputData.startNode); + checkNodes.push(inputData.startNode); + } + + for (const nodeName of checkNodes) { nodeIssues = null; node = this.nodes[nodeName]; From 7159181345d821b853a01a9b8bd25af334b7c389 Mon Sep 17 00:00:00 2001 From: Mutasem Aldmour <4711238+mutdmour@users.noreply.github.com> Date: Wed, 6 Oct 2021 19:06:33 +0200 Subject: [PATCH 046/290] :zap: Save on new workflow executions with webhook (#2231) * save on new workflow executions * only save if webhook node --- packages/editor-ui/src/components/mixins/workflowRun.ts | 6 ++++++ packages/editor-ui/src/store.ts | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/packages/editor-ui/src/components/mixins/workflowRun.ts b/packages/editor-ui/src/components/mixins/workflowRun.ts index 5196ba573b..c4404552cf 100644 --- a/packages/editor-ui/src/components/mixins/workflowRun.ts +++ b/packages/editor-ui/src/components/mixins/workflowRun.ts @@ -140,6 +140,12 @@ export const workflowRun = mixins( startNodes.push(nodeName); } + const isNewWorkflow = this.$store.getters.isNewWorkflow; + const hasWebhookNode = this.$store.getters.currentWorkflowHasWebhookNode; + if (isNewWorkflow && hasWebhookNode) { + await this.saveCurrentWorkflow(); + } + const workflowData = await this.getWorkflowDataToSave(); const startRunData: IStartRunData = { diff --git a/packages/editor-ui/src/store.ts b/packages/editor-ui/src/store.ts index aa8eeb04af..d337d9dc12 100644 --- a/packages/editor-ui/src/store.ts +++ b/packages/editor-ui/src/store.ts @@ -606,6 +606,10 @@ export const store = new Vuex.Store({ return state.workflow.id === PLACEHOLDER_EMPTY_WORKFLOW_ID; }, + currentWorkflowHasWebhookNode: (state: IRootState): boolean => { + return !!state.workflow.nodes.find((node: INodeUi) => !!node.webhookId); + }, + getActiveExecutions: (state): IExecutionsCurrentSummaryExtended[] => { return state.activeExecutions; }, @@ -790,6 +794,7 @@ export const store = new Vuex.Store({ workflowId: (state): string => { return state.workflow.id; }, + workflowSettings: (state): IWorkflowSettings => { if (state.workflow.settings === undefined) { return {}; From e2daa523d492eb11bc29519989e61168a46981c0 Mon Sep 17 00:00:00 2001 From: Mutasem Aldmour <4711238+mutdmour@users.noreply.github.com> Date: Wed, 6 Oct 2021 19:13:39 +0200 Subject: [PATCH 047/290] :bug: Apply default save manual executions (#2286) * revert savemanualexecution changes * update toasts behavior to handle default case --- packages/editor-ui/src/Interface.ts | 1 + .../editor-ui/src/components/mixins/pushConnection.ts | 6 ++++-- packages/editor-ui/src/store.ts | 8 ++++++-- packages/editor-ui/src/views/NodeView.vue | 1 + 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index 10ecafc912..8181647da4 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -578,6 +578,7 @@ export interface IRootState { pushConnectionActive: boolean; saveDataErrorExecution: string; saveDataSuccessExecution: string; + saveManualExecutions: boolean; timezone: string; stateIsDirty: boolean; executionTimeout: number; diff --git a/packages/editor-ui/src/components/mixins/pushConnection.ts b/packages/editor-ui/src/components/mixins/pushConnection.ts index a67ce0e144..2643bdb90c 100644 --- a/packages/editor-ui/src/components/mixins/pushConnection.ts +++ b/packages/editor-ui/src/components/mixins/pushConnection.ts @@ -219,13 +219,15 @@ export const pushConnection = mixins( const workflow = this.getWorkflow(); if (runDataExecuted.waitTill !== undefined) { const { - isNewWorkflow, activeExecutionId, + workflowSettings, saveManualExecutions, } = this.$store.getters; + const isSavingExecutions= workflowSettings.saveManualExecutions === undefined ? saveManualExecutions : workflowSettings.saveManualExecutions; + let action; - if (isNewWorkflow || !saveManualExecutions) { + if (!isSavingExecutions) { action = 'Turn on saving manual executions and run again to see what happened after this node.'; } else { diff --git a/packages/editor-ui/src/store.ts b/packages/editor-ui/src/store.ts index d337d9dc12..93e67903ef 100644 --- a/packages/editor-ui/src/store.ts +++ b/packages/editor-ui/src/store.ts @@ -56,6 +56,7 @@ const state: IRootState = { pushConnectionActive: true, saveDataErrorExecution: 'all', saveDataSuccessExecution: 'all', + saveManualExecutions: false, timezone: 'America/New_York', stateIsDirty: false, executionTimeout: -1, @@ -496,6 +497,9 @@ export const store = new Vuex.Store({ setSaveDataSuccessExecution (state, newValue: string) { Vue.set(state, 'saveDataSuccessExecution', newValue); }, + setSaveManualExecutions (state, saveManualExecutions: boolean) { + Vue.set(state, 'saveManualExecutions', saveManualExecutions); + }, setTimezone (state, timezone: string) { Vue.set(state, 'timezone', timezone); }, @@ -654,8 +658,8 @@ export const store = new Vuex.Store({ saveDataSuccessExecution: (state): string => { return state.saveDataSuccessExecution; }, - saveManualExecutions: (state, getters): boolean => { - return !!getters.workflowSettings.saveManualExecutions; + saveManualExecutions: (state): boolean => { + return state.saveManualExecutions; }, timezone: (state): string => { return state.timezone; diff --git a/packages/editor-ui/src/views/NodeView.vue b/packages/editor-ui/src/views/NodeView.vue index 23e200fe90..05d32cce2d 100644 --- a/packages/editor-ui/src/views/NodeView.vue +++ b/packages/editor-ui/src/views/NodeView.vue @@ -2197,6 +2197,7 @@ export default mixins( this.$store.commit('setEndpointWebhookTest', settings.endpointWebhookTest); this.$store.commit('setSaveDataErrorExecution', settings.saveDataErrorExecution); this.$store.commit('setSaveDataSuccessExecution', settings.saveDataSuccessExecution); + this.$store.commit('setSaveManualExecutions', settings.saveManualExecutions); this.$store.commit('setTimezone', settings.timezone); this.$store.commit('setExecutionTimeout', settings.executionTimeout); this.$store.commit('setMaxExecutionTimeout', settings.maxExecutionTimeout); From 86942c4ce78f627b6b5e16a628c147a7a583d687 Mon Sep 17 00:00:00 2001 From: Omar Ajoue Date: Wed, 6 Oct 2021 19:53:18 +0200 Subject: [PATCH 048/290] :bug: Fix "Ignore response code" flag in http request node (#2284) --- packages/core/src/NodeExecuteFunctions.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index 6786c10ea5..189a2a7dac 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -303,7 +303,7 @@ async function parseRequestObject(requestObject: IDataObject) { axiosConfig.maxRedirects = 0; } if ( - requestObject.followAllRedirect === false && + requestObject.followAllRedirects === false && ((requestObject.method as string | undefined) || 'get').toLowerCase() !== 'get' ) { axiosConfig.maxRedirects = 0; @@ -420,6 +420,16 @@ async function proxyRequestToAxios( } }) .catch((error) => { + if (configObject.simple === true && error.response) { + resolve({ + body: error.response.data, + headers: error.response.headers, + statusCode: error.response.status, + statusMessage: error.response.statusText, + }); + return; + } + Logger.debug('Request proxied to Axios failed', { error }); // Axios hydrates the original error with more data. We extract them. // https://github.com/axios/axios/blob/master/lib/core/enhanceError.js From a5e714f1c46da9abadfaf9151eb652eb2d0b8ee7 Mon Sep 17 00:00:00 2001 From: Omar Ajoue Date: Wed, 6 Oct 2021 20:05:28 +0200 Subject: [PATCH 049/290] :bug: Fix the way arrays are serialized for gmail and a few other nodes (#2289) --- packages/core/src/NodeExecuteFunctions.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index 189a2a7dac..2ce225badf 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -251,12 +251,23 @@ async function parseRequestObject(requestObject: IDataObject) { axiosConfig.params = requestObject.qs as IDataObject; } - if (requestObject.useQuerystring === true) { + if ( + requestObject.useQuerystring === true || + // @ts-ignore + requestObject.qsStringifyOptions?.arrayFormat === 'repeat' + ) { axiosConfig.paramsSerializer = (params) => { return stringify(params, { arrayFormat: 'repeat' }); }; } + // @ts-ignore + if (requestObject.qsStringifyOptions?.arrayFormat === 'brackets') { + axiosConfig.paramsSerializer = (params) => { + return stringify(params, { arrayFormat: 'brackets' }); + }; + } + if (requestObject.auth !== undefined) { // Check support for sendImmediately if ((requestObject.auth as IDataObject).bearer !== undefined) { From 3fe5a2ddff2c45c540d5e89ef50e5137d27f87d8 Mon Sep 17 00:00:00 2001 From: Omar Ajoue Date: Wed, 6 Oct 2021 20:14:53 +0200 Subject: [PATCH 050/290] :bug: Fix run deck node (#2285) --- .../nodes-base/nodes/Rundeck/Rundeck.node.ts | 4 +++- .../nodes-base/nodes/Rundeck/RundeckApi.ts | 23 ++++++++++--------- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/packages/nodes-base/nodes/Rundeck/Rundeck.node.ts b/packages/nodes-base/nodes/Rundeck/Rundeck.node.ts index 15a75c3c12..c6d68d0287 100644 --- a/packages/nodes-base/nodes/Rundeck/Rundeck.node.ts +++ b/packages/nodes-base/nodes/Rundeck/Rundeck.node.ts @@ -163,9 +163,11 @@ export class Rundeck implements INodeType { const operation = this.getNodeParameter('operation', 0) as string; const resource = this.getNodeParameter('resource', 0) as string; + const rundeckApi = new RundeckApi(this); + await rundeckApi.init(); + for (let i = 0; i < length; i++) { - const rundeckApi = new RundeckApi(this); if (resource === 'job') { if (operation === 'execute') { diff --git a/packages/nodes-base/nodes/Rundeck/RundeckApi.ts b/packages/nodes-base/nodes/Rundeck/RundeckApi.ts index 890d595ccd..d7d43a3d7a 100644 --- a/packages/nodes-base/nodes/Rundeck/RundeckApi.ts +++ b/packages/nodes-base/nodes/Rundeck/RundeckApi.ts @@ -8,20 +8,12 @@ export interface RundeckCredentials { } export class RundeckApi { - private credentials: RundeckCredentials; + private credentials?: RundeckCredentials; private executeFunctions: IExecuteFunctions; constructor(executeFunctions: IExecuteFunctions) { - const credentials = executeFunctions.getCredentials('rundeckApi'); - this.executeFunctions = executeFunctions; - - if (credentials === undefined) { - throw new NodeOperationError(this.executeFunctions.getNode(), 'No credentials got returned!'); - } - - this.credentials = credentials as unknown as RundeckCredentials; } @@ -30,12 +22,12 @@ export class RundeckApi { const options: OptionsWithUri = { headers: { 'user-agent': 'n8n', - 'X-Rundeck-Auth-Token': this.credentials.token, + 'X-Rundeck-Auth-Token': this.credentials?.token, }, rejectUnauthorized: false, method, qs: query, - uri: this.credentials.url + endpoint, + uri: this.credentials?.url + endpoint, body, json: true, }; @@ -47,6 +39,15 @@ export class RundeckApi { } } + async init() { + const credentials = await this.executeFunctions.getCredentials('rundeckApi'); + + if (credentials === undefined) { + throw new NodeOperationError(this.executeFunctions.getNode(), 'No credentials got returned!'); + } + + this.credentials = credentials as unknown as RundeckCredentials; + } executeJob(jobId: string, args: IDataObject[]): Promise { From 1d90cd3b0910a0ea9fe3d85175f2c63d9123c40a Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Thu, 7 Oct 2021 13:53:45 -0500 Subject: [PATCH 051/290] :bug: Fix that active executions could not be canceled in main mode --- packages/core/src/WorkflowExecute.ts | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/packages/core/src/WorkflowExecute.ts b/packages/core/src/WorkflowExecute.ts index 65361586fe..4593f90664 100644 --- a/packages/core/src/WorkflowExecute.ts +++ b/packages/core/src/WorkflowExecute.ts @@ -74,8 +74,11 @@ export class WorkflowExecute { * @returns {(Promise)} * @memberof WorkflowExecute */ - // @ts-ignore - async run(workflow: Workflow, startNode?: INode, destinationNode?: string): PCancelable { + // IMPORTANT: Do not add "async" to this function, it will then convert the + // PCancelable to a regular Promise and does so not allow canceling + // active executions anymore + // eslint-disable-next-line @typescript-eslint/promise-function-async + run(workflow: Workflow, startNode?: INode, destinationNode?: string): PCancelable { // Get the nodes to start workflow execution from startNode = startNode || workflow.getStartNode(destinationNode); @@ -134,8 +137,11 @@ export class WorkflowExecute { * @returns {(Promise)} * @memberof WorkflowExecute */ - // @ts-ignore - async runPartialWorkflow( + // IMPORTANT: Do not add "async" to this function, it will then convert the + // PCancelable to a regular Promise and does so not allow canceling + // active executions anymore + // eslint-disable-next-line @typescript-eslint/promise-function-async + runPartialWorkflow( workflow: Workflow, runData: IRunData, startNodes: string[], @@ -576,8 +582,11 @@ export class WorkflowExecute { * @returns {Promise} * @memberof WorkflowExecute */ - // @ts-ignore - async processRunExecutionData(workflow: Workflow): PCancelable { + // IMPORTANT: Do not add "async" to this function, it will then convert the + // PCancelable to a regular Promise and does so not allow canceling + // active executions anymore + // eslint-disable-next-line @typescript-eslint/promise-function-async + processRunExecutionData(workflow: Workflow): PCancelable { Logger.verbose('Workflow execution started', { workflowId: workflow.id }); const startedAt = new Date(); @@ -1063,8 +1072,7 @@ export class WorkflowExecute { startedAt: Date, workflow: Workflow, executionError?: ExecutionError, - // @ts-ignore - ): PCancelable { + ): Promise { const fullRunData = this.getFullRunData(startedAt); if (executionError !== undefined) { From 857426a918ff1ec9e26c7006abce4244efc8dfe9 Mon Sep 17 00:00:00 2001 From: Mutasem Aldmour <4711238+mutdmour@users.noreply.github.com> Date: Thu, 7 Oct 2021 21:54:22 +0200 Subject: [PATCH 052/290] :zap: Add query param for templates (#2293) --- packages/editor-ui/src/views/NodeView.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/editor-ui/src/views/NodeView.vue b/packages/editor-ui/src/views/NodeView.vue index 05d32cce2d..87b3647510 100644 --- a/packages/editor-ui/src/views/NodeView.vue +++ b/packages/editor-ui/src/views/NodeView.vue @@ -457,7 +457,7 @@ export default mixins( } this.blankRedirect = true; - this.$router.push({ name: 'NodeViewNew' }); + this.$router.push({ name: 'NodeViewNew', query: { templateId } }); await this.addNodes(data.workflow.nodes, data.workflow.connections); await this.$store.dispatch('workflows/setNewWorkflowName', data.name); From 1341958aae0e3f2e9e5cc6e8aee2126c39a27a73 Mon Sep 17 00:00:00 2001 From: Mutasem Aldmour <4711238+mutdmour@users.noreply.github.com> Date: Thu, 7 Oct 2021 21:59:00 +0200 Subject: [PATCH 053/290] :zap: Open new window when duplicating (#2237) * update duplicate to open in new window * remove active action --- .../editor-ui/src/components/DuplicateWorkflowDialog.vue | 2 +- .../editor-ui/src/components/mixins/workflowHelpers.ts | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/editor-ui/src/components/DuplicateWorkflowDialog.vue b/packages/editor-ui/src/components/DuplicateWorkflowDialog.vue index 30f718d25f..07d8e7ba22 100644 --- a/packages/editor-ui/src/components/DuplicateWorkflowDialog.vue +++ b/packages/editor-ui/src/components/DuplicateWorkflowDialog.vue @@ -115,7 +115,7 @@ export default mixins(showMessage, workflowHelpers).extend({ this.$data.isSaving = true; - const saved = await this.saveAsNewWorkflow({name, tags: this.currentTagIds, resetWebhookUrls: true}); + const saved = await this.saveAsNewWorkflow({name, tags: this.currentTagIds, resetWebhookUrls: true, openInNewWindow: true}); if (saved) { this.closeDialog(); diff --git a/packages/editor-ui/src/components/mixins/workflowHelpers.ts b/packages/editor-ui/src/components/mixins/workflowHelpers.ts index 907f5bac02..e670e0e330 100644 --- a/packages/editor-ui/src/components/mixins/workflowHelpers.ts +++ b/packages/editor-ui/src/components/mixins/workflowHelpers.ts @@ -491,7 +491,7 @@ export const workflowHelpers = mixins( } }, - async saveAsNewWorkflow ({name, tags, resetWebhookUrls}: {name?: string, tags?: string[], resetWebhookUrls?: boolean} = {}): Promise { + async saveAsNewWorkflow ({name, tags, resetWebhookUrls, openInNewWindow}: {name?: string, tags?: string[], resetWebhookUrls?: boolean, openInNewWindow?: boolean} = {}): Promise { try { this.$store.commit('addActiveAction', 'workflowSaving'); @@ -517,6 +517,12 @@ export const workflowHelpers = mixins( workflowDataRequest.tags = tags; } const workflowData = await this.restApi().createNewWorkflow(workflowDataRequest); + if (openInNewWindow) { + const routeData = this.$router.resolve({name: 'NodeViewExisting', params: {name: workflowData.id}}); + window.open(routeData.href, '_blank'); + this.$store.commit('removeActiveAction', 'workflowSaving'); + return true; + } this.$store.commit('setActive', workflowData.active || false); this.$store.commit('setWorkflowId', workflowData.id); From d3a312cc6b018e6ba1108eea489bd6ac0186c1a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 7 Oct 2021 22:31:38 +0200 Subject: [PATCH 054/290] :sparkles: Add Stop and Error node (#2232) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Throw Error Node * :hammer: Refactor Throw Error node * :truck: Rename node * :zap: Allow multiple nodes in the workflow * :truck: Rename node * :zap: Fix codex file name Co-authored-by: Günther Erb Co-authored-by: Jan Oberhauser --- .../nodes-base/nodes/StopAndError.node.json | 25 ++++ .../nodes-base/nodes/StopAndError.node.ts | 110 ++++++++++++++++++ packages/nodes-base/package.json | 1 + 3 files changed, 136 insertions(+) create mode 100644 packages/nodes-base/nodes/StopAndError.node.json create mode 100644 packages/nodes-base/nodes/StopAndError.node.ts diff --git a/packages/nodes-base/nodes/StopAndError.node.json b/packages/nodes-base/nodes/StopAndError.node.json new file mode 100644 index 0000000000..e50d829485 --- /dev/null +++ b/packages/nodes-base/nodes/StopAndError.node.json @@ -0,0 +1,25 @@ +{ + "node": "n8n-nodes-base.stopAndError", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": [ + "Core Nodes" + ], + "resources": { + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/nodes/n8n-nodes-base.stopAndError/" + } + ] + }, + "alias": [ + "error", + "throw", + "exception" + ], + "subcategories": { + "Core Nodes": [ + "Flow" + ] + } +} diff --git a/packages/nodes-base/nodes/StopAndError.node.ts b/packages/nodes-base/nodes/StopAndError.node.ts new file mode 100644 index 0000000000..b8d8ffb109 --- /dev/null +++ b/packages/nodes-base/nodes/StopAndError.node.ts @@ -0,0 +1,110 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + INodeExecutionData, + INodeType, + INodeTypeDescription, + NodeOperationError, +} from 'n8n-workflow'; + +const errorObjectPlaceholder = `{ + "code": "404", + "description": "The resource could not be fetched" +}`; + +export class StopAndError implements INodeType { + description: INodeTypeDescription = { + displayName: 'Stop and Error', + name: 'stopAndError', + icon: 'fa:exclamation-triangle', + group: ['input'], + version: 1, + description: 'Throw an error in the workflow', + defaults: { + name: 'Stop And Error', + color: '#ff0000', + }, + inputs: ['main'], + outputs: [], + properties: [ + { + displayName: 'Error Type', + name: 'errorType', + type: 'options', + options: [ + { + name: 'Error Message', + value: 'errorMessage', + }, + { + name: 'Error Object', + value: 'errorObject', + }, + ], + default: 'errorMessage', + description: 'Type of error to throw', + }, + { + displayName: 'Error Message', + name: 'errorMessage', + type: 'string', + placeholder: 'An error occurred!', + typeOptions: { + alwaysOpenEditWindow: true, + }, + default: '', + required: true, + displayOptions: { + show: { + errorType: [ + 'errorMessage', + ], + }, + }, + }, + { + displayName: 'Error Object', + name: 'errorObject', + type: 'json', + description: 'Object containing error properties', + default: '', + typeOptions: { + alwaysOpenEditWindow: true, + }, + placeholder: errorObjectPlaceholder, + required: true, + displayOptions: { + show: { + errorType: [ + 'errorObject', + ], + }, + }, + }, + ], + }; + + execute(this: IExecuteFunctions): Promise { + const errorType = this.getNodeParameter('errorType', 0) as 'errorMessage' | 'errorObject'; + const { id: workflowId, name: workflowName } = this.getWorkflow(); + + let toThrow: string | { name: string; message: string; [otherKey: string]: unknown }; + + if (errorType === 'errorMessage') { + toThrow = this.getNodeParameter('errorMessage', 0) as string; + } else { + const json = this.getNodeParameter('errorObject', 0) as string; + const errorObject = JSON.parse(json); + + toThrow = { + name: 'User-thrown error', + message: `Workflow ID ${workflowId} "${workflowName}" has failed`, + ...errorObject, + }; + } + + throw new NodeOperationError(this.getNode(), toThrow); + } +} diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 341eff0860..9c1352f3fb 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -584,6 +584,7 @@ "dist/nodes/Salesmate/Salesmate.node.js", "dist/nodes/Segment/Segment.node.js", "dist/nodes/Sendy/Sendy.node.js", + "dist/nodes/StopAndError.node.js", "dist/nodes/SurveyMonkey/SurveyMonkeyTrigger.node.js", "dist/nodes/Taiga/Taiga.node.js", "dist/nodes/Taiga/TaigaTrigger.node.js", From fa05d7557b110902ae73572a196c2f228016017c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Thu, 7 Oct 2021 23:07:56 +0200 Subject: [PATCH 055/290] :zap: Simplify more property types in credentials classes (#2211) * :zap: Simplify more property types * :shirt: Fix lint --- .../node-dev/templates/credentials/simple.ts | 8 ++++---- .../ActionNetworkApi.credentials.ts | 6 +++--- .../CiscoWebexOAuth2Api.credentials.ts | 14 +++++++------- .../credentials/FormIoApi.credentials.ts | 5 ++--- .../credentials/FormstackApi.credentials.ts | 6 +++--- .../FormstackOAuth2Api.credentials.ts | 14 +++++++------- .../GoogleDocsOAuth2Api.credentials.ts | 6 +++--- .../GooglePerspectiveOAuth2Api.credentials.ts | 6 +++--- .../HomeAssistantApi.credentials.ts | 12 ++++++------ .../credentials/Magento2Api.credentials.ts | 8 ++++---- .../credentials/MarketstackApi.credentials.ts | 8 ++++---- .../ServiceNowOAuth2Api.credentials.ts | 18 +++++++++--------- 12 files changed, 55 insertions(+), 56 deletions(-) diff --git a/packages/node-dev/templates/credentials/simple.ts b/packages/node-dev/templates/credentials/simple.ts index a1944926f5..b6d9ba3aac 100644 --- a/packages/node-dev/templates/credentials/simple.ts +++ b/packages/node-dev/templates/credentials/simple.ts @@ -1,24 +1,24 @@ -import { ICredentialType, NodePropertyTypes } from 'n8n-workflow'; +import { ICredentialType, NodePropertyTypes, INodeProperties } from 'n8n-workflow'; export class ClassNameReplace implements ICredentialType { name = 'N8nNameReplace'; displayName = 'DisplayNameReplace'; - properties = [ + properties: INodeProperties[] = [ // The credentials to get from user and save encrypted. // Properties can be defined exactly in the same way // as node properties. { displayName: 'User', name: 'user', - type: 'string' as NodePropertyTypes, + type: 'string', default: '', }, { displayName: 'Access Token', name: 'accessToken', - type: 'string' as NodePropertyTypes, + type: 'string', default: '', }, ]; diff --git a/packages/nodes-base/credentials/ActionNetworkApi.credentials.ts b/packages/nodes-base/credentials/ActionNetworkApi.credentials.ts index 91d510c84a..95e889f419 100644 --- a/packages/nodes-base/credentials/ActionNetworkApi.credentials.ts +++ b/packages/nodes-base/credentials/ActionNetworkApi.credentials.ts @@ -1,17 +1,17 @@ import { ICredentialType, - NodePropertyTypes, + INodeProperties, } from 'n8n-workflow'; export class ActionNetworkApi implements ICredentialType { name = 'actionNetworkApi'; displayName = 'Action Network API'; documentationUrl = 'actionNetwork'; - properties = [ + properties: INodeProperties[] = [ { displayName: 'API Key', name: 'apiKey', - type: 'string' as NodePropertyTypes, + type: 'string', default: '', }, ]; diff --git a/packages/nodes-base/credentials/CiscoWebexOAuth2Api.credentials.ts b/packages/nodes-base/credentials/CiscoWebexOAuth2Api.credentials.ts index 8d97262296..c728e626f5 100644 --- a/packages/nodes-base/credentials/CiscoWebexOAuth2Api.credentials.ts +++ b/packages/nodes-base/credentials/CiscoWebexOAuth2Api.credentials.ts @@ -1,6 +1,6 @@ import { ICredentialType, - NodePropertyTypes, + INodeProperties, } from 'n8n-workflow'; export class CiscoWebexOAuth2Api implements ICredentialType { @@ -9,37 +9,37 @@ export class CiscoWebexOAuth2Api implements ICredentialType { 'oAuth2Api', ]; displayName = 'Cisco Webex OAuth2 API'; - properties = [ + properties: INodeProperties[] = [ { displayName: 'Authorization URL', name: 'authUrl', - type: 'hidden' as NodePropertyTypes, + type: 'hidden', default: 'https://webexapis.com/v1/authorize', required: true, }, { displayName: 'Access Token URL', name: 'accessTokenUrl', - type: 'hidden' as NodePropertyTypes, + type: 'hidden', default: 'https://webexapis.com/v1/access_token', required: true, }, { displayName: 'Scope', name: 'scope', - type: 'hidden' as NodePropertyTypes, + type: 'hidden', default: 'spark:memberships_read meeting:recordings_read spark:kms meeting:schedules_read spark:rooms_read spark:messages_write spark:memberships_write meeting:recordings_write meeting:preferences_read spark:messages_read meeting:schedules_write', }, { displayName: 'Auth URI Query Parameters', name: 'authQueryParameters', - type: 'hidden' as NodePropertyTypes, + type: 'hidden', default: '', }, { displayName: 'Authentication', name: 'authentication', - type: 'hidden' as NodePropertyTypes, + type: 'hidden', default: 'body', }, ]; diff --git a/packages/nodes-base/credentials/FormIoApi.credentials.ts b/packages/nodes-base/credentials/FormIoApi.credentials.ts index a6bb39fb97..e7a4f5cad1 100644 --- a/packages/nodes-base/credentials/FormIoApi.credentials.ts +++ b/packages/nodes-base/credentials/FormIoApi.credentials.ts @@ -1,7 +1,6 @@ import { ICredentialType, INodeProperties, - NodePropertyTypes, } from 'n8n-workflow'; export class FormIoApi implements ICredentialType { @@ -42,13 +41,13 @@ export class FormIoApi implements ICredentialType { { displayName: 'Email', name: 'email', - type: 'string' as NodePropertyTypes, + type: 'string', default: '', }, { displayName: 'Password', name: 'password', - type: 'string' as NodePropertyTypes, + type: 'string', typeOptions: { password: true, }, diff --git a/packages/nodes-base/credentials/FormstackApi.credentials.ts b/packages/nodes-base/credentials/FormstackApi.credentials.ts index bcadce0b2e..4c611f86c8 100644 --- a/packages/nodes-base/credentials/FormstackApi.credentials.ts +++ b/packages/nodes-base/credentials/FormstackApi.credentials.ts @@ -1,6 +1,6 @@ import { ICredentialType, - NodePropertyTypes, + INodeProperties, } from 'n8n-workflow'; @@ -8,11 +8,11 @@ export class FormstackApi implements ICredentialType { name = 'formstackApi'; displayName = 'Formstack API'; documentationUrl = 'formstackTrigger'; - properties = [ + properties: INodeProperties[] = [ { displayName: 'Access Token', name: 'accessToken', - type: 'string' as NodePropertyTypes, + type: 'string', default: '', }, ]; diff --git a/packages/nodes-base/credentials/FormstackOAuth2Api.credentials.ts b/packages/nodes-base/credentials/FormstackOAuth2Api.credentials.ts index 79037a980d..fc83e1187e 100644 --- a/packages/nodes-base/credentials/FormstackOAuth2Api.credentials.ts +++ b/packages/nodes-base/credentials/FormstackOAuth2Api.credentials.ts @@ -1,6 +1,6 @@ import { ICredentialType, - NodePropertyTypes, + INodeProperties, } from 'n8n-workflow'; const scopes: string[] = []; @@ -12,37 +12,37 @@ export class FormstackOAuth2Api implements ICredentialType { ]; displayName = 'Formstack OAuth2 API'; documentationUrl = 'formstackTrigger'; - properties = [ + properties: INodeProperties[] = [ { displayName: 'Authorization URL', name: 'authUrl', - type: 'hidden' as NodePropertyTypes, + type: 'hidden', default: 'https://www.formstack.com/api/v2/oauth2/authorize', required: true, }, { displayName: 'Access Token URL', name: 'accessTokenUrl', - type: 'hidden' as NodePropertyTypes, + type: 'hidden', default: 'https://www.formstack.com/api/v2/oauth2/token', required: true, }, { displayName: 'Scope', name: 'scope', - type: 'hidden' as NodePropertyTypes, + type: 'hidden', default: scopes.join(' '), }, { displayName: 'Auth URI Query Parameters', name: 'authQueryParameters', - type: 'hidden' as NodePropertyTypes, + type: 'hidden', default: '', }, { displayName: 'Authentication', name: 'authentication', - type: 'hidden' as NodePropertyTypes, + type: 'hidden', default: 'header', }, ]; diff --git a/packages/nodes-base/credentials/GoogleDocsOAuth2Api.credentials.ts b/packages/nodes-base/credentials/GoogleDocsOAuth2Api.credentials.ts index 08db9a0b2c..dde7233ce5 100644 --- a/packages/nodes-base/credentials/GoogleDocsOAuth2Api.credentials.ts +++ b/packages/nodes-base/credentials/GoogleDocsOAuth2Api.credentials.ts @@ -1,6 +1,6 @@ import { ICredentialType, - NodePropertyTypes, + INodeProperties, } from 'n8n-workflow'; const scopes = [ @@ -16,11 +16,11 @@ export class GoogleDocsOAuth2Api implements ICredentialType { ]; displayName = 'Google Docs OAuth2 API'; documentationUrl = 'google'; - properties = [ + properties: INodeProperties[] = [ { displayName: 'Scope', name: 'scope', - type: 'hidden' as NodePropertyTypes, + type: 'hidden', default: scopes.join(' '), }, ]; diff --git a/packages/nodes-base/credentials/GooglePerspectiveOAuth2Api.credentials.ts b/packages/nodes-base/credentials/GooglePerspectiveOAuth2Api.credentials.ts index b3cd378767..9f344528a1 100644 --- a/packages/nodes-base/credentials/GooglePerspectiveOAuth2Api.credentials.ts +++ b/packages/nodes-base/credentials/GooglePerspectiveOAuth2Api.credentials.ts @@ -1,6 +1,6 @@ import { ICredentialType, - NodePropertyTypes, + INodeProperties, } from 'n8n-workflow'; const scopes = [ @@ -14,11 +14,11 @@ export class GooglePerspectiveOAuth2Api implements ICredentialType { ]; displayName = 'Google Perspective OAuth2 API'; documentationUrl = 'google'; - properties = [ + properties: INodeProperties[] = [ { displayName: 'Scope', name: 'scope', - type: 'hidden' as NodePropertyTypes, + type: 'hidden', default: scopes.join(' '), }, ]; diff --git a/packages/nodes-base/credentials/HomeAssistantApi.credentials.ts b/packages/nodes-base/credentials/HomeAssistantApi.credentials.ts index 802561ba98..7e6825fbcd 100644 --- a/packages/nodes-base/credentials/HomeAssistantApi.credentials.ts +++ b/packages/nodes-base/credentials/HomeAssistantApi.credentials.ts @@ -1,35 +1,35 @@ import { ICredentialType, - NodePropertyTypes, + INodeProperties, } from 'n8n-workflow'; export class HomeAssistantApi implements ICredentialType { name = 'homeAssistantApi'; displayName = 'Home Assistant API'; documentationUrl = 'homeAssistant'; - properties = [ + properties: INodeProperties[] = [ { displayName: 'Host', name: 'host', - type: 'string' as NodePropertyTypes, + type: 'string', default: '', }, { displayName: 'Port', name: 'port', - type: 'number' as NodePropertyTypes, + type: 'number', default: 8123, }, { displayName: 'SSL', name: 'ssl', - type: 'boolean' as NodePropertyTypes, + type: 'boolean', default: false, }, { displayName: 'Access Token', name: 'accessToken', - type: 'string' as NodePropertyTypes, + type: 'string', default: '', }, ]; diff --git a/packages/nodes-base/credentials/Magento2Api.credentials.ts b/packages/nodes-base/credentials/Magento2Api.credentials.ts index 17f42eac3b..487f68abf6 100644 --- a/packages/nodes-base/credentials/Magento2Api.credentials.ts +++ b/packages/nodes-base/credentials/Magento2Api.credentials.ts @@ -1,23 +1,23 @@ import { ICredentialType, - NodePropertyTypes, + INodeProperties, } from 'n8n-workflow'; export class Magento2Api implements ICredentialType { name = 'magento2Api'; displayName = 'Magento 2 API'; documentationUrl = 'magento2'; - properties = [ + properties: INodeProperties[] = [ { displayName: 'Host', name: 'host', - type: 'string' as NodePropertyTypes, + type: 'string', default: '', }, { displayName: 'Access Token', name: 'accessToken', - type: 'string' as NodePropertyTypes, + type: 'string', default: '', }, ]; diff --git a/packages/nodes-base/credentials/MarketstackApi.credentials.ts b/packages/nodes-base/credentials/MarketstackApi.credentials.ts index 5c5a33c832..4960f46a0e 100644 --- a/packages/nodes-base/credentials/MarketstackApi.credentials.ts +++ b/packages/nodes-base/credentials/MarketstackApi.credentials.ts @@ -1,23 +1,23 @@ import { ICredentialType, - NodePropertyTypes, + INodeProperties, } from 'n8n-workflow'; export class MarketstackApi implements ICredentialType { name = 'marketstackApi'; displayName = 'Marketstack API'; documentationUrl = 'marketstack'; - properties = [ + properties: INodeProperties[] = [ { displayName: 'API Key', name: 'apiKey', - type: 'string' as NodePropertyTypes, + type: 'string', default: '', }, { displayName: 'Use HTTPS', name: 'useHttps', - type: 'boolean' as NodePropertyTypes, + type: 'boolean', default: false, description: 'Use HTTPS (paid plans only).', }, diff --git a/packages/nodes-base/credentials/ServiceNowOAuth2Api.credentials.ts b/packages/nodes-base/credentials/ServiceNowOAuth2Api.credentials.ts index d12b0cd8f1..9539cb1fe1 100644 --- a/packages/nodes-base/credentials/ServiceNowOAuth2Api.credentials.ts +++ b/packages/nodes-base/credentials/ServiceNowOAuth2Api.credentials.ts @@ -1,6 +1,6 @@ import { ICredentialType, - NodePropertyTypes, + INodeProperties, } from 'n8n-workflow'; export class ServiceNowOAuth2Api implements ICredentialType { @@ -10,11 +10,11 @@ export class ServiceNowOAuth2Api implements ICredentialType { ]; displayName = 'ServiceNow OAuth2 API'; documentationUrl = 'serviceNow'; - properties = [ + properties: INodeProperties[] = [ { displayName: 'Subdomain', name: 'subdomain', - type: 'string' as NodePropertyTypes, + type: 'string', default: '', placeholder: 'n8n', description: 'The subdomain of your ServiceNow environment', @@ -23,39 +23,39 @@ export class ServiceNowOAuth2Api implements ICredentialType { { displayName: 'Authorization URL', name: 'authUrl', - type: 'hidden' as NodePropertyTypes, + type: 'hidden', default: '=https://{{$self["subdomain"]}}.service-now.com/oauth_auth.do', required: true, }, { displayName: 'Access Token URL', name: 'accessTokenUrl', - type: 'hidden' as NodePropertyTypes, + type: 'hidden', default: '=https://{{$self["subdomain"]}}.service-now.com/oauth_token.do', required: true, }, { displayName: 'Scope', name: 'scope', - type: 'hidden' as NodePropertyTypes, + type: 'hidden', default: 'useraccount', }, { displayName: 'Auth URI Query Parameters', name: 'authQueryParameters', - type: 'hidden' as NodePropertyTypes, + type: 'hidden', default: 'response_type=code', }, { displayName: 'Auth URI Query Parameters', name: 'authQueryParameters', - type: 'hidden' as NodePropertyTypes, + type: 'hidden', default: 'grant_type=authorization_code', }, { displayName: 'Authentication', name: 'authentication', - type: 'hidden' as NodePropertyTypes, + type: 'hidden', default: 'header', }, ]; From 3a497306750fb1b36f86b28e903e6ce8f1986b0d Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Thu, 7 Oct 2021 17:12:06 -0500 Subject: [PATCH 056/290] :zap: Simplify code --- .../nodes-base/nodes/SeaTable/GenericFunctions.ts | 15 +++++++++------ packages/nodes-base/nodes/SeaTable/Interfaces.ts | 2 +- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/nodes-base/nodes/SeaTable/GenericFunctions.ts b/packages/nodes-base/nodes/SeaTable/GenericFunctions.ts index 4f39075bbd..70e139b983 100644 --- a/packages/nodes-base/nodes/SeaTable/GenericFunctions.ts +++ b/packages/nodes-base/nodes/SeaTable/GenericFunctions.ts @@ -293,11 +293,14 @@ export const split = (subject: string): string[] => .map(s => s.replace(/\\([\s\S])/gm, ($0, $1) => $1)) ; -const userBaseUri = (str?: string) => { - if (str === undefined) { - return str; +const userBaseUri = (uri?: string) => { + if (uri === undefined) { + return uri; } - let end = str.length; - for (; end > 0 && str[end - 1] === '/'; --end) {} - return end < str.length ? str.substring(0, end) : str; + + if (uri.endsWith('/')) { + return uri.slice(0, -1); + } + + return uri; }; diff --git a/packages/nodes-base/nodes/SeaTable/Interfaces.ts b/packages/nodes-base/nodes/SeaTable/Interfaces.ts index 4d8269edb2..d950d98be8 100644 --- a/packages/nodes-base/nodes/SeaTable/Interfaces.ts +++ b/packages/nodes-base/nodes/SeaTable/Interfaces.ts @@ -95,7 +95,7 @@ export interface IRowResponse{ metadata: [ { key: string, - name: string + name: string, } ]; results: IRow[]; From c12c24e5a2eb8a604ef29f922fc7f69cc87064c0 Mon Sep 17 00:00:00 2001 From: Harshil Agrawal Date: Fri, 8 Oct 2021 00:15:10 +0200 Subject: [PATCH 057/290] :zap: Add and update codex files (#2273) --- .../ElasticSecurity/ElasticSecurity.node.json | 20 +++++++ .../nodes-base/nodes/Grist/Grist.node.json | 2 +- packages/nodes-base/nodes/Misp/Misp.node.json | 20 +++++++ .../nodes/Netlify/Netlify.node.json | 20 +++++++ .../nodes/Netlify/NetlifyTrigger.node.json | 20 +++++++ .../nodes/SeaTable/SeaTable.node.json | 54 +------------------ .../nodes/SeaTable/SeaTableTrigger.node.json | 2 +- .../nodes-base/nodes/Splunk/Splunk.node.json | 20 +++++++ .../nodes/UrlScanIo/UrlScanIo.node.json | 24 +++++++++ 9 files changed, 127 insertions(+), 55 deletions(-) create mode 100644 packages/nodes-base/nodes/Elastic/ElasticSecurity/ElasticSecurity.node.json create mode 100644 packages/nodes-base/nodes/Misp/Misp.node.json create mode 100644 packages/nodes-base/nodes/Netlify/Netlify.node.json create mode 100644 packages/nodes-base/nodes/Netlify/NetlifyTrigger.node.json create mode 100644 packages/nodes-base/nodes/Splunk/Splunk.node.json create mode 100644 packages/nodes-base/nodes/UrlScanIo/UrlScanIo.node.json diff --git a/packages/nodes-base/nodes/Elastic/ElasticSecurity/ElasticSecurity.node.json b/packages/nodes-base/nodes/Elastic/ElasticSecurity/ElasticSecurity.node.json new file mode 100644 index 0000000000..7c03a486be --- /dev/null +++ b/packages/nodes-base/nodes/Elastic/ElasticSecurity/ElasticSecurity.node.json @@ -0,0 +1,20 @@ +{ + "node": "n8n-nodes-base.elasticSecurity", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": [ + "Development" + ], + "resources": { + "credentialDocumentation": [ + { + "url": "https://docs.n8n.io/credentials/elasticSecurity" + } + ], + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/nodes/n8n-nodes-base.elasticSecurity/" + } + ] + } +} \ No newline at end of file diff --git a/packages/nodes-base/nodes/Grist/Grist.node.json b/packages/nodes-base/nodes/Grist/Grist.node.json index 4771346cb9..ec29bd838f 100644 --- a/packages/nodes-base/nodes/Grist/Grist.node.json +++ b/packages/nodes-base/nodes/Grist/Grist.node.json @@ -17,4 +17,4 @@ } ] } -} +} \ No newline at end of file diff --git a/packages/nodes-base/nodes/Misp/Misp.node.json b/packages/nodes-base/nodes/Misp/Misp.node.json new file mode 100644 index 0000000000..4d71050369 --- /dev/null +++ b/packages/nodes-base/nodes/Misp/Misp.node.json @@ -0,0 +1,20 @@ +{ + "node": "n8n-nodes-base.misp", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": [ + "Development" + ], + "resources": { + "credentialDocumentation": [ + { + "url": "https://docs.n8n.io/credentials/misp" + } + ], + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/nodes/n8n-nodes-base.misp/" + } + ] + } +} \ No newline at end of file diff --git a/packages/nodes-base/nodes/Netlify/Netlify.node.json b/packages/nodes-base/nodes/Netlify/Netlify.node.json new file mode 100644 index 0000000000..65c102a720 --- /dev/null +++ b/packages/nodes-base/nodes/Netlify/Netlify.node.json @@ -0,0 +1,20 @@ +{ + "node": "n8n-nodes-base.netlify", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": [ + "Development" + ], + "resources": { + "credentialDocumentation": [ + { + "url": "https://docs.n8n.io/credentials/netlify" + } + ], + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/nodes/n8n-nodes-base.netlify/" + } + ] + } +} \ No newline at end of file diff --git a/packages/nodes-base/nodes/Netlify/NetlifyTrigger.node.json b/packages/nodes-base/nodes/Netlify/NetlifyTrigger.node.json new file mode 100644 index 0000000000..a5f88c0297 --- /dev/null +++ b/packages/nodes-base/nodes/Netlify/NetlifyTrigger.node.json @@ -0,0 +1,20 @@ +{ + "node": "n8n-nodes-base.netlifyTrigger", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": [ + "Development" + ], + "resources": { + "credentialDocumentation": [ + { + "url": "https://docs.n8n.io/credentials/netlify" + } + ], + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/nodes/n8n-nodes-base.netlifyTrigger/" + } + ] + } +} \ No newline at end of file diff --git a/packages/nodes-base/nodes/SeaTable/SeaTable.node.json b/packages/nodes-base/nodes/SeaTable/SeaTable.node.json index 75e6a5d681..b7e9b1d12f 100644 --- a/packages/nodes-base/nodes/SeaTable/SeaTable.node.json +++ b/packages/nodes-base/nodes/SeaTable/SeaTable.node.json @@ -15,58 +15,6 @@ { "url": "https://docs.n8n.io/nodes/n8n-nodes-base.seaTable/" } - ], - "generic": [ - { - "label": "2021 Goals: Level Up Your Vocabulary With Vonage and n8n", - "icon": "🎯", - "url": "https://n8n.io/blog/2021-goals-level-up-your-vocabulary-with-vonage-and-n8n/" - }, - { - "label": "2021: The Year to Automate the New You with n8n", - "icon": "☀️", - "url": "https://n8n.io/blog/2021-the-year-to-automate-the-new-you-with-n8n/" - }, - { - "label": "15 Google apps you can combine and automate to increase productivity", - "icon": "💡", - "url": "https://n8n.io/blog/automate-google-apps-for-productivity/" - }, - { - "label": "Building an expense tracking app in 10 minutes", - "icon": "📱", - "url": "https://n8n.io/blog/building-an-expense-tracking-app-in-10-minutes/" - }, - { - "label": "Why this Product Manager loves workflow automation with n8n", - "icon": "🧠", - "url": "https://n8n.io/blog/why-this-product-manager-loves-workflow-automation-with-n8n/" - }, - { - "label": "Learn to Build Powerful API Endpoints Using Webhooks", - "icon": "🧰", - "url": "https://n8n.io/blog/learn-to-build-powerful-api-endpoints-using-webhooks/" - }, - { - "label": "Sending SMS the Low-Code Way with SeaTable, Twilio Programmable SMS, and n8n", - "icon": "📱", - "url": "https://n8n.io/blog/sending-sms-the-low-code-way-with-seatable-twilio-programmable-sms-and-n8n/" - }, - { - "label": "Automating Conference Organization Processes with n8n", - "icon": "🙋‍♀️", - "url": "https://n8n.io/blog/automating-conference-organization-processes-with-n8n/" - }, - { - "label": "Benefits of automation and n8n: An interview with HubSpot's Hugh Durkin", - "icon": "🎖", - "url": "https://n8n.io/blog/benefits-of-automation-and-n8n-an-interview-with-hubspots-hugh-durkin/" - }, - { - "label": "How Goomer automated their operations with over 200 n8n workflows", - "icon": "🛵", - "url": "https://n8n.io/blog/how-goomer-automated-their-operations-with-over-200-n8n-workflows/" - } ] } -} +} \ No newline at end of file diff --git a/packages/nodes-base/nodes/SeaTable/SeaTableTrigger.node.json b/packages/nodes-base/nodes/SeaTable/SeaTableTrigger.node.json index 9799673776..bfdc519b62 100644 --- a/packages/nodes-base/nodes/SeaTable/SeaTableTrigger.node.json +++ b/packages/nodes-base/nodes/SeaTable/SeaTableTrigger.node.json @@ -17,4 +17,4 @@ } ] } -} +} \ No newline at end of file diff --git a/packages/nodes-base/nodes/Splunk/Splunk.node.json b/packages/nodes-base/nodes/Splunk/Splunk.node.json new file mode 100644 index 0000000000..e23f0d6c5d --- /dev/null +++ b/packages/nodes-base/nodes/Splunk/Splunk.node.json @@ -0,0 +1,20 @@ +{ + "node": "n8n-nodes-base.splunk", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": [ + "Development" + ], + "resources": { + "credentialDocumentation": [ + { + "url": "https://docs.n8n.io/credentials/splunk" + } + ], + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/nodes/n8n-nodes-base.splunk/" + } + ] + } +} \ No newline at end of file diff --git a/packages/nodes-base/nodes/UrlScanIo/UrlScanIo.node.json b/packages/nodes-base/nodes/UrlScanIo/UrlScanIo.node.json new file mode 100644 index 0000000000..96fcac2242 --- /dev/null +++ b/packages/nodes-base/nodes/UrlScanIo/UrlScanIo.node.json @@ -0,0 +1,24 @@ +{ + "node": "n8n-nodes-base.urlScanIo", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": [ + "Development", + "Utility" + ], + "resources": { + "credentialDocumentation": [ + { + "url": "https://docs.n8n.io/credentials/urlScanIo" + } + ], + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/nodes/n8n-nodes-base.urlScanIo/" + } + ] + }, + "alias": [ + "Scrape" + ] +} \ No newline at end of file From 59064c0e7e0f6c68960f30c65bc4cc4f6c0e72ff Mon Sep 17 00:00:00 2001 From: Snyk bot Date: Fri, 8 Oct 2021 00:54:45 +0200 Subject: [PATCH 058/290] :arrow_up: Set node-ssh@12.0.0 and ssh2-sftp-client@7.0.0 on n8n-nodes-base The following vulnerabilities are fixed with an upgrade: - https://snyk.io/vuln/SNYK-JS-SSH2-1656673 Co-authored-by: Jan --- packages/nodes-base/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 9c1352f3fb..9c3d2632a6 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -702,8 +702,8 @@ "mqtt": "4.2.6", "mssql": "^6.2.0", "mysql2": "~2.3.0", + "node-ssh": "^12.0.0", "n8n-core": "~0.86.0", - "node-ssh": "^11.0.0", "nodemailer": "^6.5.0", "pdf-parse": "^1.1.1", "pg": "^8.3.0", @@ -715,7 +715,7 @@ "rss-parser": "^3.7.0", "simple-git": "^2.36.2", "snowflake-sdk": "^1.5.3", - "ssh2-sftp-client": "^5.2.1", + "ssh2-sftp-client": "^7.0.0", "tmp-promise": "^3.0.2", "uuid": "^8.3.0", "vm2": "^3.6.10", From e4dcf42f4c9d6a2fca9c7847dec3d3c57c52c91c Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Thu, 7 Oct 2021 23:06:17 +0000 Subject: [PATCH 059/290] =?UTF-8?q?:bookmark:=20Release=C2=A0n8n-workflow@?= =?UTF-8?q?0.71.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/workflow/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/workflow/package.json b/packages/workflow/package.json index 09638b42f7..00cee4f017 100644 --- a/packages/workflow/package.json +++ b/packages/workflow/package.json @@ -1,6 +1,6 @@ { "name": "n8n-workflow", - "version": "0.70.0", + "version": "0.71.0", "description": "Workflow base code of n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", From 097e73dc607ee83fe7ea9a548609db6cd5faffe3 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Thu, 7 Oct 2021 23:06:31 +0000 Subject: [PATCH 060/290] :arrow_up: Set n8n-workflow@0.71.0 on n8n-core --- packages/core/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/package.json b/packages/core/package.json index 5cb4e2e0aa..796b29179d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -50,7 +50,7 @@ "form-data": "^4.0.0", "lodash.get": "^4.4.2", "mime-types": "^2.1.27", - "n8n-workflow": "~0.70.0", + "n8n-workflow": "~0.71.0", "oauth-1.0a": "^2.2.6", "p-cancelable": "^2.0.0", "qs": "^6.10.1", From bc44294383d5a20a55fd7773babe2e8a80048df8 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Thu, 7 Oct 2021 23:06:31 +0000 Subject: [PATCH 061/290] =?UTF-8?q?:bookmark:=20Release=C2=A0n8n-core@0.87?= =?UTF-8?q?.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/core/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/package.json b/packages/core/package.json index 796b29179d..354d171ffb 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "n8n-core", - "version": "0.86.1", + "version": "0.87.0", "description": "Core functionality of n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", From 29e3c9b146e28385bb5c0144504b7f32bf221f3e Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Thu, 7 Oct 2021 23:06:38 +0000 Subject: [PATCH 062/290] :arrow_up: Set n8n-core@0.87.0 and n8n-workflow@0.71.0 on n8n-node-dev --- packages/node-dev/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/node-dev/package.json b/packages/node-dev/package.json index aefe1457eb..fdb210391e 100644 --- a/packages/node-dev/package.json +++ b/packages/node-dev/package.json @@ -60,8 +60,8 @@ "change-case": "^4.1.1", "copyfiles": "^2.1.1", "inquirer": "^7.0.1", - "n8n-core": "~0.86.0", - "n8n-workflow": "~0.70.0", + "n8n-core": "~0.87.0", + "n8n-workflow": "~0.71.0", "oauth-1.0a": "^2.2.6", "replace-in-file": "^6.0.0", "request": "^2.88.2", From abbd0fdcb4666fbdb17a7c9070f3270bf606aa2f Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Thu, 7 Oct 2021 23:06:38 +0000 Subject: [PATCH 063/290] =?UTF-8?q?:bookmark:=20Release=C2=A0n8n-node-dev@?= =?UTF-8?q?0.27.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/node-dev/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/node-dev/package.json b/packages/node-dev/package.json index fdb210391e..35a64f7ff9 100644 --- a/packages/node-dev/package.json +++ b/packages/node-dev/package.json @@ -1,6 +1,6 @@ { "name": "n8n-node-dev", - "version": "0.26.0", + "version": "0.27.0", "description": "CLI to simplify n8n credentials/node development", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", From 96d4d97d4c6511f7a9c96d94b9c9ce9085fa0032 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Thu, 7 Oct 2021 23:06:50 +0000 Subject: [PATCH 064/290] :arrow_up: Set n8n-core@0.87.0 and n8n-workflow@0.71.0 on n8n-nodes-base --- packages/nodes-base/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 9c3d2632a6..247e3a2e5f 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -663,7 +663,7 @@ "@types/xml2js": "^0.4.3", "gulp": "^4.0.0", "jest": "^26.4.2", - "n8n-workflow": "~0.70.0", + "n8n-workflow": "~0.71.0", "nodelinter": "^0.1.9", "ts-jest": "^26.3.0", "tslint": "^6.1.2", @@ -703,7 +703,7 @@ "mssql": "^6.2.0", "mysql2": "~2.3.0", "node-ssh": "^12.0.0", - "n8n-core": "~0.86.0", + "n8n-core": "~0.87.0", "nodemailer": "^6.5.0", "pdf-parse": "^1.1.1", "pg": "^8.3.0", From 270262922ddada41aa2ab23bd0b2d90c2c5f8245 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Thu, 7 Oct 2021 23:06:50 +0000 Subject: [PATCH 065/290] =?UTF-8?q?:bookmark:=20Release=C2=A0n8n-nodes-bas?= =?UTF-8?q?e@0.139.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/nodes-base/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 247e3a2e5f..8f23fdf7ac 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -1,6 +1,6 @@ { "name": "n8n-nodes-base", - "version": "0.138.0", + "version": "0.139.0", "description": "Base nodes of n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", From b8a01daf08f65941e4a5fb7ddc8a64ce07e76dd5 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Thu, 7 Oct 2021 23:07:34 +0000 Subject: [PATCH 066/290] :arrow_up: Set n8n-workflow@0.71.0 on n8n-editor-ui --- packages/editor-ui/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index 36918156b2..5566fbc8ee 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -71,7 +71,7 @@ "lodash.debounce": "^4.0.8", "lodash.get": "^4.4.2", "lodash.set": "^4.3.2", - "n8n-workflow": "~0.70.0", + "n8n-workflow": "~0.71.0", "sass": "^1.26.5", "normalize-wheel": "^1.0.1", "prismjs": "^1.17.1", From ef06c8704e31a13109b0882832a2ebe38f934518 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Thu, 7 Oct 2021 23:07:34 +0000 Subject: [PATCH 067/290] =?UTF-8?q?:bookmark:=20Release=C2=A0n8n-editor-ui?= =?UTF-8?q?@0.110.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/editor-ui/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index 5566fbc8ee..8b8e232178 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -1,6 +1,6 @@ { "name": "n8n-editor-ui", - "version": "0.109.0", + "version": "0.110.0", "description": "Workflow Editor UI for n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", From 1ef6bef7ab0362662ebc2c1f56aad6af3ac70bde Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Thu, 7 Oct 2021 23:08:08 +0000 Subject: [PATCH 068/290] :arrow_up: Set n8n-core@0.87.0, n8n-editor-ui@0.110.0, n8n-nodes-base@0.139.0 and n8n-workflow@0.71.0 on n8n --- packages/cli/package.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index c041a62c09..0c09cc6f57 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -109,10 +109,10 @@ "localtunnel": "^2.0.0", "lodash.get": "^4.4.2", "mysql2": "~2.3.0", - "n8n-core": "~0.86.1", - "n8n-editor-ui": "~0.109.0", - "n8n-nodes-base": "~0.138.0", - "n8n-workflow": "~0.70.0", + "n8n-core": "~0.87.0", + "n8n-editor-ui": "~0.110.0", + "n8n-nodes-base": "~0.139.0", + "n8n-workflow": "~0.71.0", "oauth-1.0a": "^2.2.6", "open": "^7.0.0", "pg": "^8.3.0", From 981d2dc313e140c96720e9e7e2114704694430bf Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Thu, 7 Oct 2021 23:08:08 +0000 Subject: [PATCH 069/290] =?UTF-8?q?:bookmark:=20Release=C2=A0n8n@0.142.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 0c09cc6f57..60155aabd8 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "0.141.1", + "version": "0.142.0", "description": "n8n Workflow Automation Tool", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", From d72d6b4b414ff31b59ff221363235a1dd22328ee Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Sat, 9 Oct 2021 11:27:58 -0500 Subject: [PATCH 070/290] :sparkles: Add support for Facebook Graph API versions 11 and 12 #2279 --- .../nodes-base/nodes/Facebook/FacebookGraphApi.node.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/nodes-base/nodes/Facebook/FacebookGraphApi.node.ts b/packages/nodes-base/nodes/Facebook/FacebookGraphApi.node.ts index d8109e7f5a..0a8018c839 100644 --- a/packages/nodes-base/nodes/Facebook/FacebookGraphApi.node.ts +++ b/packages/nodes-base/nodes/Facebook/FacebookGraphApi.node.ts @@ -86,6 +86,14 @@ export class FacebookGraphApi implements INodeType { name: 'Default', value: '', }, + { + name: 'v12.0', + value: 'v12.0', + }, + { + name: 'v11.0', + value: 'v11.0', + }, { name: 'v10.0', value: 'v10.0', From d1824b9dd0714f78f35fd4fdc3a741a98e5927bf Mon Sep 17 00:00:00 2001 From: Mutasem Aldmour <4711238+mutdmour@users.noreply.github.com> Date: Sat, 9 Oct 2021 20:42:30 +0200 Subject: [PATCH 071/290] :bug: Fix error when opening ftp/sftp credentials (#2298) * n8n-2513 fix error when opening credentials * clean up validation logic --- .../CredentialEdit/CredentialEdit.vue | 22 ++++++++++++------- .../src/components/ParameterInputExpanded.vue | 16 +++++++++++++- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/packages/editor-ui/src/components/CredentialEdit/CredentialEdit.vue b/packages/editor-ui/src/components/CredentialEdit/CredentialEdit.vue index 4fba543573..9532ad4073 100644 --- a/packages/editor-ui/src/components/CredentialEdit/CredentialEdit.vue +++ b/packages/editor-ui/src/components/CredentialEdit/CredentialEdit.vue @@ -208,14 +208,16 @@ export default mixins(showMessage, nodeHelpers).extend({ activeNode: this.$store.getters.activeNode, }); - if (this.credentialId) { - if (!this.requiredPropertiesFilled) { - this.showValidationWarning = true; + setTimeout(() => { + if (this.credentialId) { + if (!this.requiredPropertiesFilled) { + this.showValidationWarning = true; + } + else { + this.retestCredential(); + } } - else { - this.retestCredential(); - } - } + }, 0); this.loading = false; }, @@ -329,7 +331,11 @@ export default mixins(showMessage, nodeHelpers).extend({ continue; } - if (!this.credentialData[property.name]) { + if (property.type === 'string' && !this.credentialData[property.name]) { + return false; + } + + if (property.type === 'number' && typeof this.credentialData[property.name] !== 'number') { return false; } } diff --git a/packages/editor-ui/src/components/ParameterInputExpanded.vue b/packages/editor-ui/src/components/ParameterInputExpanded.vue index d12788abc4..9aa0e5ba43 100644 --- a/packages/editor-ui/src/components/ParameterInputExpanded.vue +++ b/packages/editor-ui/src/components/ParameterInputExpanded.vue @@ -53,7 +53,21 @@ export default Vue.extend({ }, computed: { showRequiredErrors(): boolean { - return this.$props.parameter.type !== 'boolean' && !this.value && this.$props.parameter.required && (this.blurred || this.showValidationWarnings); + if (!this.$props.parameter.required) { + return false; + } + + if (this.blurred || this.showValidationWarnings) { + if (this.$props.parameter.type === 'string') { + return !this.value; + } + + if (this.$props.parameter.type === 'number') { + return typeof this.value !== 'number'; + } + } + + return false; }, }, methods: { From 9e2298eb73046dce25dbef6c7fecd1dd1629c250 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Sat, 9 Oct 2021 14:20:04 -0500 Subject: [PATCH 072/290] :zap: Use shared sort function --- .../nodes/Pipedrive/GenericFunctions.ts | 14 +++ .../nodes/Pipedrive/Pipedrive.node.ts | 115 +++--------------- 2 files changed, 31 insertions(+), 98 deletions(-) diff --git a/packages/nodes-base/nodes/Pipedrive/GenericFunctions.ts b/packages/nodes-base/nodes/Pipedrive/GenericFunctions.ts index b9efb550e3..89b6338bbb 100644 --- a/packages/nodes-base/nodes/Pipedrive/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Pipedrive/GenericFunctions.ts @@ -6,6 +6,7 @@ import { import { IDataObject, ILoadOptionsFunctions, + INodePropertyOptions, NodeApiError, NodeOperationError, } from 'n8n-workflow'; @@ -261,3 +262,16 @@ export function pipedriveResolveCustomProperties(customProperties: ICustomProper } } + + +export function sortOptionParameters(optionParameters: INodePropertyOptions[]): INodePropertyOptions[] { + optionParameters.sort((a, b) => { + const aName = a.name.toLowerCase(); + const bName = b.name.toLowerCase(); + if (aName < bName) { return -1; } + if (aName > bName) { return 1; } + return 0; + }); + + return optionParameters; +} diff --git a/packages/nodes-base/nodes/Pipedrive/Pipedrive.node.ts b/packages/nodes-base/nodes/Pipedrive/Pipedrive.node.ts index 1ada18b0c3..f3d2345236 100644 --- a/packages/nodes-base/nodes/Pipedrive/Pipedrive.node.ts +++ b/packages/nodes-base/nodes/Pipedrive/Pipedrive.node.ts @@ -19,6 +19,7 @@ import { pipedriveEncodeCustomProperties, pipedriveGetCustomProperties, pipedriveResolveCustomProperties, + sortOptionParameters, } from './GenericFunctions'; import { @@ -3902,15 +3903,7 @@ export class Pipedrive implements INodeType { }); } - returnData.sort((a, b) => { - const aName = a.name.toLowerCase(); - const bName = b.name.toLowerCase(); - if (aName < bName) { return -1; } - if (aName > bName) { return 1; } - return 0; - }); - - return returnData; + return sortOptionParameters(returnData); }, // Get all Filters to display them to user so that he can // select them easily @@ -3931,15 +3924,7 @@ export class Pipedrive implements INodeType { }); } - returnData.sort((a, b) => { - const aName = a.name.toLowerCase(); - const bName = b.name.toLowerCase(); - if (aName < bName) { return -1; } - if (aName > bName) { return 1; } - return 0; - }); - - return returnData; + return sortOptionParameters(returnData); }, // Get all Organizations to display them to user so that he can // select them easily @@ -3953,15 +3938,7 @@ export class Pipedrive implements INodeType { }); } - returnData.sort((a, b) => { - const aName = a.name.toLowerCase(); - const bName = b.name.toLowerCase(); - if (aName < bName) { return -1; } - if (aName > bName) { return 1; } - return 0; - }); - - return returnData; + return sortOptionParameters(returnData); }, // Get all Organizations to display them to user so that he can // select them easily @@ -3977,15 +3954,7 @@ export class Pipedrive implements INodeType { } } - returnData.sort((a, b) => { - const aName = a.name.toLowerCase(); - const bName = b.name.toLowerCase(); - if (aName < bName) { return -1; } - if (aName > bName) { return 1; } - return 0; - }); - - return returnData; + return sortOptionParameters(returnData); }, // Get all Deals to display them to user so that he can // select them easily @@ -3993,7 +3962,7 @@ export class Pipedrive implements INodeType { const { data } = await pipedriveApiRequest.call(this, 'GET', '/deals', {}) as { data: Array<{ id: string; title: string; }> }; - return data.map(({ id, title }) => ({ value: id, name: title })); + return sortOptionParameters(data.map(({ id, title }) => ({ value: id, name: title }))); }, // Get all Products to display them to user so that he can // select them easily @@ -4001,7 +3970,7 @@ export class Pipedrive implements INodeType { const { data } = await pipedriveApiRequest.call(this, 'GET', '/products', {}) as { data: Array<{ id: string; name: string; }> }; - return data.map(({ id, name }) => ({ value: id, name })); + return sortOptionParameters(data.map(({ id, name }) => ({ value: id, name }))); }, // Get all Products related to a deal and display them to user so that he can // select them easily @@ -4011,7 +3980,7 @@ export class Pipedrive implements INodeType { const { data } = await pipedriveApiRequest.call(this, 'GET', `/deals/${dealId}/products`, {}) as { data: Array<{ id: string; name: string; }> }; - return data.map(({ id, name }) => ({ value: id, name })); + return sortOptionParameters(data.map(({ id, name }) => ({ value: id, name }))); }, // Get all Stages to display them to user so that he can // select them easily @@ -4025,15 +3994,7 @@ export class Pipedrive implements INodeType { }); } - returnData.sort((a, b) => { - const aName = a.name.toLowerCase(); - const bName = b.name.toLowerCase(); - if (aName < bName) { return -1; } - if (aName > bName) { return 1; } - return 0; - }); - - return returnData; + return sortOptionParameters(returnData); }, // Get all the Organization Custom Fields to display them to user so that he can // select them easily @@ -4049,15 +4010,7 @@ export class Pipedrive implements INodeType { } } - returnData.sort((a, b) => { - const aName = a.name.toLowerCase(); - const bName = b.name.toLowerCase(); - if (aName < bName) { return -1; } - if (aName > bName) { return 1; } - return 0; - }); - - return returnData; + return sortOptionParameters(returnData); }, // Get all the Deal Custom Fields to display them to user so that he can // select them easily @@ -4073,15 +4026,7 @@ export class Pipedrive implements INodeType { } } - returnData.sort((a, b) => { - const aName = a.name.toLowerCase(); - const bName = b.name.toLowerCase(); - if (aName < bName) { return -1; } - if (aName > bName) { return 1; } - return 0; - }); - - return returnData; + return sortOptionParameters(returnData); }, // Get all the Person Custom Fields to display them to user so that he can // select them easily @@ -4097,15 +4042,7 @@ export class Pipedrive implements INodeType { } } - returnData.sort((a, b) => { - const aName = a.name.toLowerCase(); - const bName = b.name.toLowerCase(); - if (aName < bName) { return -1; } - if (aName > bName) { return 1; } - return 0; - }); - - return returnData; + return sortOptionParameters(returnData); }, // Get all the person labels to display them to user so that he can // select them easily @@ -4126,13 +4063,7 @@ export class Pipedrive implements INodeType { } } - returnData.sort((a, b) => { - const aName = a.name.toLowerCase(); - const bName = b.name.toLowerCase(); - if (aName < bName) { return -1; } - if (aName > bName) { return 1; } - return 0; - }); + sortOptionParameters(returnData); if (operation === 'update') { returnData.push({ @@ -4161,13 +4092,7 @@ export class Pipedrive implements INodeType { } } - returnData.sort((a, b) => { - const aName = a.name.toLowerCase(); - const bName = b.name.toLowerCase(); - if (aName < bName) { return -1; } - if (aName > bName) { return 1; } - return 0; - }); + sortOptionParameters(returnData); if (operation === 'update') { returnData.push({ @@ -4185,7 +4110,7 @@ export class Pipedrive implements INodeType { data: Array<{ id: string; name: string; }> }; - return data.map(({ id, name }) => ({ value: id, name })); + return sortOptionParameters(data.map(({ id, name }) => ({ value: id, name }))); }, // Get all the lead labels to display them to user so that he can @@ -4195,7 +4120,7 @@ export class Pipedrive implements INodeType { data: Array<{ id: string; name: string; }> }; - return data.map(({ id, name }) => ({ value: id, name })); + return sortOptionParameters(data.map(({ id, name }) => ({ value: id, name }))); }, // Get all the labels to display them to user so that he can @@ -4217,13 +4142,7 @@ export class Pipedrive implements INodeType { } } - returnData.sort((a, b) => { - const aName = a.name.toLowerCase(); - const bName = b.name.toLowerCase(); - if (aName < bName) { return -1; } - if (aName > bName) { return 1; } - return 0; - }); + sortOptionParameters(returnData); if (operation === 'update') { returnData.push({ From 8a39e92348720dd3c7f0307766a3b78d981a93c0 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Sat, 9 Oct 2021 14:32:51 -0500 Subject: [PATCH 073/290] :zap: Some fixes and improvements --- .../nodes/Pipedrive/Pipedrive.node.ts | 30 ++++++------------- 1 file changed, 9 insertions(+), 21 deletions(-) diff --git a/packages/nodes-base/nodes/Pipedrive/Pipedrive.node.ts b/packages/nodes-base/nodes/Pipedrive/Pipedrive.node.ts index f3d2345236..175d628760 100644 --- a/packages/nodes-base/nodes/Pipedrive/Pipedrive.node.ts +++ b/packages/nodes-base/nodes/Pipedrive/Pipedrive.node.ts @@ -270,7 +270,7 @@ export class Pipedrive implements INodeType { { name: 'Get All', value: 'getAll', - description: 'Get all products in a deal', + description: 'Get all activities of a deal', }, ], default: 'getAll', @@ -3471,7 +3471,7 @@ export class Pipedrive implements INodeType { ], }, }, - description: 'The ID of the deal whose products to retrieve', + description: 'The ID of the deal whose activity to retrieve', }, { displayName: 'Additional Fields', @@ -3493,32 +3493,20 @@ export class Pipedrive implements INodeType { { displayName: 'Done', name: 'done', - type: 'options', - options: [ - { - name: 'Not done', - value: '0', - }, - { - name: 'Done', - value: '1', - }, - ], - default: '0', + type: 'boolean', + default: false, description: 'Whether the activity is done or not.', }, { - displayName: 'Exclude Activity Ids', + displayName: 'Exclude Activity IDs', name: 'exclude', type: 'string', - typeOptions: { - rows: 3, - }, default: '', description: 'A comma separated Activity Ids, to exclude from result. Ex. 4, 9, 11, ...', }, ], }, + // ---------------------------------------- // lead: getAll // ---------------------------------------- @@ -4423,8 +4411,8 @@ export class Pipedrive implements INodeType { qs.exclude = (additionalFields.exclude as string); } - if (additionalFields.done) { - qs.done = parseInt(additionalFields.done as string); + if (additionalFields && additionalFields.done !== undefined) { + qs.done = additionalFields.done === true ? 1 : 0; } endpoint = `/deals/${dealId}/activities`; @@ -5025,7 +5013,7 @@ export class Pipedrive implements INodeType { returnData.push(responseData.data as IDataObject); } } - } catch (error: any) { + } catch (error) { if (this.continueOnFail()) { if (resource === 'file' && operation === 'download') { items[i].json = { error: error.message }; From 49fbea75516a7ff6600c86f51aaaa9aff48c0387 Mon Sep 17 00:00:00 2001 From: Roberto Damiani Date: Sun, 10 Oct 2021 02:42:25 +0200 Subject: [PATCH 074/290] onedrive folders hierarchy creation support --- .../Microsoft/OneDrive/FolderDescription.ts | 2 +- .../OneDrive/MicrosoftOneDrive.node.ts | 25 ++++++++++++------- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/packages/nodes-base/nodes/Microsoft/OneDrive/FolderDescription.ts b/packages/nodes-base/nodes/Microsoft/OneDrive/FolderDescription.ts index 16f1ccdf4d..f910265bb0 100644 --- a/packages/nodes-base/nodes/Microsoft/OneDrive/FolderDescription.ts +++ b/packages/nodes-base/nodes/Microsoft/OneDrive/FolderDescription.ts @@ -67,7 +67,7 @@ export const folderFields = [ }, }, default: '', - description: `Folder's name`, + description: `Folder's name, or a folders slash (/) separated hierarchy`, }, { displayName: 'Options', diff --git a/packages/nodes-base/nodes/Microsoft/OneDrive/MicrosoftOneDrive.node.ts b/packages/nodes-base/nodes/Microsoft/OneDrive/MicrosoftOneDrive.node.ts index da16900725..62cc9e0494 100644 --- a/packages/nodes-base/nodes/Microsoft/OneDrive/MicrosoftOneDrive.node.ts +++ b/packages/nodes-base/nodes/Microsoft/OneDrive/MicrosoftOneDrive.node.ts @@ -209,17 +209,24 @@ export class MicrosoftOneDrive implements INodeType { if (resource === 'folder') { //https://docs.microsoft.com/en-us/onedrive/developer/rest-api/api/driveitem_post_children?view=odsp-graph-online if (operation === 'create') { - const name = this.getNodeParameter('name', i) as string; + const names = (this.getNodeParameter('name', i) as string).split("/").filter( s => s.trim() != "" ); const options = this.getNodeParameter('options', i) as IDataObject; - const body: IDataObject = { - name, - folder: {}, - }; - let endpoint = '/drive/root/children'; - if (options.parentFolderId) { - endpoint = `/drive/items/${options.parentFolderId}/children`; + let parentFolderId = options.parentFolderId ? options.parentFolderId : null; + for( let name of names ) { + let body: IDataObject = { + name, + folder: {}, + }; + let endpoint = '/drive/root/children'; + if (parentFolderId) { + endpoint = `/drive/items/${parentFolderId}/children`; + } + responseData = await microsoftApiRequest.call(this, 'POST', endpoint, body); + if( !responseData.id ) { + break; + } + parentFolderId = responseData.id; } - responseData = await microsoftApiRequest.call(this, 'POST', endpoint, body); returnData.push(responseData); } //https://docs.microsoft.com/en-us/onedrive/developer/rest-api/api/driveitem_delete?view=odsp-graph-online From 1e34aca8bda1406900137dcc11cec2a574e50166 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Tue, 12 Oct 2021 22:20:51 -0500 Subject: [PATCH 075/290] :zap: Fix build issue --- packages/core/src/NodeExecuteFunctions.ts | 6 +++--- .../nodes-base/nodes/Aws/Comprehend/GenericFunctions.ts | 3 ++- packages/nodes-base/nodes/Aws/DynamoDB/GenericFunctions.ts | 3 ++- packages/nodes-base/nodes/Aws/GenericFunctions.ts | 4 ++-- .../nodes-base/nodes/Aws/Rekognition/GenericFunctions.ts | 3 ++- packages/nodes-base/nodes/Aws/S3/GenericFunctions.ts | 3 ++- packages/nodes-base/nodes/Aws/SES/GenericFunctions.ts | 3 ++- .../nodes-base/nodes/Aws/Transcribe/GenericFunctions.ts | 4 ++-- packages/nodes-base/nodes/S3/GenericFunctions.ts | 3 ++- 9 files changed, 19 insertions(+), 13 deletions(-) diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index 2ce225badf..9e7f1bb747 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -236,11 +236,11 @@ async function parseRequestObject(requestObject: IDataObject) { } if (requestObject.uri !== undefined) { - axiosConfig.url = requestObject.uri as string; + axiosConfig.url = requestObject.uri?.toString() as string; } if (requestObject.url !== undefined) { - axiosConfig.url = requestObject.url as string; + axiosConfig.url = requestObject.url?.toString() as string; } if (requestObject.method !== undefined) { @@ -449,7 +449,7 @@ async function proxyRequestToAxios( error.cause = errorData; error.error = error.response?.data || errorData; error.statusCode = error.response?.status; - error.options = config; + error.options = config || {}; // Remove not needed data and so also remove circular references error.request = undefined; diff --git a/packages/nodes-base/nodes/Aws/Comprehend/GenericFunctions.ts b/packages/nodes-base/nodes/Aws/Comprehend/GenericFunctions.ts index 48703ee25b..399c9ea89c 100644 --- a/packages/nodes-base/nodes/Aws/Comprehend/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Aws/Comprehend/GenericFunctions.ts @@ -3,6 +3,7 @@ import { } from 'url'; import { + Request, sign, } from 'aws4'; @@ -47,7 +48,7 @@ export async function awsApiRequest(this: IHookFunctions | IExecuteFunctions | I const endpoint = new URL(getEndpointForService(service, credentials) + path); // Sign AWS API request with the user credentials - const signOpts = { headers: headers || {}, host: endpoint.host, method, path, body }; + const signOpts = { headers: headers || {}, host: endpoint.host, method, path, body } as Request; sign(signOpts, { accessKeyId: `${credentials.accessKeyId}`.trim(), secretAccessKey: `${credentials.secretAccessKey}`.trim() }); diff --git a/packages/nodes-base/nodes/Aws/DynamoDB/GenericFunctions.ts b/packages/nodes-base/nodes/Aws/DynamoDB/GenericFunctions.ts index faa08410da..9e71992875 100644 --- a/packages/nodes-base/nodes/Aws/DynamoDB/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Aws/DynamoDB/GenericFunctions.ts @@ -45,9 +45,10 @@ export async function awsApiRequest(this: IHookFunctions | IExecuteFunctions | I const endpoint = new URL(getEndpointForService(service, credentials) + path); const options = sign({ + // @ts-ignore uri: endpoint, service, - region: credentials.region, + region: credentials.region as string, method, path: '/', headers: { ...headers }, diff --git a/packages/nodes-base/nodes/Aws/GenericFunctions.ts b/packages/nodes-base/nodes/Aws/GenericFunctions.ts index afea4f9e13..49fa4c3208 100644 --- a/packages/nodes-base/nodes/Aws/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Aws/GenericFunctions.ts @@ -1,5 +1,5 @@ import { URL } from 'url'; -import { sign } from 'aws4'; +import { Request, sign } from 'aws4'; import { OptionsWithUri } from 'request'; import { parseString as parseXml } from 'xml2js'; @@ -38,7 +38,7 @@ export async function awsApiRequest(this: IHookFunctions | IExecuteFunctions | I const endpoint = new URL(getEndpointForService(service, credentials) + path); // Sign AWS API request with the user credentials - const signOpts = { headers: headers || {}, host: endpoint.host, method, path, body }; + const signOpts = { headers: headers || {}, host: endpoint.host, method, path, body } as Request; sign(signOpts, { accessKeyId: `${credentials.accessKeyId}`.trim(), secretAccessKey: `${credentials.secretAccessKey}`.trim() }); diff --git a/packages/nodes-base/nodes/Aws/Rekognition/GenericFunctions.ts b/packages/nodes-base/nodes/Aws/Rekognition/GenericFunctions.ts index b19dd1e576..dc2b1b820c 100644 --- a/packages/nodes-base/nodes/Aws/Rekognition/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Aws/Rekognition/GenericFunctions.ts @@ -3,6 +3,7 @@ import { } from 'url'; import { + Request, sign, } from 'aws4'; @@ -44,7 +45,7 @@ export async function awsApiRequest(this: IHookFunctions | IExecuteFunctions | I const endpoint = new URL(((credentials.rekognitionEndpoint as string || '').replace('{region}', credentials.region as string) || `https://${service}.${credentials.region}.amazonaws.com`) + path); // Sign AWS API request with the user credentials - const signOpts = {headers: headers || {}, host: endpoint.host, method, path, body}; + const signOpts = {headers: headers || {}, host: endpoint.host, method, path, body} as Request; sign(signOpts, { accessKeyId: `${credentials.accessKeyId}`.trim(), secretAccessKey: `${credentials.secretAccessKey}`.trim()}); diff --git a/packages/nodes-base/nodes/Aws/S3/GenericFunctions.ts b/packages/nodes-base/nodes/Aws/S3/GenericFunctions.ts index 7d183dadde..1aa593e5c3 100644 --- a/packages/nodes-base/nodes/Aws/S3/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Aws/S3/GenericFunctions.ts @@ -3,6 +3,7 @@ import { } from 'url'; import { + Request, sign, } from 'aws4'; @@ -38,7 +39,7 @@ export async function awsApiRequest(this: IHookFunctions | IExecuteFunctions | I const endpoint = new URL(((credentials.s3Endpoint as string || '').replace('{region}', credentials.region as string) || `https://${service}.${credentials.region}.amazonaws.com`) + path); // Sign AWS API request with the user credentials - const signOpts = {headers: headers || {}, host: endpoint.host, method, path: `${endpoint.pathname}?${queryToString(query).replace(/\+/g, '%2B')}`, body}; + const signOpts = {headers: headers || {}, host: endpoint.host, method, path: `${endpoint.pathname}?${queryToString(query).replace(/\+/g, '%2B')}`, body} as Request; sign(signOpts, { accessKeyId: `${credentials.accessKeyId}`.trim(), secretAccessKey: `${credentials.secretAccessKey}`.trim()}); diff --git a/packages/nodes-base/nodes/Aws/SES/GenericFunctions.ts b/packages/nodes-base/nodes/Aws/SES/GenericFunctions.ts index 20701651d3..09b28b7aa1 100644 --- a/packages/nodes-base/nodes/Aws/SES/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Aws/SES/GenericFunctions.ts @@ -3,6 +3,7 @@ import { } from 'url'; import { + Request, sign, } from 'aws4'; @@ -39,7 +40,7 @@ export async function awsApiRequest(this: IHookFunctions | IExecuteFunctions | I // Sign AWS API request with the user credentials - const signOpts = { headers: headers || {}, host: endpoint.host, method, path, body }; + const signOpts = { headers: headers || {}, host: endpoint.host, method, path, body } as Request; sign(signOpts, { accessKeyId: `${credentials.accessKeyId}`.trim(), secretAccessKey: `${credentials.secretAccessKey}`.trim() }); const options: OptionsWithUri = { diff --git a/packages/nodes-base/nodes/Aws/Transcribe/GenericFunctions.ts b/packages/nodes-base/nodes/Aws/Transcribe/GenericFunctions.ts index f94823c376..ea2abe4dd1 100644 --- a/packages/nodes-base/nodes/Aws/Transcribe/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Aws/Transcribe/GenericFunctions.ts @@ -3,6 +3,7 @@ import { } from 'url'; import { + Request, sign, } from 'aws4'; @@ -50,7 +51,7 @@ export async function awsApiRequest(this: IHookFunctions | IExecuteFunctions | I const endpoint = new URL(getEndpointForService(service, credentials) + path); // Sign AWS API request with the user credentials - const signOpts = { headers: headers || {}, host: endpoint.host, method, path, body }; + const signOpts = { headers: headers || {}, host: endpoint.host, method, path, body } as Request; sign(signOpts, { accessKeyId: `${credentials.accessKeyId}`.trim(), secretAccessKey: `${credentials.secretAccessKey}`.trim() }); @@ -104,4 +105,3 @@ export async function awsApiRequestRESTAllItems(this: IHookFunctions | IExecuteF return returnData; } - diff --git a/packages/nodes-base/nodes/S3/GenericFunctions.ts b/packages/nodes-base/nodes/S3/GenericFunctions.ts index 40d2cc2719..bfdafe0583 100644 --- a/packages/nodes-base/nodes/S3/GenericFunctions.ts +++ b/packages/nodes-base/nodes/S3/GenericFunctions.ts @@ -1,4 +1,5 @@ import { + Request, sign, } from 'aws4'; @@ -62,7 +63,7 @@ export async function s3ApiRequest(this: IHookFunctions | IExecuteFunctions | IL path: `${path}?${queryToString(query).replace(/\+/g, '%2B')}`, service: 's3', body, - }; + } as Request; sign(signOpts, { accessKeyId: `${credentials.accessKeyId}`.trim(), secretAccessKey: `${credentials.secretAccessKey}`.trim() }); From 3137de2585f93753a50fb5e41a8c39128b73002a Mon Sep 17 00:00:00 2001 From: Ben Hesseldieck <1849459+BHesseldieck@users.noreply.github.com> Date: Thu, 14 Oct 2021 00:21:00 +0200 Subject: [PATCH 076/290] :zap: Change credentials structure (#2139) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :sparkles: change FE to handle new object type * 🚸 improve UX of handling invalid credentials * 🚧 WIP * :art: fix typescript issues * 🐘 add migrations for all supported dbs * ✏️ add description to migrations * :zap: add credential update on import * :zap: resolve after merge issues * :shirt: fix lint issues * :zap: check credentials on workflow create/update * update interface * :shirt: fix ts issues * :zap: adaption to new credentials UI * :bug: intialize cache on BE for credentials check * :bug: fix undefined oldCredentials * :bug: fix deleting credential * :bug: fix check for undefined keys * :bug: fix disabling edit in execution * :art: just show credential name on execution view * ✏️ remove TODO * :zap: implement review suggestions * :zap: add cache to getCredentialsByType * ⏪ use getter instead of cache * ✏️ fix variable name typo * 🐘 include waiting nodes to migrations * :bug: fix reverting migrations command * :zap: update typeorm command * :sparkles: create db:revert command * 👕 fix lint error Co-authored-by: Mutasem --- packages/cli/commands/db/revert.ts | 61 +++++ packages/cli/commands/export/credentials.ts | 3 +- packages/cli/commands/import/workflow.ts | 43 +++- packages/cli/commands/start.ts | 10 +- packages/cli/migrations/ormconfig.ts | 6 +- packages/cli/package.json | 2 +- packages/cli/src/CredentialsHelper.ts | 44 ++-- packages/cli/src/GenericHelpers.ts | 40 ---- packages/cli/src/Server.ts | 90 +++---- packages/cli/src/WorkflowCredentials.ts | 27 ++- packages/cli/src/WorkflowHelpers.ts | 108 +++++++++ .../databases/entities/CredentialsEntity.ts | 35 ++- .../src/databases/entities/ExecutionEntity.ts | 20 +- .../cli/src/databases/entities/TagEntity.ts | 17 +- .../src/databases/entities/WorkflowEntity.ts | 37 ++- ...1630451444017-UpdateWorkflowCredentials.ts | 215 +++++++++++++++++ .../src/databases/mysqldb/migrations/index.ts | 2 + ...1630419189837-UpdateWorkflowCredentials.ts | 223 ++++++++++++++++++ .../databases/postgresdb/migrations/index.ts | 2 + ...1630330987096-UpdateWorkflowCredentials.ts | 215 +++++++++++++++++ .../src/databases/sqlite/migrations/index.ts | 2 + packages/cli/src/databases/utils.ts | 42 ---- packages/core/src/Credentials.ts | 1 + packages/core/src/NodeExecuteFunctions.ts | 43 ++-- packages/core/test/Credentials.test.ts | 11 +- packages/core/test/Helpers.ts | 12 +- packages/editor-ui/src/Interface.ts | 2 +- .../CredentialEdit/CredentialEdit.vue | 2 + .../src/components/NodeCredentials.vue | 94 ++++++-- .../src/components/mixins/nodeHelpers.ts | 31 ++- packages/editor-ui/src/modules/credentials.ts | 8 +- packages/editor-ui/src/store.ts | 26 ++ packages/editor-ui/src/views/NodeView.vue | 51 +++- packages/nodes-base/nodes/Wait.node.ts | 1 + packages/nodes-base/nodes/Webhook.node.ts | 1 + packages/workflow/src/Interfaces.ts | 42 +++- 36 files changed, 1318 insertions(+), 251 deletions(-) create mode 100644 packages/cli/commands/db/revert.ts create mode 100644 packages/cli/src/databases/mysqldb/migrations/1630451444017-UpdateWorkflowCredentials.ts create mode 100644 packages/cli/src/databases/postgresdb/migrations/1630419189837-UpdateWorkflowCredentials.ts create mode 100644 packages/cli/src/databases/sqlite/migrations/1630330987096-UpdateWorkflowCredentials.ts delete mode 100644 packages/cli/src/databases/utils.ts diff --git a/packages/cli/commands/db/revert.ts b/packages/cli/commands/db/revert.ts new file mode 100644 index 0000000000..585027cf06 --- /dev/null +++ b/packages/cli/commands/db/revert.ts @@ -0,0 +1,61 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable no-console */ +import { Command, flags } from '@oclif/command'; +import { Connection, ConnectionOptions, createConnection } from 'typeorm'; +import { LoggerProxy } from 'n8n-workflow'; + +import { getLogger } from '../../src/Logger'; + +import { Db } from '../../src'; + +export class DbRevertMigrationCommand extends Command { + static description = 'Revert last database migration'; + + static examples = ['$ n8n db:revert']; + + static flags = { + help: flags.help({ char: 'h' }), + }; + + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types + async run() { + const logger = getLogger(); + LoggerProxy.init(logger); + + // eslint-disable-next-line @typescript-eslint/no-shadow, @typescript-eslint/no-unused-vars + const { flags } = this.parse(DbRevertMigrationCommand); + + let connection: Connection | undefined; + try { + await Db.init(); + connection = Db.collections.Credentials?.manager.connection; + + if (!connection) { + throw new Error(`No database connection available.`); + } + + const connectionOptions: ConnectionOptions = Object.assign(connection.options, { + subscribers: [], + synchronize: false, + migrationsRun: false, + dropSchema: false, + logging: ['query', 'error', 'schema'], + }); + + // close connection in order to reconnect with updated options + await connection.close(); + connection = await createConnection(connectionOptions); + + await connection.undoLastMigration(); + await connection.close(); + } catch (error) { + if (connection) await connection.close(); + + console.error('Error reverting last migration. See log messages for details.'); + logger.error(error.message); + this.exit(1); + } + + this.exit(); + } +} diff --git a/packages/cli/commands/export/credentials.ts b/packages/cli/commands/export/credentials.ts index 9ee488fe0e..b8ebb95ec9 100644 --- a/packages/cli/commands/export/credentials.ts +++ b/packages/cli/commands/export/credentials.ts @@ -129,7 +129,8 @@ export class ExportCredentialsCommand extends Command { for (let i = 0; i < credentials.length; i++) { const { name, type, nodesAccess, data } = credentials[i]; - const credential = new Credentials(name, type, nodesAccess, data); + const id = credentials[i].id as string; + const credential = new Credentials({ id, name }, type, nodesAccess, data); const plainData = credential.getData(encryptionKey); (credentials[i] as ICredentialsDecryptedDb).data = plainData; } diff --git a/packages/cli/commands/import/workflow.ts b/packages/cli/commands/import/workflow.ts index 7fb94c1039..e27ac2dd16 100644 --- a/packages/cli/commands/import/workflow.ts +++ b/packages/cli/commands/import/workflow.ts @@ -2,14 +2,14 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ import { Command, flags } from '@oclif/command'; -import { LoggerProxy } from 'n8n-workflow'; +import { INode, INodeCredentialsDetails, LoggerProxy } from 'n8n-workflow'; import * as fs from 'fs'; import * as glob from 'fast-glob'; import * as path from 'path'; import { UserSettings } from 'n8n-core'; import { getLogger } from '../../src/Logger'; -import { Db } from '../../src'; +import { Db, ICredentialsDb } from '../../src'; export class ImportWorkflowsCommand extends Command { static description = 'Import workflows'; @@ -30,6 +30,32 @@ export class ImportWorkflowsCommand extends Command { }), }; + private transformCredentials(node: INode, credentialsEntities: ICredentialsDb[]) { + if (node.credentials) { + const allNodeCredentials = Object.entries(node.credentials); + // eslint-disable-next-line no-restricted-syntax + for (const [type, name] of allNodeCredentials) { + if (typeof name === 'string') { + const nodeCredentials: INodeCredentialsDetails = { + id: null, + name, + }; + + const matchingCredentials = credentialsEntities.filter( + (credentials) => credentials.name === name && credentials.type === type, + ); + + if (matchingCredentials.length === 1) { + nodeCredentials.id = matchingCredentials[0].id.toString(); + } + + // eslint-disable-next-line no-param-reassign + node.credentials[type] = nodeCredentials; + } + } + } + } + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types async run() { const logger = getLogger(); @@ -57,6 +83,7 @@ export class ImportWorkflowsCommand extends Command { // Make sure the settings exist await UserSettings.prepareUserSettings(); + const credentialsEntities = (await Db.collections.Credentials?.find()) ?? []; let i; if (flags.separate) { const files = await glob( @@ -64,6 +91,12 @@ export class ImportWorkflowsCommand extends Command { ); for (i = 0; i < files.length; i++) { const workflow = JSON.parse(fs.readFileSync(files[i], { encoding: 'utf8' })); + if (credentialsEntities.length > 0) { + // eslint-disable-next-line + workflow.nodes.forEach((node: INode) => { + this.transformCredentials(node, credentialsEntities); + }); + } // eslint-disable-next-line no-await-in-loop, @typescript-eslint/no-non-null-assertion await Db.collections.Workflow!.save(workflow); } @@ -75,6 +108,12 @@ export class ImportWorkflowsCommand extends Command { } for (i = 0; i < fileContents.length; i++) { + if (credentialsEntities.length > 0) { + // eslint-disable-next-line + fileContents[i].nodes.forEach((node: INode) => { + this.transformCredentials(node, credentialsEntities); + }); + } // eslint-disable-next-line no-await-in-loop, @typescript-eslint/no-non-null-assertion await Db.collections.Workflow!.save(fileContents[i]); } diff --git a/packages/cli/commands/start.ts b/packages/cli/commands/start.ts index 78550f7037..33f83cac7a 100644 --- a/packages/cli/commands/start.ts +++ b/packages/cli/commands/start.ts @@ -37,7 +37,7 @@ import { getLogger } from '../src/Logger'; const open = require('open'); let activeWorkflowRunner: ActiveWorkflowRunner.ActiveWorkflowRunner | undefined; -let processExistCode = 0; +let processExitCode = 0; export class Start extends Command { static description = 'Starts n8n. Makes Web-UI available and starts active workflows'; @@ -92,7 +92,7 @@ export class Start extends Command { setTimeout(() => { // In case that something goes wrong with shutdown we // kill after max. 30 seconds no matter what - process.exit(processExistCode); + process.exit(processExitCode); }, 30000); const skipWebhookDeregistration = config.get( @@ -133,7 +133,7 @@ export class Start extends Command { console.error('There was an error shutting down n8n.', error); } - process.exit(processExistCode); + process.exit(processExitCode); } async run() { @@ -160,7 +160,7 @@ export class Start extends Command { const startDbInitPromise = Db.init().catch((error: Error) => { logger.error(`There was an error initializing DB: "${error.message}"`); - processExistCode = 1; + processExitCode = 1; // @ts-ignore process.emit('SIGINT'); process.exit(1); @@ -355,7 +355,7 @@ export class Start extends Command { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions this.error(`There was an error: ${error.message}`); - processExistCode = 1; + processExitCode = 1; // @ts-ignore process.emit('SIGINT'); } diff --git a/packages/cli/migrations/ormconfig.ts b/packages/cli/migrations/ormconfig.ts index efcfce2cc3..1b83c806e1 100644 --- a/packages/cli/migrations/ormconfig.ts +++ b/packages/cli/migrations/ormconfig.ts @@ -9,7 +9,7 @@ module.exports = [ logging: true, entities: Object.values(entities), database: path.join(UserSettings.getUserN8nFolderPath(), 'database.sqlite'), - migrations: ['./src/databases/sqlite/migrations/*.ts'], + migrations: ['./src/databases/sqlite/migrations/index.ts'], subscribers: ['./src/databases/sqlite/subscribers/*.ts'], cli: { entitiesDir: './src/databases/entities', @@ -28,7 +28,7 @@ module.exports = [ database: 'n8n', schema: 'public', entities: Object.values(entities), - migrations: ['./src/databases/postgresdb/migrations/*.ts'], + migrations: ['./src/databases/postgresdb/migrations/index.ts'], subscribers: ['src/subscriber/**/*.ts'], cli: { entitiesDir: './src/databases/entities', @@ -46,7 +46,7 @@ module.exports = [ port: '3306', logging: false, entities: Object.values(entities), - migrations: ['./src/databases/mysqldb/migrations/*.ts'], + migrations: ['./src/databases/mysqldb/migrations/index.ts'], subscribers: ['src/subscriber/**/*.ts'], cli: { entitiesDir: './src/databases/entities', diff --git a/packages/cli/package.json b/packages/cli/package.json index 60155aabd8..d87958f778 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -31,7 +31,7 @@ "start:windows": "cd bin && n8n", "test": "jest", "watch": "tsc --watch", - "typeorm": "ts-node ./node_modules/typeorm/cli.js" + "typeorm": "ts-node ../../node_modules/typeorm/cli.js" }, "bin": { "n8n": "./bin/n8n" diff --git a/packages/cli/src/CredentialsHelper.ts b/packages/cli/src/CredentialsHelper.ts index 5a101e7984..f16f094bb6 100644 --- a/packages/cli/src/CredentialsHelper.ts +++ b/packages/cli/src/CredentialsHelper.ts @@ -5,6 +5,7 @@ import { ICredentialsExpressionResolveValues, ICredentialsHelper, INode, + INodeCredentialsDetails, INodeParameters, INodeProperties, INodeType, @@ -39,30 +40,32 @@ export class CredentialsHelper extends ICredentialsHelper { /** * Returns the credentials instance * - * @param {string} name Name of the credentials to return instance of + * @param {INodeCredentialsDetails} nodeCredentials id and name to return instance of * @param {string} type Type of the credentials to return instance of * @returns {Credentials} * @memberof CredentialsHelper */ - async getCredentials(name: string, type: string): Promise { - const credentialsDb = await Db.collections.Credentials?.find({ type }); - - if (credentialsDb === undefined || credentialsDb.length === 0) { - throw new Error(`No credentials of type "${type}" exist.`); + async getCredentials( + nodeCredentials: INodeCredentialsDetails, + type: string, + ): Promise { + if (!nodeCredentials.id) { + throw new Error(`Credentials "${nodeCredentials.name}" for type "${type}" don't have an ID.`); } - // eslint-disable-next-line @typescript-eslint/no-shadow - const credential = credentialsDb.find((credential) => credential.name === name); + const credentials = await Db.collections.Credentials?.findOne({ id: nodeCredentials.id, type }); - if (credential === undefined) { - throw new Error(`No credentials with name "${name}" exist for type "${type}".`); + if (!credentials) { + throw new Error( + `Credentials with ID "${nodeCredentials.id}" don't exist for type "${type}".`, + ); } return new Credentials( - credential.name, - credential.type, - credential.nodesAccess, - credential.data, + { id: credentials.id.toString(), name: credentials.name }, + credentials.type, + credentials.nodesAccess, + credentials.data, ); } @@ -101,21 +104,20 @@ export class CredentialsHelper extends ICredentialsHelper { /** * Returns the decrypted credential data with applied overwrites * - * @param {string} name Name of the credentials to return data of + * @param {INodeCredentialsDetails} nodeCredentials id and name to return instance of * @param {string} type Type of the credentials to return data of * @param {boolean} [raw] Return the data as supplied without defaults or overwrites * @returns {ICredentialDataDecryptedObject} * @memberof CredentialsHelper */ async getDecrypted( - name: string, + nodeCredentials: INodeCredentialsDetails, type: string, mode: WorkflowExecuteMode, raw?: boolean, expressionResolveValues?: ICredentialsExpressionResolveValues, ): Promise { - const credentials = await this.getCredentials(name, type); - + const credentials = await this.getCredentials(nodeCredentials, type); const decryptedDataOriginal = credentials.getData(this.encryptionKey); if (raw === true) { @@ -228,12 +230,12 @@ export class CredentialsHelper extends ICredentialsHelper { * @memberof CredentialsHelper */ async updateCredentials( - name: string, + nodeCredentials: INodeCredentialsDetails, type: string, data: ICredentialDataDecryptedObject, ): Promise { // eslint-disable-next-line @typescript-eslint/await-thenable - const credentials = await this.getCredentials(name, type); + const credentials = await this.getCredentials(nodeCredentials, type); if (Db.collections.Credentials === null) { // The first time executeWorkflow gets called the Database has @@ -251,7 +253,7 @@ export class CredentialsHelper extends ICredentialsHelper { // Save the credentials in DB const findQuery = { - name, + id: credentials.id, type, }; diff --git a/packages/cli/src/GenericHelpers.ts b/packages/cli/src/GenericHelpers.ts index 7fe8c50ac4..73a2310929 100644 --- a/packages/cli/src/GenericHelpers.ts +++ b/packages/cli/src/GenericHelpers.ts @@ -7,7 +7,6 @@ import * as express from 'express'; import { join as pathJoin } from 'path'; import { readFile as fsReadFile } from 'fs/promises'; -import { readFileSync as fsReadFileSync } from 'fs'; import { IDataObject } from 'n8n-workflow'; import * as config from '../config'; @@ -137,45 +136,6 @@ export async function getConfigValue( return data; } -/** - * Gets value from config with support for "_FILE" environment variables synchronously - * - * @export - * @param {string} configKey The key of the config data to get - * @returns {(string | boolean | number | undefined)} - */ -export function getConfigValueSync(configKey: string): string | boolean | number | undefined { - // Get the environment variable - const configSchema = config.getSchema(); - // @ts-ignore - const currentSchema = extractSchemaForKey(configKey, configSchema._cvtProperties as IDataObject); - // Check if environment variable is defined for config key - if (currentSchema.env === undefined) { - // No environment variable defined, so return value from config - return config.get(configKey); - } - - // Check if special file enviroment variable exists - const fileEnvironmentVariable = process.env[`${currentSchema.env}_FILE`]; - if (fileEnvironmentVariable === undefined) { - // Does not exist, so return value from config - return config.get(configKey); - } - - let data; - try { - data = fsReadFileSync(fileEnvironmentVariable, 'utf8'); - } catch (error) { - if (error.code === 'ENOENT') { - throw new Error(`The file "${fileEnvironmentVariable}" could not be found.`); - } - - throw error; - } - - return data; -} - /** * Generate a unique name for a workflow or credentials entity. * diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index a23c5eedb9..f19c6a922f 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -66,6 +66,7 @@ import { ICredentialType, IDataObject, INodeCredentials, + INodeCredentialsDetails, INodeParameters, INodePropertyOptions, INodeType, @@ -642,6 +643,9 @@ class App { }); } + // check credentials for old format + await WorkflowHelpers.replaceInvalidCredentials(newWorkflow); + await this.externalHooks.run('workflow.create', [newWorkflow]); await WorkflowHelpers.validateWorkflow(newWorkflow); @@ -782,6 +786,9 @@ class App { const { id } = req.params; updateData.id = id; + // check credentials for old format + await WorkflowHelpers.replaceInvalidCredentials(updateData as WorkflowEntity); + await this.externalHooks.run('workflow.update', [updateData]); const isActive = await this.activeWorkflowRunner.isActive(id); @@ -1293,26 +1300,9 @@ class App { throw new Error('Credentials have to have a name set!'); } - // Check if credentials with the same name and type exist already - const findQuery = { - where: { - name: incomingData.name, - type: incomingData.type, - }, - } as FindOneOptions; - - const checkResult = await Db.collections.Credentials!.findOne(findQuery); - if (checkResult !== undefined) { - throw new ResponseHelper.ResponseError( - `Credentials with the same type and name exist already.`, - undefined, - 400, - ); - } - // Encrypt the data const credentials = new Credentials( - incomingData.name, + { id: null, name: incomingData.name }, incomingData.type, incomingData.nodesAccess, ); @@ -1321,10 +1311,6 @@ class App { await this.externalHooks.run('credentials.create', [newCredentialsData]); - // Add special database related data - - // TODO: also add user automatically depending on who is logged in, if anybody is logged in - // Save the credentials in DB const result = await Db.collections.Credentials!.save(newCredentialsData); result.data = incomingData.data; @@ -1445,24 +1431,6 @@ class App { } } - // Check if credentials with the same name and type exist already - const findQuery = { - where: { - id: Not(id), - name: incomingData.name, - type: incomingData.type, - }, - } as FindOneOptions; - - const checkResult = await Db.collections.Credentials!.findOne(findQuery); - if (checkResult !== undefined) { - throw new ResponseHelper.ResponseError( - `Credentials with the same type and name exist already.`, - undefined, - 400, - ); - } - const encryptionKey = await UserSettings.getEncryptionKey(); if (encryptionKey === undefined) { throw new Error('No encryption key got found to encrypt the credentials!'); @@ -1479,7 +1447,7 @@ class App { } const currentlySavedCredentials = new Credentials( - result.name, + result as INodeCredentialsDetails, result.type, result.nodesAccess, result.data, @@ -1494,7 +1462,7 @@ class App { // Encrypt the data const credentials = new Credentials( - incomingData.name, + { id, name: incomingData.name }, incomingData.type, incomingData.nodesAccess, ); @@ -1563,7 +1531,7 @@ class App { } const credentials = new Credentials( - result.name, + result as INodeCredentialsDetails, result.type, result.nodesAccess, result.data, @@ -1707,7 +1675,7 @@ class App { const mode: WorkflowExecuteMode = 'internal'; const credentialsHelper = new CredentialsHelper(encryptionKey); const decryptedDataOriginal = await credentialsHelper.getDecrypted( - result.name, + result as INodeCredentialsDetails, result.type, mode, true, @@ -1766,7 +1734,11 @@ class App { }`; // Encrypt the data - const credentials = new Credentials(result.name, result.type, result.nodesAccess); + const credentials = new Credentials( + result as INodeCredentialsDetails, + result.type, + result.nodesAccess, + ); credentials.setData(decryptedDataOriginal, encryptionKey); const newCredentialsData = credentials.getDataToSave() as unknown as ICredentialsDb; @@ -1823,13 +1795,13 @@ class App { // Decrypt the currently saved credentials const workflowCredentials: IWorkflowCredentials = { [result.type]: { - [result.name]: result as ICredentialsEncrypted, + [result.id.toString()]: result as ICredentialsEncrypted, }, }; const mode: WorkflowExecuteMode = 'internal'; const credentialsHelper = new CredentialsHelper(encryptionKey); const decryptedDataOriginal = await credentialsHelper.getDecrypted( - result.name, + result as INodeCredentialsDetails, result.type, mode, true, @@ -1868,7 +1840,11 @@ class App { decryptedDataOriginal.oauthTokenData = oauthTokenJson; - const credentials = new Credentials(result.name, result.type, result.nodesAccess); + const credentials = new Credentials( + result as INodeCredentialsDetails, + result.type, + result.nodesAccess, + ); credentials.setData(decryptedDataOriginal, encryptionKey); const newCredentialsData = credentials.getDataToSave() as unknown as ICredentialsDb; // Add special database related data @@ -1913,7 +1889,7 @@ class App { const mode: WorkflowExecuteMode = 'internal'; const credentialsHelper = new CredentialsHelper(encryptionKey); const decryptedDataOriginal = await credentialsHelper.getDecrypted( - result.name, + result as INodeCredentialsDetails, result.type, mode, true, @@ -1950,7 +1926,11 @@ class App { const oAuthObj = new clientOAuth2(oAuthOptions); // Encrypt the data - const credentials = new Credentials(result.name, result.type, result.nodesAccess); + const credentials = new Credentials( + result as INodeCredentialsDetails, + result.type, + result.nodesAccess, + ); decryptedDataOriginal.csrfSecret = csrfSecret; credentials.setData(decryptedDataOriginal, encryptionKey); @@ -2039,14 +2019,14 @@ class App { // Decrypt the currently saved credentials const workflowCredentials: IWorkflowCredentials = { [result.type]: { - [result.name]: result as ICredentialsEncrypted, + [result.id.toString()]: result as ICredentialsEncrypted, }, }; const mode: WorkflowExecuteMode = 'internal'; const credentialsHelper = new CredentialsHelper(encryptionKey); const decryptedDataOriginal = await credentialsHelper.getDecrypted( - result.name, + result as INodeCredentialsDetails, result.type, mode, true, @@ -2128,7 +2108,11 @@ class App { _.unset(decryptedDataOriginal, 'csrfSecret'); - const credentials = new Credentials(result.name, result.type, result.nodesAccess); + const credentials = new Credentials( + result as INodeCredentialsDetails, + result.type, + result.nodesAccess, + ); credentials.setData(decryptedDataOriginal, encryptionKey); const newCredentialsData = credentials.getDataToSave() as unknown as ICredentialsDb; // Add special database related data diff --git a/packages/cli/src/WorkflowCredentials.ts b/packages/cli/src/WorkflowCredentials.ts index 622f1d9b0e..a52232866f 100644 --- a/packages/cli/src/WorkflowCredentials.ts +++ b/packages/cli/src/WorkflowCredentials.ts @@ -10,7 +10,7 @@ export async function WorkflowCredentials(nodes: INode[]): Promise { + const { nodes } = workflow; + if (!nodes) return workflow; + + // caching + const credentialsByName: Record> = {}; + const credentialsById: Record> = {}; + + // for loop to run DB fetches sequential and use cache to keep pressure off DB + // trade-off: longer response time for less DB queries + /* eslint-disable no-await-in-loop */ + for (const node of nodes) { + if (!node.credentials || node.disabled) { + continue; + } + // extract credentials types + const allNodeCredentials = Object.entries(node.credentials); + for (const [nodeCredentialType, nodeCredentials] of allNodeCredentials) { + // Check if Node applies old credentials style + if (typeof nodeCredentials === 'string' || nodeCredentials.id === null) { + const name = typeof nodeCredentials === 'string' ? nodeCredentials : nodeCredentials.name; + // init cache for type + if (!credentialsByName[nodeCredentialType]) { + credentialsByName[nodeCredentialType] = {}; + } + if (credentialsByName[nodeCredentialType][name] === undefined) { + const credentials = await Db.collections.Credentials?.find({ + name, + type: nodeCredentialType, + }); + // if credential name-type combination is unique, use it + if (credentials?.length === 1) { + credentialsByName[nodeCredentialType][name] = { + id: credentials[0].id.toString(), + name: credentials[0].name, + }; + node.credentials[nodeCredentialType] = credentialsByName[nodeCredentialType][name]; + continue; + } + + // nothing found - add invalid credentials to cache to prevent further DB checks + credentialsByName[nodeCredentialType][name] = { + id: null, + name, + }; + } else { + // get credentials from cache + node.credentials[nodeCredentialType] = credentialsByName[nodeCredentialType][name]; + } + continue; + } + + // Node has credentials with an ID + + // init cache for type + if (!credentialsById[nodeCredentialType]) { + credentialsById[nodeCredentialType] = {}; + } + + // check if credentials for ID-type are not yet cached + if (credentialsById[nodeCredentialType][nodeCredentials.id] === undefined) { + // check first if ID-type combination exists + const credentials = await Db.collections.Credentials?.findOne({ + id: nodeCredentials.id, + type: nodeCredentialType, + }); + if (credentials) { + credentialsById[nodeCredentialType][nodeCredentials.id] = { + id: credentials.id.toString(), + name: credentials.name, + }; + node.credentials[nodeCredentialType] = + credentialsById[nodeCredentialType][nodeCredentials.id]; + continue; + } + // no credentials found for ID, check if some exist for name + const credsByName = await Db.collections.Credentials?.find({ + name: nodeCredentials.name, + type: nodeCredentialType, + }); + // if credential name-type combination is unique, take it + if (credsByName?.length === 1) { + // add found credential to cache + credentialsById[nodeCredentialType][credsByName[0].id] = { + id: credsByName[0].id.toString(), + name: credsByName[0].name, + }; + node.credentials[nodeCredentialType] = + credentialsById[nodeCredentialType][credsByName[0].id]; + continue; + } + + // nothing found - add invalid credentials to cache to prevent further DB checks + credentialsById[nodeCredentialType][nodeCredentials.id] = nodeCredentials; + continue; + } + + // get credentials from cache + node.credentials[nodeCredentialType] = + credentialsById[nodeCredentialType][nodeCredentials.id]; + } + } + /* eslint-enable no-await-in-loop */ + return workflow; +} + // TODO: Deduplicate `validateWorkflow` and `throwDuplicateEntryError` with TagHelpers? // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types diff --git a/packages/cli/src/databases/entities/CredentialsEntity.ts b/packages/cli/src/databases/entities/CredentialsEntity.ts index 5cf65fb0c3..50b6f168e6 100644 --- a/packages/cli/src/databases/entities/CredentialsEntity.ts +++ b/packages/cli/src/databases/entities/CredentialsEntity.ts @@ -11,9 +11,40 @@ import { PrimaryGeneratedColumn, UpdateDateColumn, } from 'typeorm'; -import { getTimestampSyntax, resolveDataType } from '../utils'; -import { ICredentialsDb } from '../..'; +import config = require('../../../config'); +import { DatabaseType, ICredentialsDb } from '../..'; + +function resolveDataType(dataType: string) { + const dbType = config.get('database.type') as DatabaseType; + + const typeMap: { [key in DatabaseType]: { [key: string]: string } } = { + sqlite: { + json: 'simple-json', + }, + postgresdb: { + datetime: 'timestamptz', + }, + mysqldb: {}, + mariadb: {}, + }; + + return typeMap[dbType][dataType] ?? dataType; +} + +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types +function getTimestampSyntax() { + const dbType = config.get('database.type') as DatabaseType; + + const map: { [key in DatabaseType]: string } = { + sqlite: "STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')", + postgresdb: 'CURRENT_TIMESTAMP(3)', + mysqldb: 'CURRENT_TIMESTAMP(3)', + mariadb: 'CURRENT_TIMESTAMP(3)', + }; + + return map[dbType]; +} @Entity() export class CredentialsEntity implements ICredentialsDb { diff --git a/packages/cli/src/databases/entities/ExecutionEntity.ts b/packages/cli/src/databases/entities/ExecutionEntity.ts index 564ec9a4e3..1777727b91 100644 --- a/packages/cli/src/databases/entities/ExecutionEntity.ts +++ b/packages/cli/src/databases/entities/ExecutionEntity.ts @@ -2,9 +2,25 @@ import { WorkflowExecuteMode } from 'n8n-workflow'; import { Column, ColumnOptions, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; -import { IExecutionFlattedDb, IWorkflowDb } from '../..'; +import config = require('../../../config'); +import { DatabaseType, IExecutionFlattedDb, IWorkflowDb } from '../..'; -import { resolveDataType } from '../utils'; +function resolveDataType(dataType: string) { + const dbType = config.get('database.type') as DatabaseType; + + const typeMap: { [key in DatabaseType]: { [key: string]: string } } = { + sqlite: { + json: 'simple-json', + }, + postgresdb: { + datetime: 'timestamptz', + }, + mysqldb: {}, + mariadb: {}, + }; + + return typeMap[dbType][dataType] ?? dataType; +} @Entity() export class ExecutionEntity implements IExecutionFlattedDb { diff --git a/packages/cli/src/databases/entities/TagEntity.ts b/packages/cli/src/databases/entities/TagEntity.ts index 445a104af9..a845313ea7 100644 --- a/packages/cli/src/databases/entities/TagEntity.ts +++ b/packages/cli/src/databases/entities/TagEntity.ts @@ -12,9 +12,24 @@ import { } from 'typeorm'; import { IsDate, IsOptional, IsString, Length } from 'class-validator'; +import config = require('../../../config'); +import { DatabaseType } from '../../index'; import { ITagDb } from '../../Interfaces'; import { WorkflowEntity } from './WorkflowEntity'; -import { getTimestampSyntax } from '../utils'; + +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types +function getTimestampSyntax() { + const dbType = config.get('database.type') as DatabaseType; + + const map: { [key in DatabaseType]: string } = { + sqlite: "STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')", + postgresdb: 'CURRENT_TIMESTAMP(3)', + mysqldb: 'CURRENT_TIMESTAMP(3)', + mariadb: 'CURRENT_TIMESTAMP(3)', + }; + + return map[dbType]; +} @Entity() export class TagEntity implements ITagDb { diff --git a/packages/cli/src/databases/entities/WorkflowEntity.ts b/packages/cli/src/databases/entities/WorkflowEntity.ts index 88eb44d7ff..b07c4e6a06 100644 --- a/packages/cli/src/databases/entities/WorkflowEntity.ts +++ b/packages/cli/src/databases/entities/WorkflowEntity.ts @@ -17,12 +17,41 @@ import { UpdateDateColumn, } from 'typeorm'; -import { IWorkflowDb } from '../..'; - -import { getTimestampSyntax, resolveDataType } from '../utils'; - +import config = require('../../../config'); +import { DatabaseType, IWorkflowDb } from '../..'; import { TagEntity } from './TagEntity'; +function resolveDataType(dataType: string) { + const dbType = config.get('database.type') as DatabaseType; + + const typeMap: { [key in DatabaseType]: { [key: string]: string } } = { + sqlite: { + json: 'simple-json', + }, + postgresdb: { + datetime: 'timestamptz', + }, + mysqldb: {}, + mariadb: {}, + }; + + return typeMap[dbType][dataType] ?? dataType; +} + +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types +function getTimestampSyntax() { + const dbType = config.get('database.type') as DatabaseType; + + const map: { [key in DatabaseType]: string } = { + sqlite: "STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')", + postgresdb: 'CURRENT_TIMESTAMP(3)', + mysqldb: 'CURRENT_TIMESTAMP(3)', + mariadb: 'CURRENT_TIMESTAMP(3)', + }; + + return map[dbType]; +} + @Entity() export class WorkflowEntity implements IWorkflowDb { @PrimaryGeneratedColumn() diff --git a/packages/cli/src/databases/mysqldb/migrations/1630451444017-UpdateWorkflowCredentials.ts b/packages/cli/src/databases/mysqldb/migrations/1630451444017-UpdateWorkflowCredentials.ts new file mode 100644 index 0000000000..0012ee0aa1 --- /dev/null +++ b/packages/cli/src/databases/mysqldb/migrations/1630451444017-UpdateWorkflowCredentials.ts @@ -0,0 +1,215 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import config = require('../../../../config'); + +// replacing the credentials in workflows and execution +// `nodeType: name` changes to `nodeType: { id, name }` + +export class UpdateWorkflowCredentials1630451444017 implements MigrationInterface { + name = 'UpdateWorkflowCredentials1630451444017'; + + public async up(queryRunner: QueryRunner): Promise { + const tablePrefix = config.get('database.tablePrefix'); + + const credentialsEntities = await queryRunner.query(` + SELECT id, name, type + FROM ${tablePrefix}credentials_entity + `); + + const workflows = await queryRunner.query(` + SELECT id, nodes + FROM ${tablePrefix}workflow_entity + `); + // @ts-ignore + workflows.forEach(async (workflow) => { + const nodes = workflow.nodes; + let credentialsUpdated = false; + // @ts-ignore + nodes.forEach((node) => { + if (node.credentials) { + const allNodeCredentials = Object.entries(node.credentials); + for (const [type, name] of allNodeCredentials) { + if (typeof name === 'string') { + // @ts-ignore + const matchingCredentials = credentialsEntities.find( + // @ts-ignore + (credentials) => credentials.name === name && credentials.type === type, + ); + node.credentials[type] = { id: matchingCredentials?.id.toString() || null, name }; + credentialsUpdated = true; + } + } + } + }); + if (credentialsUpdated) { + const [updateQuery, updateParams] = queryRunner.connection.driver.escapeQueryWithParameters( + ` + UPDATE ${tablePrefix}workflow_entity + SET nodes = :nodes + WHERE id = '${workflow.id}' + `, + { nodes: JSON.stringify(nodes) }, + {}, + ); + + await queryRunner.query(updateQuery, updateParams); + } + }); + + const waitingExecutions = await queryRunner.query(` + SELECT id, workflowData + FROM ${tablePrefix}execution_entity + WHERE waitTill IS NOT NULL AND finished = 0 + `); + + const retryableExecutions = await queryRunner.query(` + SELECT id, workflowData + FROM ${tablePrefix}execution_entity + WHERE waitTill IS NULL AND finished = 0 AND mode != 'retry' + ORDER BY startedAt DESC + LIMIT 200 + `); + + [...waitingExecutions, ...retryableExecutions].forEach(async (execution) => { + const data = execution.workflowData; + let credentialsUpdated = false; + // @ts-ignore + data.nodes.forEach((node) => { + if (node.credentials) { + const allNodeCredentials = Object.entries(node.credentials); + for (const [type, name] of allNodeCredentials) { + if (typeof name === 'string') { + // @ts-ignore + const matchingCredentials = credentialsEntities.find( + // @ts-ignore + (credentials) => credentials.name === name && credentials.type === type, + ); + node.credentials[type] = { id: matchingCredentials?.id.toString() || null, name }; + credentialsUpdated = true; + } + } + } + }); + if (credentialsUpdated) { + const [updateQuery, updateParams] = queryRunner.connection.driver.escapeQueryWithParameters( + ` + UPDATE ${tablePrefix}execution_entity + SET workflowData = :data + WHERE id = '${execution.id}' + `, + { data: JSON.stringify(data) }, + {}, + ); + + await queryRunner.query(updateQuery, updateParams); + } + }); + } + + public async down(queryRunner: QueryRunner): Promise { + const tablePrefix = config.get('database.tablePrefix'); + + const credentialsEntities = await queryRunner.query(` + SELECT id, name, type + FROM ${tablePrefix}credentials_entity + `); + + const workflows = await queryRunner.query(` + SELECT id, nodes + FROM ${tablePrefix}workflow_entity + `); + // @ts-ignore + workflows.forEach(async (workflow) => { + const nodes = workflow.nodes; + let credentialsUpdated = false; + // @ts-ignore + nodes.forEach((node) => { + if (node.credentials) { + const allNodeCredentials = Object.entries(node.credentials); + for (const [type, creds] of allNodeCredentials) { + if (typeof creds === 'object') { + // @ts-ignore + const matchingCredentials = credentialsEntities.find( + // @ts-ignore + (credentials) => credentials.id === creds.id && credentials.type === type, + ); + if (matchingCredentials) { + node.credentials[type] = matchingCredentials.name; + } else { + // @ts-ignore + node.credentials[type] = creds.name; + } + credentialsUpdated = true; + } + } + } + }); + if (credentialsUpdated) { + const [updateQuery, updateParams] = queryRunner.connection.driver.escapeQueryWithParameters( + ` + UPDATE ${tablePrefix}workflow_entity + SET nodes = :nodes + WHERE id = '${workflow.id}' + `, + { nodes: JSON.stringify(nodes) }, + {}, + ); + + await queryRunner.query(updateQuery, updateParams); + } + }); + + const waitingExecutions = await queryRunner.query(` + SELECT id, workflowData + FROM ${tablePrefix}execution_entity + WHERE waitTill IS NOT NULL AND finished = 0 + `); + + const retryableExecutions = await queryRunner.query(` + SELECT id, workflowData + FROM ${tablePrefix}execution_entity + WHERE waitTill IS NULL AND finished = 0 AND mode != 'retry' + ORDER BY startedAt DESC + LIMIT 200 + `); + + [...waitingExecutions, ...retryableExecutions].forEach(async (execution) => { + const data = execution.workflowData; + let credentialsUpdated = false; + // @ts-ignore + data.nodes.forEach((node) => { + if (node.credentials) { + const allNodeCredentials = Object.entries(node.credentials); + for (const [type, creds] of allNodeCredentials) { + if (typeof creds === 'object') { + // @ts-ignore + const matchingCredentials = credentialsEntities.find( + // @ts-ignore + (credentials) => credentials.id === creds.id && credentials.type === type, + ); + if (matchingCredentials) { + node.credentials[type] = matchingCredentials.name; + } else { + // @ts-ignore + node.credentials[type] = creds.name; + } + credentialsUpdated = true; + } + } + } + }); + if (credentialsUpdated) { + const [updateQuery, updateParams] = queryRunner.connection.driver.escapeQueryWithParameters( + ` + UPDATE ${tablePrefix}execution_entity + SET workflowData = :data + WHERE id = '${execution.id}' + `, + { data: JSON.stringify(data) }, + {}, + ); + + await queryRunner.query(updateQuery, updateParams); + } + }); + } +} diff --git a/packages/cli/src/databases/mysqldb/migrations/index.ts b/packages/cli/src/databases/mysqldb/migrations/index.ts index b48bc58aff..0d3501eb3f 100644 --- a/packages/cli/src/databases/mysqldb/migrations/index.ts +++ b/packages/cli/src/databases/mysqldb/migrations/index.ts @@ -9,6 +9,7 @@ import { CreateTagEntity1617268711084 } from './1617268711084-CreateTagEntity'; import { UniqueWorkflowNames1620826335440 } from './1620826335440-UniqueWorkflowNames'; import { CertifyCorrectCollation1623936588000 } from './1623936588000-CertifyCorrectCollation'; import { AddWaitColumnId1626183952959 } from './1626183952959-AddWaitColumn'; +import { UpdateWorkflowCredentials1630451444017 } from './1630451444017-UpdateWorkflowCredentials'; export const mysqlMigrations = [ InitialMigration1588157391238, @@ -22,4 +23,5 @@ export const mysqlMigrations = [ UniqueWorkflowNames1620826335440, CertifyCorrectCollation1623936588000, AddWaitColumnId1626183952959, + UpdateWorkflowCredentials1630451444017, ]; diff --git a/packages/cli/src/databases/postgresdb/migrations/1630419189837-UpdateWorkflowCredentials.ts b/packages/cli/src/databases/postgresdb/migrations/1630419189837-UpdateWorkflowCredentials.ts new file mode 100644 index 0000000000..357d7c2974 --- /dev/null +++ b/packages/cli/src/databases/postgresdb/migrations/1630419189837-UpdateWorkflowCredentials.ts @@ -0,0 +1,223 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import config = require('../../../../config'); + +// replacing the credentials in workflows and execution +// `nodeType: name` changes to `nodeType: { id, name }` + +export class UpdateWorkflowCredentials1630419189837 implements MigrationInterface { + name = 'UpdateWorkflowCredentials1630419189837'; + + public async up(queryRunner: QueryRunner): Promise { + let tablePrefix = config.get('database.tablePrefix'); + const schema = config.get('database.postgresdb.schema'); + if (schema) { + tablePrefix = schema + '.' + tablePrefix; + } + + const credentialsEntities = await queryRunner.query(` + SELECT id, name, type + FROM ${tablePrefix}credentials_entity + `); + + const workflows = await queryRunner.query(` + SELECT id, nodes + FROM ${tablePrefix}workflow_entity + `); + // @ts-ignore + workflows.forEach(async (workflow) => { + const nodes = workflow.nodes; + let credentialsUpdated = false; + // @ts-ignore + nodes.forEach((node) => { + if (node.credentials) { + const allNodeCredentials = Object.entries(node.credentials); + for (const [type, name] of allNodeCredentials) { + if (typeof name === 'string') { + // @ts-ignore + const matchingCredentials = credentialsEntities.find( + // @ts-ignore + (credentials) => credentials.name === name && credentials.type === type, + ); + node.credentials[type] = { id: matchingCredentials?.id.toString() || null, name }; + credentialsUpdated = true; + } + } + } + }); + if (credentialsUpdated) { + const [updateQuery, updateParams] = queryRunner.connection.driver.escapeQueryWithParameters( + ` + UPDATE ${tablePrefix}workflow_entity + SET nodes = :nodes + WHERE id = '${workflow.id}' + `, + { nodes: JSON.stringify(nodes) }, + {}, + ); + + await queryRunner.query(updateQuery, updateParams); + } + }); + + const waitingExecutions = await queryRunner.query(` + SELECT id, "workflowData" + FROM ${tablePrefix}execution_entity + WHERE "waitTill" IS NOT NULL AND finished = FALSE + `); + + const retryableExecutions = await queryRunner.query(` + SELECT id, "workflowData" + FROM ${tablePrefix}execution_entity + WHERE "waitTill" IS NULL AND finished = FALSE AND mode != 'retry' + ORDER BY "startedAt" DESC + LIMIT 200 + `); + + [...waitingExecutions, ...retryableExecutions].forEach(async (execution) => { + const data = execution.workflowData; + let credentialsUpdated = false; + // @ts-ignore + data.nodes.forEach((node) => { + if (node.credentials) { + const allNodeCredentials = Object.entries(node.credentials); + for (const [type, name] of allNodeCredentials) { + if (typeof name === 'string') { + // @ts-ignore + const matchingCredentials = credentialsEntities.find( + // @ts-ignore + (credentials) => credentials.name === name && credentials.type === type, + ); + node.credentials[type] = { id: matchingCredentials?.id.toString() || null, name }; + credentialsUpdated = true; + } + } + } + }); + if (credentialsUpdated) { + const [updateQuery, updateParams] = queryRunner.connection.driver.escapeQueryWithParameters( + ` + UPDATE ${tablePrefix}execution_entity + SET "workflowData" = :data + WHERE id = '${execution.id}' + `, + { data: JSON.stringify(data) }, + {}, + ); + + await queryRunner.query(updateQuery, updateParams); + } + }); + } + + public async down(queryRunner: QueryRunner): Promise { + let tablePrefix = config.get('database.tablePrefix'); + const schema = config.get('database.postgresdb.schema'); + if (schema) { + tablePrefix = schema + '.' + tablePrefix; + } + + const credentialsEntities = await queryRunner.query(` + SELECT id, name, type + FROM ${tablePrefix}credentials_entity + `); + + const workflows = await queryRunner.query(` + SELECT id, nodes + FROM ${tablePrefix}workflow_entity + `); + // @ts-ignore + workflows.forEach(async (workflow) => { + const nodes = workflow.nodes; + let credentialsUpdated = false; + // @ts-ignore + nodes.forEach((node) => { + if (node.credentials) { + const allNodeCredentials = Object.entries(node.credentials); + for (const [type, creds] of allNodeCredentials) { + if (typeof creds === 'object') { + // @ts-ignore + const matchingCredentials = credentialsEntities.find( + // @ts-ignore + (credentials) => credentials.id === creds.id && credentials.type === type, + ); + if (matchingCredentials) { + node.credentials[type] = matchingCredentials.name; + } else { + // @ts-ignore + node.credentials[type] = creds.name; + } + credentialsUpdated = true; + } + } + } + }); + if (credentialsUpdated) { + const [updateQuery, updateParams] = queryRunner.connection.driver.escapeQueryWithParameters( + ` + UPDATE ${tablePrefix}workflow_entity + SET nodes = :nodes + WHERE id = '${workflow.id}' + `, + { nodes: JSON.stringify(nodes) }, + {}, + ); + + await queryRunner.query(updateQuery, updateParams); + } + }); + + const waitingExecutions = await queryRunner.query(` + SELECT id, "workflowData" + FROM ${tablePrefix}execution_entity + WHERE "waitTill" IS NOT NULL AND finished = FALSE + `); + + const retryableExecutions = await queryRunner.query(` + SELECT id, "workflowData" + FROM ${tablePrefix}execution_entity + WHERE "waitTill" IS NULL AND finished = FALSE AND mode != 'retry' + ORDER BY "startedAt" DESC + LIMIT 200 + `); + + [...waitingExecutions, ...retryableExecutions].forEach(async (execution) => { + const data = execution.workflowData; + let credentialsUpdated = false; + // @ts-ignore + data.nodes.forEach((node) => { + if (node.credentials) { + const allNodeCredentials = Object.entries(node.credentials); + for (const [type, creds] of allNodeCredentials) { + if (typeof creds === 'object') { + // @ts-ignore + const matchingCredentials = credentialsEntities.find( + // @ts-ignore + (credentials) => credentials.id === creds.id && credentials.type === type, + ); + if (matchingCredentials) { + node.credentials[type] = matchingCredentials.name; + } else { + // @ts-ignore + node.credentials[type] = creds.name; + } + credentialsUpdated = true; + } + } + } + }); + if (credentialsUpdated) { + const [updateQuery, updateParams] = queryRunner.connection.driver.escapeQueryWithParameters( + ` + UPDATE ${tablePrefix}execution_entity + SET "workflowData" = :data + WHERE id = '${execution.id}' + `, + { data: JSON.stringify(data) }, + {}, + ); + + await queryRunner.query(updateQuery, updateParams); + } + }); + } +} diff --git a/packages/cli/src/databases/postgresdb/migrations/index.ts b/packages/cli/src/databases/postgresdb/migrations/index.ts index 83983dd039..f885c97851 100644 --- a/packages/cli/src/databases/postgresdb/migrations/index.ts +++ b/packages/cli/src/databases/postgresdb/migrations/index.ts @@ -6,6 +6,7 @@ import { MakeStoppedAtNullable1607431743768 } from './1607431743768-MakeStoppedA import { CreateTagEntity1617270242566 } from './1617270242566-CreateTagEntity'; import { UniqueWorkflowNames1620824779533 } from './1620824779533-UniqueWorkflowNames'; import { AddwaitTill1626176912946 } from './1626176912946-AddwaitTill'; +import { UpdateWorkflowCredentials1630419189837 } from './1630419189837-UpdateWorkflowCredentials'; export const postgresMigrations = [ InitialMigration1587669153312, @@ -16,4 +17,5 @@ export const postgresMigrations = [ CreateTagEntity1617270242566, UniqueWorkflowNames1620824779533, AddwaitTill1626176912946, + UpdateWorkflowCredentials1630419189837, ]; diff --git a/packages/cli/src/databases/sqlite/migrations/1630330987096-UpdateWorkflowCredentials.ts b/packages/cli/src/databases/sqlite/migrations/1630330987096-UpdateWorkflowCredentials.ts new file mode 100644 index 0000000000..f2a6f0a19a --- /dev/null +++ b/packages/cli/src/databases/sqlite/migrations/1630330987096-UpdateWorkflowCredentials.ts @@ -0,0 +1,215 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import config = require('../../../../config'); + +// replacing the credentials in workflows and execution +// `nodeType: name` changes to `nodeType: { id, name }` + +export class UpdateWorkflowCredentials1630330987096 implements MigrationInterface { + name = 'UpdateWorkflowCredentials1630330987096'; + + public async up(queryRunner: QueryRunner): Promise { + const tablePrefix = config.get('database.tablePrefix'); + + const credentialsEntities = await queryRunner.query(` + SELECT id, name, type + FROM "${tablePrefix}credentials_entity" + `); + + const workflows = await queryRunner.query(` + SELECT id, nodes + FROM "${tablePrefix}workflow_entity" + `); + // @ts-ignore + workflows.forEach(async (workflow) => { + const nodes = JSON.parse(workflow.nodes); + let credentialsUpdated = false; + // @ts-ignore + nodes.forEach((node) => { + if (node.credentials) { + const allNodeCredentials = Object.entries(node.credentials); + for (const [type, name] of allNodeCredentials) { + if (typeof name === 'string') { + // @ts-ignore + const matchingCredentials = credentialsEntities.find( + // @ts-ignore + (credentials) => credentials.name === name && credentials.type === type, + ); + node.credentials[type] = { id: matchingCredentials?.id || null, name }; + credentialsUpdated = true; + } + } + } + }); + if (credentialsUpdated) { + const [updateQuery, updateParams] = queryRunner.connection.driver.escapeQueryWithParameters( + ` + UPDATE "${tablePrefix}workflow_entity" + SET nodes = :nodes + WHERE id = '${workflow.id}' + `, + { nodes: JSON.stringify(nodes) }, + {}, + ); + + await queryRunner.query(updateQuery, updateParams); + } + }); + + const waitingExecutions = await queryRunner.query(` + SELECT id, "workflowData" + FROM "${tablePrefix}execution_entity" + WHERE "waitTill" IS NOT NULL AND finished = 0 + `); + + const retryableExecutions = await queryRunner.query(` + SELECT id, "workflowData" + FROM "${tablePrefix}execution_entity" + WHERE "waitTill" IS NULL AND finished = 0 AND mode != 'retry' + ORDER BY "startedAt" DESC + LIMIT 200 + `); + + [...waitingExecutions, ...retryableExecutions].forEach(async (execution) => { + const data = JSON.parse(execution.workflowData); + let credentialsUpdated = false; + // @ts-ignore + data.nodes.forEach((node) => { + if (node.credentials) { + const allNodeCredentials = Object.entries(node.credentials); + for (const [type, name] of allNodeCredentials) { + if (typeof name === 'string') { + // @ts-ignore + const matchingCredentials = credentialsEntities.find( + // @ts-ignore + (credentials) => credentials.name === name && credentials.type === type, + ); + node.credentials[type] = { id: matchingCredentials?.id || null, name }; + credentialsUpdated = true; + } + } + } + }); + if (credentialsUpdated) { + const [updateQuery, updateParams] = queryRunner.connection.driver.escapeQueryWithParameters( + ` + UPDATE "${tablePrefix}execution_entity" + SET "workflowData" = :data + WHERE id = '${execution.id}' + `, + { data: JSON.stringify(data) }, + {}, + ); + + await queryRunner.query(updateQuery, updateParams); + } + }); + } + + public async down(queryRunner: QueryRunner): Promise { + const tablePrefix = config.get('database.tablePrefix'); + + const credentialsEntities = await queryRunner.query(` + SELECT id, name, type + FROM "${tablePrefix}credentials_entity" + `); + + const workflows = await queryRunner.query(` + SELECT id, nodes + FROM "${tablePrefix}workflow_entity" + `); + // @ts-ignore + workflows.forEach(async (workflow) => { + const nodes = JSON.parse(workflow.nodes); + let credentialsUpdated = false; + // @ts-ignore + nodes.forEach((node) => { + if (node.credentials) { + const allNodeCredentials = Object.entries(node.credentials); + for (const [type, creds] of allNodeCredentials) { + if (typeof creds === 'object') { + // @ts-ignore + const matchingCredentials = credentialsEntities.find( + // @ts-ignore + (credentials) => credentials.id === creds.id && credentials.type === type, + ); + if (matchingCredentials) { + node.credentials[type] = matchingCredentials.name; + } else { + // @ts-ignore + node.credentials[type] = creds.name; + } + credentialsUpdated = true; + } + } + } + }); + if (credentialsUpdated) { + const [updateQuery, updateParams] = queryRunner.connection.driver.escapeQueryWithParameters( + ` + UPDATE "${tablePrefix}workflow_entity" + SET nodes = :nodes + WHERE id = '${workflow.id}' + `, + { nodes: JSON.stringify(nodes) }, + {}, + ); + + await queryRunner.query(updateQuery, updateParams); + } + }); + + const waitingExecutions = await queryRunner.query(` + SELECT id, "workflowData" + FROM "${tablePrefix}execution_entity" + WHERE "waitTill" IS NOT NULL AND finished = 0 + `); + + const retryableExecutions = await queryRunner.query(` + SELECT id, "workflowData" + FROM "${tablePrefix}execution_entity" + WHERE "waitTill" IS NULL AND finished = 0 AND mode != 'retry' + ORDER BY "startedAt" DESC + LIMIT 200 + `); + + [...waitingExecutions, ...retryableExecutions].forEach(async (execution) => { + const data = JSON.parse(execution.workflowData); + let credentialsUpdated = false; + // @ts-ignore + data.nodes.forEach((node) => { + if (node.credentials) { + const allNodeCredentials = Object.entries(node.credentials); + for (const [type, creds] of allNodeCredentials) { + if (typeof creds === 'object') { + // @ts-ignore + const matchingCredentials = credentialsEntities.find( + // @ts-ignore + (credentials) => credentials.id === creds.id && credentials.type === type, + ); + if (matchingCredentials) { + node.credentials[type] = matchingCredentials.name; + } else { + // @ts-ignore + node.credentials[type] = creds.name; + } + credentialsUpdated = true; + } + } + } + }); + if (credentialsUpdated) { + const [updateQuery, updateParams] = queryRunner.connection.driver.escapeQueryWithParameters( + ` + UPDATE "${tablePrefix}execution_entity" + SET "workflowData" = :data + WHERE id = '${execution.id}' + `, + { data: JSON.stringify(data) }, + {}, + ); + + await queryRunner.query(updateQuery, updateParams); + } + }); + } +} diff --git a/packages/cli/src/databases/sqlite/migrations/index.ts b/packages/cli/src/databases/sqlite/migrations/index.ts index 64038d9e30..0e1907d2cc 100644 --- a/packages/cli/src/databases/sqlite/migrations/index.ts +++ b/packages/cli/src/databases/sqlite/migrations/index.ts @@ -6,6 +6,7 @@ import { MakeStoppedAtNullable1607431743769 } from './1607431743769-MakeStoppedA import { CreateTagEntity1617213344594 } from './1617213344594-CreateTagEntity'; import { UniqueWorkflowNames1620821879465 } from './1620821879465-UniqueWorkflowNames'; import { AddWaitColumn1621707690587 } from './1621707690587-AddWaitColumn'; +import { UpdateWorkflowCredentials1630330987096 } from './1630330987096-UpdateWorkflowCredentials'; export const sqliteMigrations = [ InitialMigration1588102412422, @@ -16,4 +17,5 @@ export const sqliteMigrations = [ CreateTagEntity1617213344594, UniqueWorkflowNames1620821879465, AddWaitColumn1621707690587, + UpdateWorkflowCredentials1630330987096, ]; diff --git a/packages/cli/src/databases/utils.ts b/packages/cli/src/databases/utils.ts deleted file mode 100644 index 3a0e9b08c3..0000000000 --- a/packages/cli/src/databases/utils.ts +++ /dev/null @@ -1,42 +0,0 @@ -/* eslint-disable import/no-cycle */ -import { DatabaseType } from '../index'; -import { getConfigValueSync } from '../GenericHelpers'; - -/** - * Resolves the data type for the used database type - * - * @export - * @param {string} dataType - * @returns {string} - */ -// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types -export function resolveDataType(dataType: string) { - const dbType = getConfigValueSync('database.type') as DatabaseType; - - const typeMap: { [key in DatabaseType]: { [key: string]: string } } = { - sqlite: { - json: 'simple-json', - }, - postgresdb: { - datetime: 'timestamptz', - }, - mysqldb: {}, - mariadb: {}, - }; - - return typeMap[dbType][dataType] ?? dataType; -} - -// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types -export function getTimestampSyntax() { - const dbType = getConfigValueSync('database.type') as DatabaseType; - - const map: { [key in DatabaseType]: string } = { - sqlite: "STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')", - postgresdb: 'CURRENT_TIMESTAMP(3)', - mysqldb: 'CURRENT_TIMESTAMP(3)', - mariadb: 'CURRENT_TIMESTAMP(3)', - }; - - return map[dbType]; -} diff --git a/packages/core/src/Credentials.ts b/packages/core/src/Credentials.ts index 2ba8b10634..293a35a8c7 100644 --- a/packages/core/src/Credentials.ts +++ b/packages/core/src/Credentials.ts @@ -98,6 +98,7 @@ export class Credentials extends ICredentials { } return { + id: this.id, name: this.name, type: this.type, data: this.data, diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index 9e7f1bb747..54be53f1e7 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -738,16 +738,20 @@ export async function requestOAuth2( credentials.oauthTokenData = newToken.data; - // Find the name of the credentials + // Find the credentials if (!node.credentials || !node.credentials[credentialsType]) { throw new Error( `The node "${node.name}" does not have credentials of type "${credentialsType}"!`, ); } - const name = node.credentials[credentialsType]; + const nodeCredentials = node.credentials[credentialsType]; // Save the refreshed token - await additionalData.credentialsHelper.updateCredentials(name, credentialsType, credentials); + await additionalData.credentialsHelper.updateCredentials( + nodeCredentials, + credentialsType, + credentials, + ); Logger.debug( `OAuth2 token for "${credentialsType}" used by node "${node.name}" has been saved to database successfully.`, @@ -955,25 +959,26 @@ export async function getCredentials( } as ICredentialsExpressionResolveValues; } - let name = node.credentials[type]; + const nodeCredentials = node.credentials[type]; - if (name.charAt(0) === '=') { - // If the credential name is an expression resolve it - const additionalKeys = getAdditionalKeys(additionalData); - name = workflow.expression.getParameterValue( - name, - runExecutionData || null, - runIndex || 0, - itemIndex || 0, - node.name, - connectionInputData || [], - mode, - additionalKeys, - ) as string; - } + // TODO: solve using credentials via expression + // if (name.charAt(0) === '=') { + // // If the credential name is an expression resolve it + // const additionalKeys = getAdditionalKeys(additionalData); + // name = workflow.expression.getParameterValue( + // name, + // runExecutionData || null, + // runIndex || 0, + // itemIndex || 0, + // node.name, + // connectionInputData || [], + // mode, + // additionalKeys, + // ) as string; + // } const decryptedDataObject = await additionalData.credentialsHelper.getDecrypted( - name, + nodeCredentials, type, mode, false, diff --git a/packages/core/test/Credentials.test.ts b/packages/core/test/Credentials.test.ts index c5fe7f57c8..860c842341 100644 --- a/packages/core/test/Credentials.test.ts +++ b/packages/core/test/Credentials.test.ts @@ -3,7 +3,7 @@ import { Credentials } from '../src'; describe('Credentials', () => { describe('without nodeType set', () => { test('should be able to set and read key data without initial data set', () => { - const credentials = new Credentials('testName', 'testType', []); + const credentials = new Credentials({ id: null, name: 'testName' }, 'testType', []); const key = 'key1'; const password = 'password'; @@ -23,7 +23,12 @@ describe('Credentials', () => { const initialData = 4321; const initialDataEncoded = 'U2FsdGVkX1+0baznXt+Ag/ub8A2kHLyoLxn/rR9h4XQ='; - const credentials = new Credentials('testName', 'testType', [], initialDataEncoded); + const credentials = new Credentials( + { id: null, name: 'testName' }, + 'testType', + [], + initialDataEncoded, + ); const newData = 1234; @@ -46,7 +51,7 @@ describe('Credentials', () => { }, ]; - const credentials = new Credentials('testName', 'testType', nodeAccess); + const credentials = new Credentials({ id: null, name: 'testName' }, 'testType', nodeAccess); const key = 'key1'; const password = 'password'; diff --git a/packages/core/test/Helpers.ts b/packages/core/test/Helpers.ts index 74305631d1..387ac67a85 100644 --- a/packages/core/test/Helpers.ts +++ b/packages/core/test/Helpers.ts @@ -5,6 +5,7 @@ import { ICredentialsHelper, IDataObject, IExecuteWorkflowInfo, + INodeCredentialsDetails, INodeExecutionData, INodeParameters, INodeType, @@ -22,18 +23,21 @@ import { import { Credentials, IDeferredPromise, IExecuteFunctions } from '../src'; export class CredentialsHelper extends ICredentialsHelper { - getDecrypted(name: string, type: string): Promise { + getDecrypted( + nodeCredentials: INodeCredentialsDetails, + type: string, + ): Promise { return new Promise((res) => res({})); } - getCredentials(name: string, type: string): Promise { + getCredentials(nodeCredentials: INodeCredentialsDetails, type: string): Promise { return new Promise((res) => { - res(new Credentials('', '', [], '')); + res(new Credentials({ id: null, name: '' }, '', [], '')); }); } async updateCredentials( - name: string, + nodeCredentials: INodeCredentialsDetails, type: string, data: ICredentialDataDecryptedObject, ): Promise {} diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index 8181647da4..70ccf59ee6 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -249,7 +249,7 @@ export interface IActivationError { } export interface ICredentialsResponse extends ICredentialsEncrypted { - id?: string; + id: string; createdAt: number | string; updatedAt: number | string; } diff --git a/packages/editor-ui/src/components/CredentialEdit/CredentialEdit.vue b/packages/editor-ui/src/components/CredentialEdit/CredentialEdit.vue index 9532ad4073..105507699f 100644 --- a/packages/editor-ui/src/components/CredentialEdit/CredentialEdit.vue +++ b/packages/editor-ui/src/components/CredentialEdit/CredentialEdit.vue @@ -557,6 +557,7 @@ export default mixins(showMessage, nodeHelpers).extend({ ); const details: ICredentialsDecrypted = { + id: this.credentialId, name: this.credentialName, type: this.credentialTypeName!, data: data as unknown as ICredentialDataDecryptedObject, @@ -605,6 +606,7 @@ export default mixins(showMessage, nodeHelpers).extend({ ); const credentialDetails: ICredentialsDecrypted = { + id: this.credentialId, name: this.credentialName, type: this.credentialTypeName!, data: data as unknown as ICredentialDataDecryptedObject, diff --git a/packages/editor-ui/src/components/NodeCredentials.vue b/packages/editor-ui/src/components/NodeCredentials.vue index fb6c0b651e..ea81a4c252 100644 --- a/packages/editor-ui/src/components/NodeCredentials.vue +++ b/packages/editor-ui/src/components/NodeCredentials.vue @@ -9,15 +9,15 @@ {{credentialTypeNames[credentialTypeDescription.name]}}: - +
- + + :value="item.id">
-
- - + + + + + + @@ -49,12 +52,14 @@ + + diff --git a/packages/design-system/src/components/N8nHeading/index.js b/packages/design-system/src/components/N8nHeading/index.js new file mode 100644 index 0000000000..32d93055a6 --- /dev/null +++ b/packages/design-system/src/components/N8nHeading/index.js @@ -0,0 +1,3 @@ +import N8nHeading from './Heading.vue'; + +export default N8nHeading; diff --git a/packages/design-system/src/components/N8nIconButton/IconButton.vue b/packages/design-system/src/components/N8nIconButton/IconButton.vue index 0ace9defb8..6064ca4a53 100644 --- a/packages/design-system/src/components/N8nIconButton/IconButton.vue +++ b/packages/design-system/src/components/N8nIconButton/IconButton.vue @@ -1,5 +1,5 @@ diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index 70ccf59ee6..02fe67ec4f 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -18,6 +18,7 @@ import { IRun, IRunData, ITaskData, + ITelemetrySettings, WorkflowExecuteMode, } from 'n8n-workflow'; @@ -129,7 +130,6 @@ export interface IRestApi { getPastExecutions(filter: object, limit: number, lastId?: string | number, firstId?: string | number): Promise; stopCurrentExecution(executionId: string): Promise; makeRestApiRequest(method: string, endpoint: string, data?: any): Promise; // tslint:disable-line:no-any - getSettings(): Promise; getNodeTypes(onlyLatest?: boolean): Promise; getNodesInformation(nodeInfos: INodeTypeNameVersion[]): Promise; getNodeParameterOptions(nodeTypeAndVersion: INodeTypeNameVersion, path: string, methodName: string, currentNodeParameters: INodeParameters, credentials?: INodeCredentials): Promise; @@ -437,6 +437,17 @@ export interface IVersionNotificationSettings { infoUrl: string; } +export type IPersonalizationSurveyKeys = 'companySize' | 'codingSkill' | 'workArea' | 'otherWorkArea'; + +export type IPersonalizationSurveyAnswers = { + [key in IPersonalizationSurveyKeys]: string | null +}; + +export interface IPersonalizationSurvey { + answers?: IPersonalizationSurveyAnswers; + shouldShow: boolean; +} + export interface IN8nUISettings { endpointWebhook: string; endpointWebhookTest: string; @@ -457,6 +468,8 @@ export interface IN8nUISettings { }; versionNotifications: IVersionNotificationSettings; instanceId: string; + personalizationSurvey?: IPersonalizationSurvey; + telemetry: ITelemetrySettings; } export interface IWorkflowSettings extends IWorkflowSettingsWorkflow { @@ -599,6 +612,7 @@ export interface IRootState { workflow: IWorkflowDb; sidebarMenuItems: IMenuItem[]; instanceId: string; + telemetry: ITelemetrySettings | null; } export interface ICredentialTypeMap { @@ -636,6 +650,10 @@ export interface IUiState { isPageLoading: boolean; } +export interface ISettingsState { + settings: IN8nUISettings; +} + export interface IVersionsState { versionNotificationSettings: IVersionNotificationSettings; nextVersions: IVersion[]; diff --git a/packages/editor-ui/src/api/settings.ts b/packages/editor-ui/src/api/settings.ts new file mode 100644 index 0000000000..44302f8168 --- /dev/null +++ b/packages/editor-ui/src/api/settings.ts @@ -0,0 +1,12 @@ +import { IDataObject } from 'n8n-workflow'; +import { IRestApiContext, IN8nUISettings, IPersonalizationSurveyAnswers } from '../Interface'; +import { makeRestApiRequest } from './helpers'; + +export async function getSettings(context: IRestApiContext): Promise { + return await makeRestApiRequest(context, 'GET', '/settings'); +} + +export async function submitPersonalizationSurvey(context: IRestApiContext, params: IPersonalizationSurveyAnswers): Promise { + await makeRestApiRequest(context, 'POST', '/user-survey', params as unknown as IDataObject); +} + diff --git a/packages/editor-ui/src/components/CredentialEdit/CredentialConfig.vue b/packages/editor-ui/src/components/CredentialEdit/CredentialConfig.vue index 3ad1712676..25b002cc88 100644 --- a/packages/editor-ui/src/components/CredentialEdit/CredentialConfig.vue +++ b/packages/editor-ui/src/components/CredentialEdit/CredentialConfig.vue @@ -40,7 +40,7 @@ Need help filling out these fields? - Open docs + Open docs -
- -
- Your saved credentials: -
- + + + + diff --git a/packages/editor-ui/src/components/Modals.vue b/packages/editor-ui/src/components/Modals.vue index 12de097bb8..b7819e3b6c 100644 --- a/packages/editor-ui/src/components/Modals.vue +++ b/packages/editor-ui/src/components/Modals.vue @@ -1,45 +1,5 @@