diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 568f8fc9a7..fcc046beef 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,7 +38,7 @@ The most important directories: execution, active webhooks and workflows - [/packages/editor-ui](/packages/editor-ui) - Vue frontend workflow editor - - [/packages/node-dev](/packages/node-dev) - Simple CLI to create new n8n-nodes + - [/packages/node-dev](/packages/node-dev) - CLI to create new n8n-nodes - [/packages/nodes-base](/packages/nodes-base) - Base n8n nodes - [/packages/workflow](/packages/workflow) - Workflow code with interfaces which get used by front- & backend @@ -159,7 +159,7 @@ tests of all packages. ## Create Custom Nodes -It is very easy to create own nodes for n8n. More information about that can +It is very straightforward to create your own nodes for n8n. More information about that can be found in the documentation of "n8n-node-dev" which is a small CLI which helps with n8n-node-development. @@ -177,9 +177,9 @@ If you want to create a node which should be added to n8n follow these steps: 1. Create a new folder for the new node. For a service named "Example" the folder would be called: `/packages/nodes-base/nodes/Example` - 1. If there is already a similar node simply copy the existing one in the new folder and rename it. If none exists yet, create a boilerplate node with [n8n-node-dev](https://github.com/n8n-io/n8n/tree/master/packages/node-dev) and copy that one in the folder. + 1. If there is already a similar node, copy the existing one in the new folder and rename it. If none exists yet, create a boilerplate node with [n8n-node-dev](https://github.com/n8n-io/n8n/tree/master/packages/node-dev) and copy that one in the folder. - 1. If the node needs credentials because it has to authenticate with an API or similar create new ones. Existing ones can be found in folder `/packages/nodes-base/credentials`. Also there it is the easiest to simply copy existing similar ones. + 1. If the node needs credentials because it has to authenticate with an API or similar create new ones. Existing ones can be found in folder `/packages/nodes-base/credentials`. Also there it is the easiest to copy existing similar ones. 1. Add the path to the new node (and optionally credentials) to package.json of `nodes-base`. It already contains a property `n8n` with its own keys `credentials` and `nodes`. @@ -236,6 +236,6 @@ docsify serve ./docs That we do not have any potential problems later it is sadly necessary to sign a [Contributor License Agreement](CONTRIBUTOR_LICENSE_AGREEMENT.md). That can be done literally with the push of a button. -We used the most simple one that exists. It is from [Indie Open Source](https://indieopensource.com/forms/cla) which uses plain English and is literally just a few lines long. +We used the most simple one that exists. It is from [Indie Open Source](https://indieopensource.com/forms/cla) which uses plain English and is literally only a few lines long. A bot will automatically comment on the pull request once it got opened asking for the agreement to be signed. Before it did not get signed it is sadly not possible to merge it in. diff --git a/LICENSE.md b/LICENSE.md index aac54547eb..24a7d38fc9 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -215,7 +215,7 @@ Licensor: n8n GmbH same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2020 n8n GmbH Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/docs/configuration.md b/docs/configuration.md index ce57ef92df..63b8c95f12 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -14,6 +14,9 @@ Sets how n8n should be made available. # The port n8n should be made available on N8N_PORT=5678 +# The IP address n8n should listen on +N8N_LISTEN_ADDRESS=0.0.0.0 + # This ones are currently only important for the webhook URL creation. # So if "WEBHOOK_TUNNEL_URL" got set they do get ignored. It is however # encouraged to set them correctly anyway in case they will become diff --git a/docs/server-setup.md b/docs/server-setup.md index f2c830c48f..d34d076a6f 100644 --- a/docs/server-setup.md +++ b/docs/server-setup.md @@ -105,6 +105,7 @@ services: - N8N_BASIC_AUTH_PASSWORD - N8N_HOST=${SUBDOMAIN}.${DOMAIN_NAME} - N8N_PORT=5678 + - N8N_LISTEN_ADDRESS=0.0.0.0 - N8N_PROTOCOL=https - NODE_ENV=production - WEBHOOK_TUNNEL_URL=https://${SUBDOMAIN}.${DOMAIN_NAME}/ diff --git a/packages/cli/BREAKING-CHANGES.md b/packages/cli/BREAKING-CHANGES.md index abd00e9dac..68be5ce947 100644 --- a/packages/cli/BREAKING-CHANGES.md +++ b/packages/cli/BREAKING-CHANGES.md @@ -2,6 +2,50 @@ This list shows all the versions which include breaking changes and how to upgrade. + +## 0.69.0 + +### What changed? + +We have simplified how attachments are handled by the Twitter node. Rather than clicking on `Add Attachments` and having to specify the `Catergory`, you can now add attachments by just clicking on `Add Field` and selecting `Attachments`. There's no longer an option to specify the type of attachment you are adding. + +### When is action necessary? + +If you have used the Attachments option in your Twitter nodes. + +### How to upgrade: + +You'll need to re-create the attachments for the Twitter node. + + +## 0.68.0 + +### What changed? + +To make it easier to use the data which the Slack-Node outputs we no longer return the whole +object the Slack-API returns if the only other property is `"ok": true`. In this case it returns +now directly the data under "channel". + +### When is action necessary? + +When you currently use the Slack-Node with Operations Channel -> Create and you use +any of the data the node outputs. + +### How to upgrade: + +All values that get referenced which were before under the property "channel" are now on the main level. +This means that these expressions have to get adjusted. + +Meaning if the expression used before was: +``` +{{ $node["Slack"].data["channel"]["id"] }} +``` +it has to get changed to: +``` +{{ $node["Slack"].data["id"] }} +``` + + ## 0.67.0 ### What changed? diff --git a/packages/cli/LICENSE.md b/packages/cli/LICENSE.md index aac54547eb..24a7d38fc9 100644 --- a/packages/cli/LICENSE.md +++ b/packages/cli/LICENSE.md @@ -215,7 +215,7 @@ Licensor: n8n GmbH same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2020 n8n GmbH Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/packages/cli/commands/execute.ts b/packages/cli/commands/execute.ts index 7501b4aa9b..3eb5956e9d 100644 --- a/packages/cli/commands/execute.ts +++ b/packages/cli/commands/execute.ts @@ -9,7 +9,9 @@ import { import { ActiveExecutions, + CredentialsOverwrites, Db, + ExternalHooks, GenericHelpers, IWorkflowBase, IWorkflowExecutionDataProcess, @@ -103,6 +105,14 @@ export class Execute extends Command { // Wait till the n8n-packages have been read await loadNodesAndCredentialsPromise; + // Load the credentials overwrites if any exist + const credentialsOverwrites = CredentialsOverwrites(); + await credentialsOverwrites.init(); + + // Load all external hooks + const externalHooks = ExternalHooks(); + await externalHooks.init(); + // Add the found types to an instance other parts of the application can use const nodeTypes = NodeTypes(); await nodeTypes.init(loadNodesAndCredentials.nodeTypes); diff --git a/packages/cli/commands/start.ts b/packages/cli/commands/start.ts index 4e2efaacfd..1b76459de7 100644 --- a/packages/cli/commands/start.ts +++ b/packages/cli/commands/start.ts @@ -5,13 +5,14 @@ import { } from 'n8n-core'; import { Command, flags } from '@oclif/command'; const open = require('open'); -// import { dirname } from 'path'; import * as config from '../config'; import { ActiveWorkflowRunner, CredentialTypes, + CredentialsOverwrites, Db, + ExternalHooks, GenericHelpers, LoadNodesAndCredentials, NodeTypes, @@ -108,6 +109,14 @@ export class Start extends Command { const loadNodesAndCredentials = LoadNodesAndCredentials(); await loadNodesAndCredentials.init(); + // Load the credentials overwrites if any exist + const credentialsOverwrites = CredentialsOverwrites(); + await credentialsOverwrites.init(); + + // Load all external hooks + const externalHooks = ExternalHooks(); + await externalHooks.init(); + // Add the found types to an instance other parts of the application can use const nodeTypes = NodeTypes(); await nodeTypes.init(loadNodesAndCredentials.nodeTypes); diff --git a/packages/cli/config/index.ts b/packages/cli/config/index.ts index 69b3154064..ce79ca5221 100644 --- a/packages/cli/config/index.ts +++ b/packages/cli/config/index.ts @@ -98,6 +98,19 @@ const config = convict({ }, }, + credentials: { + overwrite: { + // Allows to set default values for credentials which + // get automatically prefilled and the user does not get + // displayed and can not change. + // Format: { CREDENTIAL_NAME: { PARAMTER: VALUE }} + doc: 'Overwrites for credentials', + format: '*', + default: '{}', + env: 'CREDENTIALS_OVERWRITE' + } + }, + executions: { // By default workflows get always executed in their own process. @@ -169,6 +182,12 @@ const config = convict({ env: 'N8N_PORT', doc: 'HTTP port n8n can be reached' }, + listen_address: { + format: String, + default: '0.0.0.0', + env: 'N8N_LISTEN_ADDRESS', + doc: 'IP address n8n should listen on' + }, protocol: { format: ['http', 'https'], default: 'http', @@ -252,6 +271,13 @@ const config = convict({ }, }, + externalHookFiles: { + doc: 'Files containing external hooks. Multiple files can be separated by colon (":")', + format: String, + default: '', + env: 'EXTERNAL_HOOK_FILES' + }, + nodes: { exclude: { doc: 'Nodes not to load', diff --git a/packages/cli/package.json b/packages/cli/package.json index 6373497f78..1628a80b82 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "0.67.3", + "version": "0.70.0", "description": "n8n Workflow Automation Tool", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", @@ -47,6 +47,7 @@ }, "files": [ "bin", + "templates", "dist", "oclif.manifest.json" ], @@ -82,9 +83,11 @@ "basic-auth": "^2.0.1", "body-parser": "^1.18.3", "body-parser-xml": "^1.1.0", + "client-oauth2": "^4.2.5", "compression": "^1.7.4", "connect-history-api-fallback": "^1.6.0", "convict": "^5.0.0", + "csrf": "^3.1.0", "dotenv": "^8.0.0", "express": "^4.16.4", "flatted": "^2.0.0", @@ -97,10 +100,11 @@ "lodash.get": "^4.4.2", "mongodb": "^3.5.5", "mysql2": "^2.0.1", - "n8n-core": "~0.34.0", - "n8n-editor-ui": "~0.45.0", - "n8n-nodes-base": "~0.62.1", - "n8n-workflow": "~0.31.0", + "n8n-core": "~0.36.0", + "n8n-editor-ui": "~0.47.0", + "n8n-nodes-base": "~0.65.0", + "n8n-workflow": "~0.33.0", + "oauth-1.0a": "^2.2.6", "open": "^7.0.0", "pg": "^7.11.0", "request-promise-native": "^1.0.7", diff --git a/packages/cli/src/CredentialTypes.ts b/packages/cli/src/CredentialTypes.ts index dc14801cbc..4eb8739ad1 100644 --- a/packages/cli/src/CredentialTypes.ts +++ b/packages/cli/src/CredentialTypes.ts @@ -3,16 +3,31 @@ import { ICredentialTypes as ICredentialTypesInterface, } from 'n8n-workflow'; +import { + CredentialsOverwrites, + ICredentialsTypeData, +} from './'; class CredentialTypesClass implements ICredentialTypesInterface { - credentialTypes: { - [key: string]: ICredentialType - } = {}; + credentialTypes: ICredentialsTypeData = {}; - async init(credentialTypes: { [key: string]: ICredentialType }): Promise { + async init(credentialTypes: ICredentialsTypeData): Promise { this.credentialTypes = credentialTypes; + + // Load the credentials overwrites if any exist + const credentialsOverwrites = CredentialsOverwrites().getAll(); + + for (const credentialType of Object.keys(credentialsOverwrites)) { + if (credentialTypes[credentialType] === undefined) { + continue; + } + + // Add which properties got overwritten that the Editor-UI knows + // which properties it should hide + credentialTypes[credentialType].__overwrittenProperties = Object.keys(credentialsOverwrites[credentialType]); + } } getAll(): ICredentialType[] { diff --git a/packages/cli/src/CredentialsHelper.ts b/packages/cli/src/CredentialsHelper.ts new file mode 100644 index 0000000000..84e368adf3 --- /dev/null +++ b/packages/cli/src/CredentialsHelper.ts @@ -0,0 +1,159 @@ +import { + Credentials, +} from 'n8n-core'; + +import { + ICredentialDataDecryptedObject, + ICredentialsHelper, + INodeParameters, + INodeProperties, + NodeHelpers, +} from 'n8n-workflow'; + +import { + CredentialsOverwrites, + CredentialTypes, + Db, + ICredentialsDb, +} from './'; + + +export class CredentialsHelper extends ICredentialsHelper { + + /** + * Returns the credentials instance + * + * @param {string} name Name of the credentials to return instance of + * @param {string} type Type of the credentials to return instance of + * @returns {Credentials} + * @memberof CredentialsHelper + */ + getCredentials(name: string, type: string): Credentials { + if (!this.workflowCredentials[type]) { + throw new Error(`No credentials of type "${type}" exist.`); + } + if (!this.workflowCredentials[type][name]) { + throw new Error(`No credentials with name "${name}" exist for type "${type}".`); + } + const credentialData = this.workflowCredentials[type][name]; + + return new Credentials(credentialData.name, credentialData.type, credentialData.nodesAccess, credentialData.data); + } + + + /** + * Returns all the properties of the credentials with the given name + * + * @param {string} type The name of the type to return credentials off + * @returns {INodeProperties[]} + * @memberof CredentialsHelper + */ + getCredentialsProperties(type: string): INodeProperties[] { + const credentialTypes = CredentialTypes(); + const credentialTypeData = credentialTypes.getByName(type); + + if (credentialTypeData === undefined) { + throw new Error(`The credentials of type "${type}" are not known.`); + } + + if (credentialTypeData.extends === undefined) { + return credentialTypeData.properties; + } + + const combineProperties = [] as INodeProperties[]; + for (const credentialsTypeName of credentialTypeData.extends) { + const mergeCredentialProperties = this.getCredentialsProperties(credentialsTypeName); + NodeHelpers.mergeNodeProperties(combineProperties, mergeCredentialProperties); + } + + // The properties defined on the parent credentials take presidence + NodeHelpers.mergeNodeProperties(combineProperties, credentialTypeData.properties); + + return combineProperties; + } + + + /** + * Returns the decrypted credential data with applied overwrites + * + * @param {string} name Name of the credentials to return data 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 + */ + getDecrypted(name: string, type: string, raw?: boolean): ICredentialDataDecryptedObject { + const credentials = this.getCredentials(name, type); + + const decryptedDataOriginal = credentials.getData(this.encryptionKey); + + if (raw === true) { + return decryptedDataOriginal; + } + + return this.applyDefaultsAndOverwrites(decryptedDataOriginal, type); + } + + + /** + * Applies credential default data and overwrites + * + * @param {ICredentialDataDecryptedObject} decryptedDataOriginal The credential data to overwrite data on + * @param {string} type Type of the credentials to overwrite data of + * @returns {ICredentialDataDecryptedObject} + * @memberof CredentialsHelper + */ + applyDefaultsAndOverwrites(decryptedDataOriginal: ICredentialDataDecryptedObject, type: string): ICredentialDataDecryptedObject { + const credentialsProperties = this.getCredentialsProperties(type); + + // Add the default credential values + const decryptedData = NodeHelpers.getNodeParameters(credentialsProperties, decryptedDataOriginal as INodeParameters, true, false) as ICredentialDataDecryptedObject; + + if (decryptedDataOriginal.oauthTokenData !== undefined) { + // The OAuth data gets removed as it is not defined specifically as a parameter + // on the credentials so add it back in case it was set + decryptedData.oauthTokenData = decryptedDataOriginal.oauthTokenData; + } + + // Load and apply the credentials overwrites if any exist + const credentialsOverwrites = CredentialsOverwrites(); + return credentialsOverwrites.applyOverwrite(type, decryptedData); + } + + + /** + * Updates credentials in the database + * + * @param {string} name Name of the credentials to set data of + * @param {string} type Type of the credentials to set data of + * @param {ICredentialDataDecryptedObject} data The data to set + * @returns {Promise} + * @memberof CredentialsHelper + */ + async updateCredentials(name: string, type: string, data: ICredentialDataDecryptedObject): Promise { + const credentials = await this.getCredentials(name, type); + + if (Db.collections!.Credentials === null) { + // The first time executeWorkflow gets called the Database has + // to get initialized first + await Db.init(); + } + + credentials.setData(data, this.encryptionKey); + const newCredentialsData = credentials.getDataToSave() as ICredentialsDb; + + // Add special database related data + newCredentialsData.updatedAt = new Date(); + + // TODO: also add user automatically depending on who is logged in, if anybody is logged in + + // Save the credentials in DB + const findQuery = { + name, + type, + }; + + await Db.collections.Credentials!.update(findQuery, newCredentialsData); + } + +} diff --git a/packages/cli/src/CredentialsOverwrites.ts b/packages/cli/src/CredentialsOverwrites.ts new file mode 100644 index 0000000000..a6e115100e --- /dev/null +++ b/packages/cli/src/CredentialsOverwrites.ts @@ -0,0 +1,63 @@ +import { + ICredentialDataDecryptedObject, +} from 'n8n-workflow'; + +import { + ICredentialsOverwrite, + GenericHelpers, +} from './'; + + +class CredentialsOverwritesClass { + + private overwriteData: ICredentialsOverwrite = {}; + + async init(overwriteData?: ICredentialsOverwrite) { + if (overwriteData !== undefined) { + // If data is already given it can directly be set instead of + // loaded from environment + this.overwriteData = overwriteData; + return; + } + + const data = await GenericHelpers.getConfigValue('credentials.overwrite') as string; + + try { + this.overwriteData = JSON.parse(data); + } catch (error) { + throw new Error(`The credentials-overwrite is not valid JSON.`); + } + } + + applyOverwrite(type: string, data: ICredentialDataDecryptedObject) { + const overwrites = this.get(type); + + if (overwrites === undefined) { + return data; + } + + const returnData = JSON.parse(JSON.stringify(data)); + Object.assign(returnData, overwrites); + + return returnData; + } + + get(type: string): ICredentialDataDecryptedObject | undefined { + return this.overwriteData[type]; + } + + getAll(): ICredentialsOverwrite { + return this.overwriteData; + } +} + + +let credentialsOverwritesInstance: CredentialsOverwritesClass | undefined; + +export function CredentialsOverwrites(): CredentialsOverwritesClass { + if (credentialsOverwritesInstance === undefined) { + credentialsOverwritesInstance = new CredentialsOverwritesClass(); + } + + return credentialsOverwritesInstance; +} diff --git a/packages/cli/src/ExternalHooks.ts b/packages/cli/src/ExternalHooks.ts new file mode 100644 index 0000000000..355415158a --- /dev/null +++ b/packages/cli/src/ExternalHooks.ts @@ -0,0 +1,79 @@ +import { + Db, + IExternalHooksFunctions, + IExternalHooksClass, +} from './'; + +import * as config from '../config'; + + +class ExternalHooksClass implements IExternalHooksClass { + + externalHooks: { + [key: string]: Array<() => {}> + } = {}; + initDidRun = false; + + + async init(): Promise { + if (this.initDidRun === true) { + return; + } + + const externalHookFiles = config.get('externalHookFiles').split(':'); + + // Load all the provided hook-files + for (let hookFilePath of externalHookFiles) { + hookFilePath = hookFilePath.trim(); + if (hookFilePath !== '') { + try { + const hookFile = require(hookFilePath); + + for (const resource of Object.keys(hookFile)) { + for (const operation of Object.keys(hookFile[resource])) { + // Save all the hook functions directly under their string + // format in an array + const hookString = `${resource}.${operation}`; + if (this.externalHooks[hookString] === undefined) { + this.externalHooks[hookString] = []; + } + + this.externalHooks[hookString].push.apply(this.externalHooks[hookString], hookFile[resource][operation]); + } + } + } catch (error) { + throw new Error(`Problem loading external hook file "${hookFilePath}": ${error.message}`); + } + } + } + + this.initDidRun = true; + } + + async run(hookName: string, hookParameters?: any[]): Promise { // tslint:disable-line:no-any + const externalHookFunctions: IExternalHooksFunctions = { + dbCollections: Db.collections, + }; + + if (this.externalHooks[hookName] === undefined) { + return; + } + + for(const externalHookFunction of this.externalHooks[hookName]) { + await externalHookFunction.apply(externalHookFunctions, hookParameters); + } + } + +} + + + +let externalHooksInstance: ExternalHooksClass | undefined; + +export function ExternalHooks(): ExternalHooksClass { + if (externalHooksInstance === undefined) { + externalHooksInstance = new ExternalHooksClass(); + } + + return externalHooksInstance; +} diff --git a/packages/cli/src/Interfaces.ts b/packages/cli/src/Interfaces.ts index fd56ad3ada..2aecd27466 100644 --- a/packages/cli/src/Interfaces.ts +++ b/packages/cli/src/Interfaces.ts @@ -1,6 +1,8 @@ import { + ICredentialDataDecryptedObject, ICredentialsDecrypted, ICredentialsEncrypted, + ICredentialType, IDataObject, IExecutionError, IRun, @@ -35,6 +37,13 @@ export interface ICustomRequest extends Request { parsedUrl: Url | undefined; } +export interface ICredentialsTypeData { + [key: string]: ICredentialType; +} + +export interface ICredentialsOverwrite { + [key: string]: ICredentialDataDecryptedObject; +} export interface IDatabaseCollections { Credentials: Repository | null; @@ -78,7 +87,7 @@ export interface ICredentialsBase { updatedAt: Date; } -export interface ICredentialsDb extends ICredentialsBase, ICredentialsEncrypted{ +export interface ICredentialsDb extends ICredentialsBase, ICredentialsEncrypted { id: number | string | ObjectID; } @@ -195,6 +204,30 @@ export interface IExecutingWorkflowData { workflowExecution?: PCancelable; } +export interface IExternalHooks { + credentials?: { + create?: Array<{ (this: IExternalHooksFunctions, credentialsData: ICredentialsEncrypted): Promise; }> + delete?: Array<{ (this: IExternalHooksFunctions, credentialId: string): Promise; }> + update?: Array<{ (this: IExternalHooksFunctions, credentialsData: ICredentialsDb): Promise; }> + }; + workflow?: { + activate?: Array<{ (this: IExternalHooksFunctions, workflowData: IWorkflowDb): Promise; }> + create?: Array<{ (this: IExternalHooksFunctions, workflowData: IWorkflowBase): Promise; }> + delete?: Array<{ (this: IExternalHooksFunctions, workflowId: string): Promise; }> + execute?: Array<{ (this: IExternalHooksFunctions, workflowData: IWorkflowDb, mode: WorkflowExecuteMode): Promise; }> + update?: Array<{ (this: IExternalHooksFunctions, workflowData: IWorkflowDb): Promise; }> + }; +} + +export interface IExternalHooksFunctions { + dbCollections: IDatabaseCollections; +} + +export interface IExternalHooksClass { + init(): Promise; + run(hookName: string, hookParameters?: any[]): Promise; // tslint:disable-line:no-any +} + export interface IN8nConfig { database: IN8nConfigDatabase; endpoints: IN8nConfigEndpoints; @@ -353,7 +386,10 @@ export interface IWorkflowExecutionDataProcess { workflowData: IWorkflowBase; } + export interface IWorkflowExecutionDataProcessWithExecution extends IWorkflowExecutionDataProcess { + credentialsOverwrite: ICredentialsOverwrite; + credentialsTypeData: ICredentialsTypeData; executionId: string; nodeTypeData: ITransferNodeTypes; } diff --git a/packages/cli/src/LoadNodesAndCredentials.ts b/packages/cli/src/LoadNodesAndCredentials.ts index 0d0eb21df3..6e268b70fc 100644 --- a/packages/cli/src/LoadNodesAndCredentials.ts +++ b/packages/cli/src/LoadNodesAndCredentials.ts @@ -97,24 +97,28 @@ class LoadNodesAndCredentialsClass { * @memberof LoadNodesAndCredentialsClass */ async getN8nNodePackages(): Promise { - const packages: string[] = []; - for (const file of await fsReaddirAsync(this.nodeModulesPath)) { - if (file.indexOf('n8n-nodes-') !== 0) { - continue; + const getN8nNodePackagesRecursive = async (relativePath: string): Promise => { + const results: string[] = []; + const nodeModulesPath = `${this.nodeModulesPath}/${relativePath}`; + for (const file of await fsReaddirAsync(nodeModulesPath)) { + const isN8nNodesPackage = file.indexOf('n8n-nodes-') === 0; + const isNpmScopedPackage = file.indexOf('@') === 0; + if (!isN8nNodesPackage && !isNpmScopedPackage) { + continue; + } + if (!(await fsStatAsync(nodeModulesPath)).isDirectory()) { + continue; + } + if (isN8nNodesPackage) { results.push(`${relativePath}${file}`); } + if (isNpmScopedPackage) { + results.push(...await getN8nNodePackagesRecursive(`${relativePath}${file}/`)); + } } - - // Check if it is really a folder - if (!(await fsStatAsync(path.join(this.nodeModulesPath, file))).isDirectory()) { - continue; - } - - packages.push(file); - } - - return packages; + return results; + }; + return getN8nNodePackagesRecursive(''); } - /** * Loads credentials from a file * @@ -137,7 +141,7 @@ class LoadNodesAndCredentialsClass { } } - this.credentialTypes[credentialName] = tempCredential; + this.credentialTypes[tempCredential.name] = tempCredential; } diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index cf53b85665..546aa97f47 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -5,6 +5,7 @@ import { import { dirname as pathDirname, join as pathJoin, + resolve as pathResolve, } from 'path'; import { getConnectionManager, @@ -12,13 +13,21 @@ import { import * as bodyParser from 'body-parser'; require('body-parser-xml')(bodyParser); import * as history from 'connect-history-api-fallback'; -import * as requestPromise from 'request-promise-native'; +import * as _ from 'lodash'; +import * as clientOAuth2 from 'client-oauth2'; +import * as clientOAuth1 from 'oauth-1.0a'; +import { RequestOptions } from 'oauth-1.0a'; +import * as csrf from 'csrf'; +import * as requestPromise from 'request-promise-native'; +import { createHmac } from 'crypto'; import { ActiveExecutions, ActiveWorkflowRunner, + CredentialsHelper, CredentialTypes, Db, + ExternalHooks, IActivationError, ICustomRequest, ICredentialsDb, @@ -33,6 +42,7 @@ import { IExecutionsListResponse, IExecutionsStopData, IExecutionsSummary, + IExternalHooksClass, IN8nUISettings, IPackageVersions, IWorkflowBase, @@ -57,6 +67,7 @@ import { } from 'n8n-core'; import { + ICredentialsEncrypted, ICredentialType, IDataObject, INodeCredentials, @@ -64,6 +75,7 @@ import { INodeParameters, INodePropertyOptions, IRunData, + IWorkflowCredentials, Workflow, } from 'n8n-workflow'; @@ -83,7 +95,8 @@ import * as jwks from 'jwks-rsa'; // @ts-ignore import * as timezones from 'google-timezones-json'; import * as parseUrl from 'parseurl'; - +import * as querystring from 'querystring'; +import { OptionsWithUrl } from 'request-promise-native'; class App { @@ -92,6 +105,7 @@ class App { testWebhooks: TestWebhooks.TestWebhooks; endpointWebhook: string; endpointWebhookTest: string; + externalHooks: IExternalHooksClass; saveDataErrorExecution: string; saveDataSuccessExecution: string; saveManualExecutions: boolean; @@ -123,6 +137,8 @@ class App { this.protocol = config.get('protocol'); this.sslKey = config.get('ssl_key'); this.sslCert = config.get('ssl_cert'); + + this.externalHooks = ExternalHooks(); } @@ -340,7 +356,7 @@ class App { // Creates a new workflow this.app.post('/rest/workflows', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { - const newWorkflowData = req.body; + const newWorkflowData = req.body as IWorkflowBase; newWorkflowData.name = newWorkflowData.name.trim(); newWorkflowData.createdAt = this.getCurrentDate(); @@ -348,6 +364,8 @@ class App { newWorkflowData.id = undefined; + await this.externalHooks.run('workflow.create', [newWorkflowData]); + // Save the workflow in DB const result = await Db.collections.Workflow!.save(newWorkflowData); @@ -423,9 +441,11 @@ class App { // Updates an existing workflow this.app.patch('/rest/workflows/:id', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { - const newWorkflowData = req.body; + const newWorkflowData = req.body as IWorkflowBase; const id = req.params.id; + await this.externalHooks.run('workflow.update', [newWorkflowData]); + const isActive = await this.activeWorkflowRunner.isActive(id); if (isActive) { @@ -469,6 +489,8 @@ class App { if (responseData.active === true) { // When the workflow is supposed to be active add it again try { + await this.externalHooks.run('workflow.activate', [responseData]); + await this.activeWorkflowRunner.add(id); } catch (error) { // If workflow could not be activated set it again to inactive @@ -493,6 +515,8 @@ class App { this.app.delete('/rest/workflows/:id', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { const id = req.params.id; + await this.externalHooks.run('workflow.delete', [id]); + const isActive = await this.activeWorkflowRunner.isActive(id); if (isActive) { @@ -567,7 +591,7 @@ class App { const nodeTypes = NodeTypes(); - const loadDataInstance = new LoadNodeParameterOptions(nodeType, nodeTypes, credentials); + const loadDataInstance = new LoadNodeParameterOptions(nodeType, nodeTypes, JSON.parse('' + req.query.currentNodeParameters), credentials!); const workflowData = loadDataInstance.getWorkflowData() as IWorkflowBase; const workflowCredentials = await WorkflowCredentials(workflowData.nodes); @@ -601,8 +625,8 @@ class App { // Returns the node icon - this.app.get('/rest/node-icon/:nodeType', async (req: express.Request, res: express.Response): Promise => { - const nodeTypeName = req.params.nodeType; + this.app.get(['/rest/node-icon/:nodeType', '/rest/node-icon/:scope/:nodeType'], async (req: express.Request, res: express.Response): Promise => { + const nodeTypeName = `${req.params.scope ? `${req.params.scope}/` : ''}${req.params.nodeType}`; const nodeTypes = NodeTypes(); const nodeType = nodeTypes.getByName(nodeTypeName); @@ -658,6 +682,8 @@ class App { this.app.delete('/rest/credentials/:id', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { const id = req.params.id; + await this.externalHooks.run('credentials.delete', [id]); + await Db.collections.Credentials!.delete({ id }); return true; @@ -667,6 +693,10 @@ class App { this.app.post('/rest/credentials', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { const incomingData = req.body; + if (!incomingData.name || incomingData.name.length < 3) { + throw new ResponseHelper.ResponseError(`Credentials name must be at least 3 characters long.`, undefined, 400); + } + // Add the added date for node access permissions for (const nodeAccess of incomingData.nodesAccess) { nodeAccess.date = this.getCurrentDate(); @@ -699,6 +729,8 @@ class App { credentials.setData(incomingData.data, encryptionKey); const newCredentialsData = credentials.getDataToSave() as ICredentialsDb; + await this.externalHooks.run('credentials.create', [newCredentialsData]); + // Add special database related data newCredentialsData.createdAt = this.getCurrentDate(); newCredentialsData.updatedAt = this.getCurrentDate(); @@ -707,6 +739,7 @@ class App { // Save the credentials in DB const result = await Db.collections.Credentials!.save(newCredentialsData); + result.data = incomingData.data; // Convert to response format in which the id is a string (result as unknown as ICredentialsResponse).id = result.id.toString(); @@ -750,6 +783,21 @@ class App { throw new Error('No encryption key got found to encrypt the credentials!'); } + // Load the currently saved credentials to be able to persist some of the data if + const result = await Db.collections.Credentials!.findOne(id); + if (result === undefined) { + throw new ResponseHelper.ResponseError(`Credentials with the id "${id}" do not exist.`, undefined, 400); + } + + const currentlySavedCredentials = new Credentials(result.name, result.type, result.nodesAccess, result.data); + const decryptedData = currentlySavedCredentials.getData(encryptionKey!); + + // Do not overwrite the oauth data else data like the access or refresh token would get lost + // everytime anybody changes anything on the credentials even if it is just the name. + if (decryptedData.oauthTokenData) { + incomingData.data.oauthTokenData = decryptedData.oauthTokenData; + } + // Encrypt the data const credentials = new Credentials(incomingData.name, incomingData.type, incomingData.nodesAccess); credentials.setData(incomingData.data, encryptionKey); @@ -758,6 +806,8 @@ class App { // Add special database related data newCredentialsData.updatedAt = this.getCurrentDate(); + await this.externalHooks.run('credentials.update', [newCredentialsData]); + // Update the credentials in DB await Db.collections.Credentials!.update(id, newCredentialsData); @@ -869,6 +919,331 @@ class App { return returnData; })); + // ---------------------------------------- + // OAuth1-Credential/Auth + // ---------------------------------------- + + // Authorize OAuth Data + this.app.get('/rest/oauth1-credential/auth', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + if (req.query.id === undefined) { + throw new Error('Required credential id is missing!'); + } + + const result = await Db.collections.Credentials!.findOne(req.query.id as string); + if (result === undefined) { + res.status(404).send('The credential is not known.'); + return ''; + } + + let encryptionKey = undefined; + encryptionKey = await UserSettings.getEncryptionKey(); + if (encryptionKey === undefined) { + throw new Error('No encryption key got found to decrypt the credentials!'); + } + + // Decrypt the currently saved credentials + const workflowCredentials: IWorkflowCredentials = { + [result.type as string]: { + [result.name as string]: result as ICredentialsEncrypted, + }, + }; + const credentialsHelper = new CredentialsHelper(workflowCredentials, encryptionKey); + const decryptedDataOriginal = credentialsHelper.getDecrypted(result.name, result.type, true); + const oauthCredentials = credentialsHelper.applyDefaultsAndOverwrites(decryptedDataOriginal, result.type); + + const signatureMethod = _.get(oauthCredentials, 'signatureMethod') as string; + + const oauth = new clientOAuth1({ + consumer: { + key: _.get(oauthCredentials, 'consumerKey') as string, + secret: _.get(oauthCredentials, 'consumerSecret') as string, + }, + signature_method: signatureMethod, + hash_function(base, key) { + const algorithm = (signatureMethod === 'HMAC-SHA1') ? 'sha1' : 'sha256'; + return createHmac(algorithm, key) + .update(base) + .digest('base64'); + }, + }); + + const callback = `${WebhookHelpers.getWebhookBaseUrl()}rest/oauth1-credential/callback?cid=${req.query.id}`; + + const options: RequestOptions = { + method: 'POST', + url: (_.get(oauthCredentials, 'requestTokenUrl') as string), + data: { + oauth_callback: callback, + }, + }; + + const data = oauth.toHeader(oauth.authorize(options as RequestOptions)); + + //@ts-ignore + options.headers = data; + + const response = await requestPromise(options); + + // Response comes as x-www-form-urlencoded string so convert it to JSON + + const responseJson = querystring.parse(response); + + const returnUri = `${_.get(oauthCredentials, 'authUrl')}?oauth_token=${responseJson.oauth_token}`; + + // Encrypt the data + const credentials = new Credentials(result.name, result.type, result.nodesAccess); + + credentials.setData(decryptedDataOriginal, encryptionKey); + const newCredentialsData = credentials.getDataToSave() as unknown as ICredentialsDb; + + // Add special database related data + newCredentialsData.updatedAt = this.getCurrentDate(); + + // Update the credentials in DB + await Db.collections.Credentials!.update(req.query.id as string, newCredentialsData); + + return returnUri; + })); + + // Verify and store app code. Generate access tokens and store for respective credential. + this.app.get('/rest/oauth1-credential/callback', async (req: express.Request, res: express.Response) => { + const { oauth_verifier, oauth_token, cid } = req.query; + + if (oauth_verifier === undefined || oauth_token === undefined) { + throw new Error('Insufficient parameters for OAuth1 callback'); + } + + const result = await Db.collections.Credentials!.findOne(cid as any); // tslint:disable-line:no-any + if (result === undefined) { + const errorResponse = new ResponseHelper.ResponseError('The credential is not known.', undefined, 404); + return ResponseHelper.sendErrorResponse(res, errorResponse); + } + + let encryptionKey = undefined; + encryptionKey = await UserSettings.getEncryptionKey(); + if (encryptionKey === undefined) { + const errorResponse = new ResponseHelper.ResponseError('No encryption key got found to decrypt the credentials!', undefined, 503); + return ResponseHelper.sendErrorResponse(res, errorResponse); + } + + // Decrypt the currently saved credentials + const workflowCredentials: IWorkflowCredentials = { + [result.type as string]: { + [result.name as string]: result as ICredentialsEncrypted, + }, + }; + const credentialsHelper = new CredentialsHelper(workflowCredentials, encryptionKey); + const decryptedDataOriginal = credentialsHelper.getDecrypted(result.name, result.type, true); + const oauthCredentials = credentialsHelper.applyDefaultsAndOverwrites(decryptedDataOriginal, result.type); + + const options: OptionsWithUrl = { + method: 'POST', + url: _.get(oauthCredentials, 'accessTokenUrl') as string, + qs: { + oauth_token, + oauth_verifier, + } + }; + + let oauthToken; + + try { + oauthToken = await requestPromise(options); + } catch (error) { + const errorResponse = new ResponseHelper.ResponseError('Unable to get access tokens!', undefined, 404); + return ResponseHelper.sendErrorResponse(res, errorResponse); + } + + // Response comes as x-www-form-urlencoded string so convert it to JSON + + const oauthTokenJson = querystring.parse(oauthToken); + + decryptedDataOriginal.oauthTokenData = oauthTokenJson; + + const credentials = new Credentials(result.name, result.type, result.nodesAccess); + credentials.setData(decryptedDataOriginal, encryptionKey); + const newCredentialsData = credentials.getDataToSave() as unknown as ICredentialsDb; + // Add special database related data + newCredentialsData.updatedAt = this.getCurrentDate(); + // Save the credentials in DB + await Db.collections.Credentials!.update(cid as any, newCredentialsData); // tslint:disable-line:no-any + + res.sendFile(pathResolve(__dirname, '../../templates/oauth-callback.html')); + }); + + + // ---------------------------------------- + // OAuth2-Credential/Auth + // ---------------------------------------- + + + // Authorize OAuth Data + this.app.get('/rest/oauth2-credential/auth', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + if (req.query.id === undefined) { + throw new Error('Required credential id is missing!'); + } + + const result = await Db.collections.Credentials!.findOne(req.query.id as string); + if (result === undefined) { + res.status(404).send('The credential is not known.'); + return ''; + } + + let encryptionKey = undefined; + encryptionKey = await UserSettings.getEncryptionKey(); + if (encryptionKey === undefined) { + throw new Error('No encryption key got found to decrypt the credentials!'); + } + + // Decrypt the currently saved credentials + const workflowCredentials: IWorkflowCredentials = { + [result.type as string]: { + [result.name as string]: result as ICredentialsEncrypted, + }, + }; + const credentialsHelper = new CredentialsHelper(workflowCredentials, encryptionKey); + const decryptedDataOriginal = credentialsHelper.getDecrypted(result.name, result.type, true); + const oauthCredentials = credentialsHelper.applyDefaultsAndOverwrites(decryptedDataOriginal, result.type); + + const token = new csrf(); + // Generate a CSRF prevention token and send it as a OAuth2 state stringma/ERR + const csrfSecret = token.secretSync(); + const state = { + token: token.create(csrfSecret), + cid: req.query.id + }; + const stateEncodedStr = Buffer.from(JSON.stringify(state)).toString('base64') as string; + + const oAuthObj = new clientOAuth2({ + clientId: _.get(oauthCredentials, 'clientId') as string, + clientSecret: _.get(oauthCredentials, 'clientSecret', '') as string, + accessTokenUri: _.get(oauthCredentials, 'accessTokenUrl', '') as string, + authorizationUri: _.get(oauthCredentials, 'authUrl', '') as string, + redirectUri: `${WebhookHelpers.getWebhookBaseUrl()}rest/oauth2-credential/callback`, + scopes: _.split(_.get(oauthCredentials, 'scope', 'openid,') as string, ','), + state: stateEncodedStr, + }); + + // Encrypt the data + const credentials = new Credentials(result.name, result.type, result.nodesAccess); + decryptedDataOriginal.csrfSecret = csrfSecret; + + credentials.setData(decryptedDataOriginal, encryptionKey); + const newCredentialsData = credentials.getDataToSave() as unknown as ICredentialsDb; + + // Add special database related data + newCredentialsData.updatedAt = this.getCurrentDate(); + + // Update the credentials in DB + await Db.collections.Credentials!.update(req.query.id as string, newCredentialsData); + + const authQueryParameters = _.get(oauthCredentials, 'authQueryParameters', '') as string; + let returnUri = oAuthObj.code.getUri(); + + if (authQueryParameters) { + returnUri += '&' + authQueryParameters; + } + + return returnUri; + })); + + // ---------------------------------------- + // OAuth2-Credential/Callback + // ---------------------------------------- + + // Verify and store app code. Generate access tokens and store for respective credential. + this.app.get('/rest/oauth2-credential/callback', async (req: express.Request, res: express.Response) => { + const {code, state: stateEncoded } = req.query; + + if (code === undefined || stateEncoded === undefined) { + throw new Error('Insufficient parameters for OAuth2 callback'); + } + + let state; + try { + state = JSON.parse(Buffer.from(stateEncoded as string, 'base64').toString()); + } catch (error) { + const errorResponse = new ResponseHelper.ResponseError('Invalid state format returned', undefined, 503); + return ResponseHelper.sendErrorResponse(res, errorResponse); + } + + const result = await Db.collections.Credentials!.findOne(state.cid); + if (result === undefined) { + const errorResponse = new ResponseHelper.ResponseError('The credential is not known.', undefined, 404); + return ResponseHelper.sendErrorResponse(res, errorResponse); + } + + let encryptionKey = undefined; + encryptionKey = await UserSettings.getEncryptionKey(); + if (encryptionKey === undefined) { + const errorResponse = new ResponseHelper.ResponseError('No encryption key got found to decrypt the credentials!', undefined, 503); + return ResponseHelper.sendErrorResponse(res, errorResponse); + } + + // Decrypt the currently saved credentials + const workflowCredentials: IWorkflowCredentials = { + [result.type as string]: { + [result.name as string]: result as ICredentialsEncrypted, + }, + }; + const credentialsHelper = new CredentialsHelper(workflowCredentials, encryptionKey); + const decryptedDataOriginal = credentialsHelper.getDecrypted(result.name, result.type, true); + const oauthCredentials = credentialsHelper.applyDefaultsAndOverwrites(decryptedDataOriginal, result.type); + + const token = new csrf(); + if (decryptedDataOriginal.csrfSecret === undefined || !token.verify(decryptedDataOriginal.csrfSecret as string, state.token)) { + const errorResponse = new ResponseHelper.ResponseError('The OAuth2 callback state is invalid!', undefined, 404); + return ResponseHelper.sendErrorResponse(res, errorResponse); + } + + let options = {}; + + if (_.get(oauthCredentials, 'authentication', 'header') as string === 'body') { + options = { + body: { + client_id: _.get(oauthCredentials, 'clientId') as string, + client_secret: _.get(oauthCredentials, 'clientSecret', '') as string, + }, + }; + } + + const oAuthObj = new clientOAuth2({ + clientId: _.get(oauthCredentials, 'clientId') as string, + clientSecret: _.get(oauthCredentials, 'clientSecret', '') as string, + accessTokenUri: _.get(oauthCredentials, 'accessTokenUrl', '') as string, + authorizationUri: _.get(oauthCredentials, 'authUrl', '') as string, + redirectUri: `${WebhookHelpers.getWebhookBaseUrl()}rest/oauth2-credential/callback`, + scopes: _.split(_.get(oauthCredentials, 'scope', 'openid,') as string, ',') + }); + + const oauthToken = await oAuthObj.code.getToken(req.originalUrl, options); + + if (oauthToken === undefined) { + const errorResponse = new ResponseHelper.ResponseError('Unable to get access tokens!', undefined, 404); + return ResponseHelper.sendErrorResponse(res, errorResponse); + } + + if (decryptedDataOriginal.oauthTokenData) { + // Only overwrite supplied data as some providers do for example just return the + // refresh_token on the very first request and not on subsequent ones. + Object.assign(decryptedDataOriginal.oauthTokenData, oauthToken.data); + } else { + // No data exists so simply set + decryptedDataOriginal.oauthTokenData = oauthToken.data; + } + + _.unset(decryptedDataOriginal, 'csrfSecret'); + + const credentials = new Credentials(result.name, result.type, result.nodesAccess); + credentials.setData(decryptedDataOriginal, encryptionKey); + const newCredentialsData = credentials.getDataToSave() as unknown as ICredentialsDb; + // Add special database related data + newCredentialsData.updatedAt = this.getCurrentDate(); + // Save the credentials in DB + await Db.collections.Credentials!.update(state.cid, newCredentialsData); + + res.sendFile(pathResolve(__dirname, '../../templates/oauth-callback.html')); + }); // ---------------------------------------- @@ -1299,6 +1674,7 @@ class App { export async function start(): Promise { const PORT = config.get('port'); + const ADDRESS = config.get('listen_address'); const app = new App(); @@ -1317,9 +1693,9 @@ export async function start(): Promise { server = http.createServer(app.app); } - server.listen(PORT, async () => { + server.listen(PORT, ADDRESS, async () => { const versions = await GenericHelpers.getVersions(); - console.log(`n8n ready on port ${PORT}`); + console.log(`n8n ready on ${ADDRESS}, port ${PORT}`); console.log(`Version: ${versions.cli}`); }); } diff --git a/packages/cli/src/WebhookHelpers.ts b/packages/cli/src/WebhookHelpers.ts index 0badd1c9d1..cbfb7be95c 100644 --- a/packages/cli/src/WebhookHelpers.ts +++ b/packages/cli/src/WebhookHelpers.ts @@ -176,6 +176,9 @@ export function getWorkflowWebhooksBasic(workflow: Workflow): IWebhookData[] { }; } + // Save static data if it changed + await WorkflowHelpers.saveStaticData(workflow); + if (webhookData.webhookDescription['responseHeaders'] !== undefined) { const responseHeaders = workflow.getComplexParameterValue(workflowStartNode, webhookData.webhookDescription['responseHeaders'], undefined) as { entries?: Array<{ diff --git a/packages/cli/src/WorkflowExecuteAdditionalData.ts b/packages/cli/src/WorkflowExecuteAdditionalData.ts index 7ac79310ba..7a61bfd657 100644 --- a/packages/cli/src/WorkflowExecuteAdditionalData.ts +++ b/packages/cli/src/WorkflowExecuteAdditionalData.ts @@ -1,5 +1,7 @@ import { + CredentialsHelper, Db, + ExternalHooks, IExecutionDb, IExecutionFlattedDb, IPushDataExecutionFinished, @@ -302,6 +304,10 @@ export async function executeWorkflow(workflowInfo: IExecuteWorkflowInfo, additi workflowData = workflowInfo.code; } + const externalHooks = ExternalHooks(); + await externalHooks.init(); + await externalHooks.run('workflow.execute', [workflowData, mode]); + const nodeTypes = NodeTypes(); const workflowName = workflowData ? workflowData.name : undefined; @@ -404,6 +410,7 @@ export async function getBase(credentials: IWorkflowCredentials, currentNodePara return { credentials, + credentialsHelper: new CredentialsHelper(credentials, encryptionKey), encryptionKey, executeWorkflow, restApiUrl: urlBaseWebhook + config.get('endpoints.rest') as string, diff --git a/packages/cli/src/WorkflowHelpers.ts b/packages/cli/src/WorkflowHelpers.ts index 90a41952ca..0f824398f4 100644 --- a/packages/cli/src/WorkflowHelpers.ts +++ b/packages/cli/src/WorkflowHelpers.ts @@ -1,5 +1,7 @@ import { + CredentialTypes, Db, + ICredentialsTypeData, ITransferNodeTypes, IWorkflowExecutionDataProcess, IWorkflowErrorData, @@ -15,6 +17,7 @@ import { IRun, IRunExecutionData, ITaskData, + IWorkflowCredentials, Workflow, } from 'n8n-workflow'; @@ -217,6 +220,63 @@ export function getNodeTypeData(nodes: INode[]): ITransferNodeTypes { +/** + * Returns the credentials data of the given type and its parent types + * it extends + * + * @export + * @param {string} type The credential type to return data off + * @returns {ICredentialsTypeData} + */ +export function getCredentialsDataWithParents(type: string): ICredentialsTypeData { + const credentialTypes = CredentialTypes(); + const credentialType = credentialTypes.getByName(type); + + const credentialTypeData: ICredentialsTypeData = {}; + credentialTypeData[type] = credentialType; + + if (credentialType === undefined || credentialType.extends === undefined) { + return credentialTypeData; + } + + for (const typeName of credentialType.extends) { + if (credentialTypeData[typeName] !== undefined) { + continue; + } + + credentialTypeData[typeName] = credentialTypes.getByName(typeName); + Object.assign(credentialTypeData, getCredentialsDataWithParents(typeName)); + } + + return credentialTypeData; +} + + + +/** + * Returns all the credentialTypes which are needed to resolve + * the given workflow credentials + * + * @export + * @param {IWorkflowCredentials} credentials The credentials which have to be able to be resolved + * @returns {ICredentialsTypeData} + */ +export function getCredentialsData(credentials: IWorkflowCredentials): ICredentialsTypeData { + const credentialTypeData: ICredentialsTypeData = {}; + + for (const credentialType of Object.keys(credentials)) { + if (credentialTypeData[credentialType] !== undefined) { + continue; + } + + Object.assign(credentialTypeData, getCredentialsDataWithParents(credentialType)); + } + + return credentialTypeData; +} + + + /** * Returns the names of the NodeTypes which are are needed * to execute the gives nodes diff --git a/packages/cli/src/WorkflowRunner.ts b/packages/cli/src/WorkflowRunner.ts index 3a09606550..c0d08f446c 100644 --- a/packages/cli/src/WorkflowRunner.ts +++ b/packages/cli/src/WorkflowRunner.ts @@ -1,5 +1,10 @@ import { ActiveExecutions, + CredentialsOverwrites, + CredentialTypes, + ExternalHooks, + ICredentialsOverwrite, + ICredentialsTypeData, IProcessMessageDataHook, ITransferNodeTypes, IWorkflowExecutionDataProcess, @@ -31,12 +36,14 @@ import { fork } from 'child_process'; export class WorkflowRunner { activeExecutions: ActiveExecutions.ActiveExecutions; + credentialsOverwrites: ICredentialsOverwrite; push: Push.Push; constructor() { this.push = Push.getInstance(); this.activeExecutions = ActiveExecutions.getInstance(); + this.credentialsOverwrites = CredentialsOverwrites().getAll(); } @@ -94,6 +101,9 @@ export class WorkflowRunner { * @memberof WorkflowRunner */ async run(data: IWorkflowExecutionDataProcess, loadStaticData?: boolean): Promise { + const externalHooks = ExternalHooks(); + await externalHooks.run('workflow.execute', [data.workflowData, data.executionMode]); + const executionsProcess = config.get('executions.process') as string; if (executionsProcess === 'main') { return this.runMainProcess(data, loadStaticData); @@ -173,8 +183,8 @@ export class WorkflowRunner { const executionId = this.activeExecutions.add(data, subprocess); // Check if workflow contains a "executeWorkflow" Node as in this - // case we can not know which nodeTypes will be needed and so have - // to load all of them in the workflowRunnerProcess + // case we can not know which nodeTypes and credentialTypes will + // be needed and so have to load all of them in the workflowRunnerProcess let loadAllNodeTypes = false; for (const node of data.workflowData.nodes) { if (node.type === 'n8n-nodes-base.executeWorkflow') { @@ -184,16 +194,24 @@ export class WorkflowRunner { } let nodeTypeData: ITransferNodeTypes; + let credentialTypeData: ICredentialsTypeData; + if (loadAllNodeTypes === true) { - // Supply all nodeTypes + // Supply all nodeTypes and credentialTypes nodeTypeData = WorkflowHelpers.getAllNodeTypeData(); + const credentialTypes = CredentialTypes(); + credentialTypeData = credentialTypes.credentialTypes; } else { - // Supply only nodeTypes which the workflow needs + // Supply only nodeTypes and credentialTypes which the workflow needs nodeTypeData = WorkflowHelpers.getNodeTypeData(data.workflowData.nodes); + credentialTypeData = WorkflowHelpers.getCredentialsData(data.credentials); } + (data as unknown as IWorkflowExecutionDataProcessWithExecution).executionId = executionId; (data as unknown as IWorkflowExecutionDataProcessWithExecution).nodeTypeData = nodeTypeData; + (data as unknown as IWorkflowExecutionDataProcessWithExecution).credentialsOverwrite = this.credentialsOverwrites; + (data as unknown as IWorkflowExecutionDataProcessWithExecution).credentialsTypeData = credentialTypeData; // TODO: Still needs correct value const workflowHooks = WorkflowExecuteAdditionalData.getWorkflowHooksMain(data, executionId); diff --git a/packages/cli/src/WorkflowRunnerProcess.ts b/packages/cli/src/WorkflowRunnerProcess.ts index 250e23cdaa..5748038a4f 100644 --- a/packages/cli/src/WorkflowRunnerProcess.ts +++ b/packages/cli/src/WorkflowRunnerProcess.ts @@ -1,5 +1,7 @@ import { + CredentialsOverwrites, + CredentialTypes, IWorkflowExecutionDataProcessWithExecution, NodeTypes, WorkflowExecuteAdditionalData, @@ -58,6 +60,14 @@ export class WorkflowRunnerProcess { const nodeTypes = NodeTypes(); await nodeTypes.init(nodeTypesData); + // Init credential types the workflow uses (is needed to apply default values to credentials) + const credentialTypes = CredentialTypes(); + await credentialTypes.init(inputData.credentialsTypeData); + + // Load the credentials overwrites if any exist + const credentialsOverwrites = CredentialsOverwrites(); + await credentialsOverwrites.init(); + this.workflow = new Workflow({ id: this.data.workflowData.id as string | undefined, name: this.data.workflowData.name, nodes: this.data.workflowData!.nodes, connections: this.data.workflowData!.connections, active: this.data.workflowData!.active, nodeTypes, staticData: this.data.workflowData!.staticData, settings: this.data.workflowData!.settings}); const additionalData = await WorkflowExecuteAdditionalData.getBase(this.data.credentials); additionalData.hooks = this.getProcessForwardHooks(); diff --git a/packages/cli/src/databases/mysqldb/migrations/1588157391238-InitialMigration.ts b/packages/cli/src/databases/mysqldb/migrations/1588157391238-InitialMigration.ts index fc11ef32fb..1d1d4d8cc5 100644 --- a/packages/cli/src/databases/mysqldb/migrations/1588157391238-InitialMigration.ts +++ b/packages/cli/src/databases/mysqldb/migrations/1588157391238-InitialMigration.ts @@ -8,8 +8,8 @@ export class InitialMigration1588157391238 implements MigrationInterface { async up(queryRunner: QueryRunner): Promise { const tablePrefix = config.get('database.tablePrefix'); - await queryRunner.query('CREATE TABLE IF NOT EXISTS `' + tablePrefix + 'credentials_entity` (`id` int NOT NULL AUTO_INCREMENT, `name` varchar(128) NOT NULL, `data` text NOT NULL, `type` varchar(32) NOT NULL, `nodesAccess` json NOT NULL, `createdAt` datetime NOT NULL, `updatedAt` datetime NOT NULL, INDEX `IDX_07fde106c0b471d8cc80a64fc8` (`type`), PRIMARY KEY (`id`)) ENGINE=InnoDB', undefined); - await queryRunner.query('CREATE TABLE IF NOT EXISTS `' + tablePrefix + 'execution_entity` (`id` int NOT NULL AUTO_INCREMENT, `data` text NOT NULL, `finished` tinyint NOT NULL, `mode` varchar(255) NOT NULL, `retryOf` varchar(255) NULL, `retrySuccessId` varchar(255) NULL, `startedAt` datetime NOT NULL, `stoppedAt` datetime NOT NULL, `workflowData` json NOT NULL, `workflowId` varchar(255) NULL, INDEX `IDX_c4d999a5e90784e8caccf5589d` (`workflowId`), PRIMARY KEY (`id`)) ENGINE=InnoDB', undefined); + await queryRunner.query('CREATE TABLE IF NOT EXISTS `' + tablePrefix + 'credentials_entity` (`id` int NOT NULL AUTO_INCREMENT, `name` varchar(128) NOT NULL, `data` text NOT NULL, `type` varchar(32) NOT NULL, `nodesAccess` json NOT NULL, `createdAt` datetime NOT NULL, `updatedAt` datetime NOT NULL, INDEX `IDX_' + tablePrefix + '07fde106c0b471d8cc80a64fc8` (`type`), PRIMARY KEY (`id`)) ENGINE=InnoDB', undefined); + await queryRunner.query('CREATE TABLE IF NOT EXISTS `' + tablePrefix + 'execution_entity` (`id` int NOT NULL AUTO_INCREMENT, `data` text NOT NULL, `finished` tinyint NOT NULL, `mode` varchar(255) NOT NULL, `retryOf` varchar(255) NULL, `retrySuccessId` varchar(255) NULL, `startedAt` datetime NOT NULL, `stoppedAt` datetime NOT NULL, `workflowData` json NOT NULL, `workflowId` varchar(255) NULL, INDEX `IDX_' + tablePrefix + 'c4d999a5e90784e8caccf5589d` (`workflowId`), PRIMARY KEY (`id`)) ENGINE=InnoDB', undefined); await queryRunner.query('CREATE TABLE IF NOT EXISTS`' + tablePrefix + 'workflow_entity` (`id` int NOT NULL AUTO_INCREMENT, `name` varchar(128) NOT NULL, `active` tinyint NOT NULL, `nodes` json NOT NULL, `connections` json NOT NULL, `createdAt` datetime NOT NULL, `updatedAt` datetime NOT NULL, `settings` json NULL, `staticData` json NULL, PRIMARY KEY (`id`)) ENGINE=InnoDB', undefined); } @@ -17,9 +17,9 @@ export class InitialMigration1588157391238 implements MigrationInterface { const tablePrefix = config.get('database.tablePrefix'); await queryRunner.query('DROP TABLE `' + tablePrefix + 'workflow_entity`', undefined); - await queryRunner.query('DROP INDEX `IDX_c4d999a5e90784e8caccf5589d` ON `' + tablePrefix + 'execution_entity`', undefined); + await queryRunner.query('DROP INDEX `IDX_' + tablePrefix + 'c4d999a5e90784e8caccf5589d` ON `' + tablePrefix + 'execution_entity`', undefined); await queryRunner.query('DROP TABLE `' + tablePrefix + 'execution_entity`', undefined); - await queryRunner.query('DROP INDEX `IDX_07fde106c0b471d8cc80a64fc8` ON `' + tablePrefix + 'credentials_entity`', undefined); + await queryRunner.query('DROP INDEX `IDX_' + tablePrefix + '07fde106c0b471d8cc80a64fc8` ON `' + tablePrefix + 'credentials_entity`', undefined); await queryRunner.query('DROP TABLE `' + tablePrefix + 'credentials_entity`', undefined); } diff --git a/packages/cli/src/databases/postgresdb/migrations/1587669153312-InitialMigration.ts b/packages/cli/src/databases/postgresdb/migrations/1587669153312-InitialMigration.ts index 5f3798ca66..eace7a92fb 100644 --- a/packages/cli/src/databases/postgresdb/migrations/1587669153312-InitialMigration.ts +++ b/packages/cli/src/databases/postgresdb/migrations/1587669153312-InitialMigration.ts @@ -8,29 +8,31 @@ export class InitialMigration1587669153312 implements MigrationInterface { async up(queryRunner: QueryRunner): Promise { let tablePrefix = config.get('database.tablePrefix'); + const tablePrefixIndex = tablePrefix; const schema = config.get('database.postgresdb.schema'); if (schema) { tablePrefix = schema + '.' + tablePrefix; } - await queryRunner.query(`CREATE TABLE IF NOT EXISTS ${tablePrefix}credentials_entity ("id" SERIAL NOT NULL, "name" character varying(128) NOT NULL, "data" text NOT NULL, "type" character varying(32) NOT NULL, "nodesAccess" json NOT NULL, "createdAt" TIMESTAMP NOT NULL, "updatedAt" TIMESTAMP NOT NULL, CONSTRAINT PK_814c3d3c36e8a27fa8edb761b0e PRIMARY KEY ("id"))`, undefined); - await queryRunner.query(`CREATE INDEX IF NOT EXISTS IDX_07fde106c0b471d8cc80a64fc8 ON ${tablePrefix}credentials_entity (type) `, undefined); - await queryRunner.query(`CREATE TABLE IF NOT EXISTS ${tablePrefix}execution_entity ("id" SERIAL NOT NULL, "data" text NOT NULL, "finished" boolean NOT NULL, "mode" character varying NOT NULL, "retryOf" character varying, "retrySuccessId" character varying, "startedAt" TIMESTAMP NOT NULL, "stoppedAt" TIMESTAMP NOT NULL, "workflowData" json NOT NULL, "workflowId" character varying, CONSTRAINT PK_e3e63bbf986767844bbe1166d4e PRIMARY KEY ("id"))`, undefined); - await queryRunner.query(`CREATE INDEX IF NOT EXISTS IDX_c4d999a5e90784e8caccf5589d ON ${tablePrefix}execution_entity ("workflowId") `, undefined); - await queryRunner.query(`CREATE TABLE IF NOT EXISTS ${tablePrefix}workflow_entity ("id" SERIAL NOT NULL, "name" character varying(128) NOT NULL, "active" boolean NOT NULL, "nodes" json NOT NULL, "connections" json NOT NULL, "createdAt" TIMESTAMP NOT NULL, "updatedAt" TIMESTAMP NOT NULL, "settings" json, "staticData" json, CONSTRAINT PK_eded7d72664448da7745d551207 PRIMARY KEY ("id"))`, undefined); + await queryRunner.query(`CREATE TABLE IF NOT EXISTS ${tablePrefix}credentials_entity ("id" SERIAL NOT NULL, "name" character varying(128) NOT NULL, "data" text NOT NULL, "type" character varying(32) NOT NULL, "nodesAccess" json NOT NULL, "createdAt" TIMESTAMP NOT NULL, "updatedAt" TIMESTAMP NOT NULL, CONSTRAINT PK_${tablePrefixIndex}814c3d3c36e8a27fa8edb761b0e PRIMARY KEY ("id"))`, undefined); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS IDX_${tablePrefixIndex}07fde106c0b471d8cc80a64fc8 ON ${tablePrefix}credentials_entity (type) `, undefined); + await queryRunner.query(`CREATE TABLE IF NOT EXISTS ${tablePrefix}execution_entity ("id" SERIAL NOT NULL, "data" text NOT NULL, "finished" boolean NOT NULL, "mode" character varying NOT NULL, "retryOf" character varying, "retrySuccessId" character varying, "startedAt" TIMESTAMP NOT NULL, "stoppedAt" TIMESTAMP NOT NULL, "workflowData" json NOT NULL, "workflowId" character varying, CONSTRAINT PK_${tablePrefixIndex}e3e63bbf986767844bbe1166d4e PRIMARY KEY ("id"))`, undefined); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS IDX_${tablePrefixIndex}c4d999a5e90784e8caccf5589d ON ${tablePrefix}execution_entity ("workflowId") `, undefined); + await queryRunner.query(`CREATE TABLE IF NOT EXISTS ${tablePrefix}workflow_entity ("id" SERIAL NOT NULL, "name" character varying(128) NOT NULL, "active" boolean NOT NULL, "nodes" json NOT NULL, "connections" json NOT NULL, "createdAt" TIMESTAMP NOT NULL, "updatedAt" TIMESTAMP NOT NULL, "settings" json, "staticData" json, CONSTRAINT PK_${tablePrefixIndex}eded7d72664448da7745d551207 PRIMARY KEY ("id"))`, undefined); } async down(queryRunner: QueryRunner): Promise { let tablePrefix = config.get('database.tablePrefix'); + const tablePrefixIndex = tablePrefix; const schema = config.get('database.postgresdb.schema'); if (schema) { tablePrefix = schema + '.' + tablePrefix; } await queryRunner.query(`DROP TABLE ${tablePrefix}workflow_entity`, undefined); - await queryRunner.query(`DROP INDEX IDX_c4d999a5e90784e8caccf5589d`, undefined); + await queryRunner.query(`DROP INDEX IDX_${tablePrefixIndex}c4d999a5e90784e8caccf5589d`, undefined); await queryRunner.query(`DROP TABLE ${tablePrefix}execution_entity`, undefined); - await queryRunner.query(`DROP INDEX IDX_07fde106c0b471d8cc80a64fc8`, undefined); + await queryRunner.query(`DROP INDEX IDX_${tablePrefixIndex}07fde106c0b471d8cc80a64fc8`, undefined); await queryRunner.query(`DROP TABLE ${tablePrefix}credentials_entity`, undefined); } diff --git a/packages/cli/src/databases/sqlite/migrations/1588102412422-InitialMigration.ts b/packages/cli/src/databases/sqlite/migrations/1588102412422-InitialMigration.ts index 31b271d633..c2bb55040e 100644 --- a/packages/cli/src/databases/sqlite/migrations/1588102412422-InitialMigration.ts +++ b/packages/cli/src/databases/sqlite/migrations/1588102412422-InitialMigration.ts @@ -9,9 +9,9 @@ export class InitialMigration1588102412422 implements MigrationInterface { const tablePrefix = config.get('database.tablePrefix'); await queryRunner.query(`CREATE TABLE IF NOT EXISTS "${tablePrefix}credentials_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar(128) NOT NULL, "data" text NOT NULL, "type" varchar(32) NOT NULL, "nodesAccess" text NOT NULL, "createdAt" datetime NOT NULL, "updatedAt" datetime NOT NULL)`, undefined); - await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_07fde106c0b471d8cc80a64fc8" ON "${tablePrefix}credentials_entity" ("type") `, undefined); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_${tablePrefix}07fde106c0b471d8cc80a64fc8" ON "${tablePrefix}credentials_entity" ("type") `, undefined); await queryRunner.query(`CREATE TABLE IF NOT EXISTS "${tablePrefix}execution_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "data" text NOT NULL, "finished" boolean NOT NULL, "mode" varchar NOT NULL, "retryOf" varchar, "retrySuccessId" varchar, "startedAt" datetime NOT NULL, "stoppedAt" datetime NOT NULL, "workflowData" text NOT NULL, "workflowId" varchar)`, undefined); - await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_c4d999a5e90784e8caccf5589d" ON "${tablePrefix}execution_entity" ("workflowId") `, undefined); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_${tablePrefix}c4d999a5e90784e8caccf5589d" ON "${tablePrefix}execution_entity" ("workflowId") `, undefined); await queryRunner.query(`CREATE TABLE IF NOT EXISTS "${tablePrefix}workflow_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar(128) NOT NULL, "active" boolean NOT NULL, "nodes" text NOT NULL, "connections" text NOT NULL, "createdAt" datetime NOT NULL, "updatedAt" datetime NOT NULL, "settings" text, "staticData" text)`, undefined); } @@ -19,9 +19,9 @@ export class InitialMigration1588102412422 implements MigrationInterface { const tablePrefix = config.get('database.tablePrefix'); await queryRunner.query(`DROP TABLE "${tablePrefix}workflow_entity"`, undefined); - await queryRunner.query(`DROP INDEX "IDX_c4d999a5e90784e8caccf5589d"`, undefined); + await queryRunner.query(`DROP INDEX "IDX_${tablePrefix}c4d999a5e90784e8caccf5589d"`, undefined); await queryRunner.query(`DROP TABLE "${tablePrefix}execution_entity"`, undefined); - await queryRunner.query(`DROP INDEX "IDX_07fde106c0b471d8cc80a64fc8"`, undefined); + await queryRunner.query(`DROP INDEX "IDX_${tablePrefix}07fde106c0b471d8cc80a64fc8"`, undefined); await queryRunner.query(`DROP TABLE "${tablePrefix}credentials_entity"`, undefined); } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 0b7ae6ad0a..3a6337a35d 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,4 +1,7 @@ +export * from './CredentialsHelper'; export * from './CredentialTypes'; +export * from './CredentialsOverwrites'; +export * from './ExternalHooks'; export * from './Interfaces'; export * from './LoadNodesAndCredentials'; export * from './NodeTypes'; diff --git a/packages/cli/templates/oauth-callback.html b/packages/cli/templates/oauth-callback.html new file mode 100644 index 0000000000..e479c5ea9e --- /dev/null +++ b/packages/cli/templates/oauth-callback.html @@ -0,0 +1,9 @@ + + + +Got connected. The window can be closed now. + diff --git a/packages/core/LICENSE.md b/packages/core/LICENSE.md index aac54547eb..24a7d38fc9 100644 --- a/packages/core/LICENSE.md +++ b/packages/core/LICENSE.md @@ -215,7 +215,7 @@ Licensor: n8n GmbH same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2020 n8n GmbH Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/packages/core/package.json b/packages/core/package.json index 3feab8f447..598ae14b23 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "n8n-core", - "version": "0.34.0", + "version": "0.36.0", "description": "Core functionality of n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", @@ -40,11 +40,12 @@ "typescript": "~3.7.4" }, "dependencies": { + "client-oauth2": "^4.2.5", "cron": "^1.7.2", "crypto-js": "3.1.9-1", "lodash.get": "^4.4.2", "mmmagic": "^0.5.2", - "n8n-workflow": "~0.31.0", + "n8n-workflow": "~0.32.0", "p-cancelable": "^2.0.0", "request": "^2.88.2", "request-promise-native": "^1.0.7" diff --git a/packages/core/src/Credentials.ts b/packages/core/src/Credentials.ts index 2559d78dda..e692f597cd 100644 --- a/packages/core/src/Credentials.ts +++ b/packages/core/src/Credentials.ts @@ -1,25 +1,14 @@ import { - ICredentialDataDecryptedObject, CredentialInformation, + ICredentialDataDecryptedObject, + ICredentials, ICredentialsEncrypted, - ICredentialNodeAccess, } from 'n8n-workflow'; import { enc, AES } from 'crypto-js'; -export class Credentials implements ICredentialsEncrypted { - name: string; - type: string; - data: string | undefined; - nodesAccess: ICredentialNodeAccess[]; - - constructor(name: string, type: string, nodesAccess: ICredentialNodeAccess[], data?: string) { - this.name = name; - this.type = type; - this.nodesAccess = nodesAccess; - this.data = data; - } +export class Credentials extends ICredentials { /** diff --git a/packages/core/src/Interfaces.ts b/packages/core/src/Interfaces.ts index cfbc19bb8e..5aa0e10a64 100644 --- a/packages/core/src/Interfaces.ts +++ b/packages/core/src/Interfaces.ts @@ -1,4 +1,5 @@ import { + IAllExecuteFunctions, IBinaryData, ICredentialType, IDataObject, @@ -17,6 +18,7 @@ import { } from 'n8n-workflow'; +import { OptionsWithUri, OptionsWithUrl } from 'request'; import * as requestPromise from 'request-promise-native'; interface Constructable { @@ -34,6 +36,8 @@ export interface IExecuteFunctions extends IExecuteFunctionsBase { helpers: { prepareBinaryData(binaryData: Buffer, filePath?: string, mimeType?: string): Promise; request: requestPromise.RequestPromiseAPI, + requestOAuth2(this: IAllExecuteFunctions, credentialsType: string, requestOptions: OptionsWithUri | requestPromise.RequestPromiseOptions): Promise, // tslint:disable-line:no-any + requestOAuth1(this: IAllExecuteFunctions, credentialsType: string, requestOptions: OptionsWithUrl | requestPromise.RequestPromiseOptions): Promise, // tslint:disable-line:no-any returnJsonArray(jsonData: IDataObject | IDataObject[]): INodeExecutionData[]; }; } @@ -43,6 +47,8 @@ export interface IExecuteSingleFunctions extends IExecuteSingleFunctionsBase { helpers: { prepareBinaryData(binaryData: Buffer, filePath?: string, mimeType?: string): Promise; request: requestPromise.RequestPromiseAPI, + requestOAuth2(this: IAllExecuteFunctions, credentialsType: string, requestOptions: OptionsWithUri | requestPromise.RequestPromiseOptions): Promise, // tslint:disable-line:no-any + requestOAuth1(this: IAllExecuteFunctions, credentialsType: string, requestOptions: OptionsWithUrl | requestPromise.RequestPromiseOptions): Promise, // tslint:disable-line:no-any }; } @@ -51,15 +57,24 @@ export interface IPollFunctions extends IPollFunctionsBase { helpers: { prepareBinaryData(binaryData: Buffer, filePath?: string, mimeType?: string): Promise; request: requestPromise.RequestPromiseAPI, + requestOAuth2(this: IAllExecuteFunctions, credentialsType: string, requestOptions: OptionsWithUri | requestPromise.RequestPromiseOptions): Promise, // tslint:disable-line:no-any + requestOAuth1(this: IAllExecuteFunctions, credentialsType: string, requestOptions: OptionsWithUrl | requestPromise.RequestPromiseOptions): Promise, // tslint:disable-line:no-any returnJsonArray(jsonData: IDataObject | IDataObject[]): INodeExecutionData[]; }; } +export interface IResponseError extends Error { + statusCode?: number; +} + + export interface ITriggerFunctions extends ITriggerFunctionsBase { helpers: { prepareBinaryData(binaryData: Buffer, filePath?: string, mimeType?: string): Promise; request: requestPromise.RequestPromiseAPI, + requestOAuth2(this: IAllExecuteFunctions, credentialsType: string, requestOptions: OptionsWithUri | requestPromise.RequestPromiseOptions): Promise, // tslint:disable-line:no-any + requestOAuth1(this: IAllExecuteFunctions, credentialsType: string, requestOptions: OptionsWithUrl | requestPromise.RequestPromiseOptions): Promise, // tslint:disable-line:no-any returnJsonArray(jsonData: IDataObject | IDataObject[]): INodeExecutionData[]; }; } @@ -83,6 +98,8 @@ export interface IUserSettings { export interface ILoadOptionsFunctions extends ILoadOptionsFunctionsBase { helpers: { request?: requestPromise.RequestPromiseAPI, + requestOAuth2?: (this: IAllExecuteFunctions, credentialsType: string, requestOptions: OptionsWithUri | requestPromise.RequestPromiseOptions) => Promise, // tslint:disable-line:no-any + requestOAuth1?(this: IAllExecuteFunctions, credentialsType: string, requestOptions: OptionsWithUrl | requestPromise.RequestPromiseOptions): Promise, // tslint:disable-line:no-any }; } @@ -90,6 +107,8 @@ export interface ILoadOptionsFunctions extends ILoadOptionsFunctionsBase { export interface IHookFunctions extends IHookFunctionsBase { helpers: { request: requestPromise.RequestPromiseAPI, + requestOAuth2(this: IAllExecuteFunctions, credentialsType: string, requestOptions: OptionsWithUri | requestPromise.RequestPromiseOptions): Promise, // tslint:disable-line:no-any + requestOAuth1(this: IAllExecuteFunctions, credentialsType: string, requestOptions: OptionsWithUrl | requestPromise.RequestPromiseOptions): Promise, // tslint:disable-line:no-any }; } @@ -98,6 +117,8 @@ export interface IWebhookFunctions extends IWebhookFunctionsBase { helpers: { prepareBinaryData(binaryData: Buffer, filePath?: string, mimeType?: string): Promise; request: requestPromise.RequestPromiseAPI, + requestOAuth2(this: IAllExecuteFunctions, credentialsType: string, requestOptions: OptionsWithUri | requestPromise.RequestPromiseOptions): Promise, // tslint:disable-line:no-any + requestOAuth1(this: IAllExecuteFunctions, credentialsType: string, requestOptions: OptionsWithUrl | requestPromise.RequestPromiseOptions): Promise, // tslint:disable-line:no-any returnJsonArray(jsonData: IDataObject | IDataObject[]): INodeExecutionData[]; }; } diff --git a/packages/core/src/LoadNodeParameterOptions.ts b/packages/core/src/LoadNodeParameterOptions.ts index 0da8669254..e561216065 100644 --- a/packages/core/src/LoadNodeParameterOptions.ts +++ b/packages/core/src/LoadNodeParameterOptions.ts @@ -1,6 +1,7 @@ import { INode, INodeCredentials, + INodeParameters, INodePropertyOptions, INodeTypes, IWorkflowExecuteAdditionalData, @@ -20,7 +21,7 @@ export class LoadNodeParameterOptions { workflow: Workflow; - constructor(nodeTypeName: string, nodeTypes: INodeTypes, credentials?: INodeCredentials) { + constructor(nodeTypeName: string, nodeTypes: INodeTypes, currentNodeParameters: INodeParameters, credentials?: INodeCredentials) { const nodeType = nodeTypes.getByName(nodeTypeName); if (nodeType === undefined) { @@ -28,8 +29,7 @@ export class LoadNodeParameterOptions { } const nodeData: INode = { - parameters: { - }, + parameters: currentNodeParameters, name: TEMP_NODE_NAME, type: nodeTypeName, typeVersion: 1, diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index 9cc9c776de..d76b9f9854 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -1,12 +1,13 @@ import { - Credentials, IHookFunctions, ILoadOptionsFunctions, + IResponseError, IWorkflowSettings, BINARY_ENCODING, } from './'; import { + IAllExecuteFunctions, IBinaryData, IContextObject, ICredentialDataDecryptedObject, @@ -35,13 +36,20 @@ import { WorkflowExecuteMode, } from 'n8n-workflow'; +import * as clientOAuth1 from 'oauth-1.0a'; +import { RequestOptions, Token } from 'oauth-1.0a'; +import * as clientOAuth2 from 'client-oauth2'; import { get } from 'lodash'; -import * as express from "express"; +import * as express from 'express'; import * as path from 'path'; +import { OptionsWithUrl, OptionsWithUri } from 'request'; import * as requestPromise from 'request-promise-native'; import { Magic, MAGIC_MIME_TYPE } from 'mmmagic'; +import { createHmac } from 'crypto'; + + const magic = new Magic(MAGIC_MIME_TYPE); @@ -102,6 +110,135 @@ export async function prepareBinaryData(binaryData: Buffer, filePath?: string, m +/** + * Makes a request using OAuth data for authentication + * + * @export + * @param {IAllExecuteFunctions} this + * @param {string} credentialsType + * @param {(OptionsWithUri | requestPromise.RequestPromiseOptions)} requestOptions + * @param {INode} node + * @param {IWorkflowExecuteAdditionalData} additionalData + * @returns + */ +export function requestOAuth2(this: IAllExecuteFunctions, credentialsType: string, requestOptions: OptionsWithUri | requestPromise.RequestPromiseOptions, node: INode, additionalData: IWorkflowExecuteAdditionalData, tokenType?: string, property?: string) { + const credentials = this.getCredentials(credentialsType) as ICredentialDataDecryptedObject; + + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + + if (credentials.oauthTokenData === undefined) { + throw new Error('OAuth credentials not connected!'); + } + + const oAuthClient = new clientOAuth2({ + clientId: credentials.clientId as string, + clientSecret: credentials.clientSecret as string, + accessTokenUri: credentials.accessTokenUrl as string, + }); + + const oauthTokenData = credentials.oauthTokenData as clientOAuth2.Data; + + const token = oAuthClient.createToken(get(oauthTokenData, property as string) || oauthTokenData.accessToken, oauthTokenData.refreshToken, tokenType || oauthTokenData.tokenType, oauthTokenData); + // Signs the request by adding authorization headers or query parameters depending + // on the token-type used. + const newRequestOptions = token.sign(requestOptions as clientOAuth2.RequestObject); + + return this.helpers.request!(newRequestOptions) + .catch(async (error: IResponseError) => { + // TODO: Check if also other codes are possible + if (error.statusCode === 401) { + // TODO: Whole refresh process is not tested yet + // Token is probably not valid anymore. So try refresh it. + const newToken = await token.refresh(); + + credentials.oauthTokenData = newToken.data; + + // Find the name of 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]; + + // Save the refreshed token + await additionalData.credentialsHelper.updateCredentials(name, credentialsType, credentials); + + // Make the request again with the new token + const newRequestOptions = newToken.sign(requestOptions as clientOAuth2.RequestObject); + + return this.helpers.request!(newRequestOptions); + } + + // Unknown error so simply throw it + throw error; + }); +} + +/* Makes a request using OAuth1 data for authentication +* +* @export +* @param {IAllExecuteFunctions} this +* @param {string} credentialsType +* @param {(OptionsWithUrl | requestPromise.RequestPromiseOptions)} requestOptionså +* @returns +*/ +export function requestOAuth1(this: IAllExecuteFunctions, credentialsType: string, requestOptions: OptionsWithUrl | requestPromise.RequestPromiseOptions) { + const credentials = this.getCredentials(credentialsType) as ICredentialDataDecryptedObject; + + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + + if (credentials.oauthTokenData === undefined) { + throw new Error('OAuth credentials not connected!'); + } + + const oauth = new clientOAuth1({ + consumer: { + key: credentials.consumerKey as string, + secret: credentials.consumerSecret as string, + }, + signature_method: credentials.signatureMethod as string, + hash_function(base, key) { + const algorithm = (credentials.signatureMethod === 'HMAC-SHA1') ? 'sha1' : 'sha256'; + return createHmac(algorithm, key) + .update(base) + .digest('base64'); + }, + }); + + const oauthTokenData = credentials.oauthTokenData as IDataObject; + + const token: Token = { + key: oauthTokenData.oauth_token as string, + secret: oauthTokenData.oauth_token_secret as string, + }; + + const newRequestOptions = { + //@ts-ignore + url: requestOptions.url, + method: requestOptions.method, + data: { ...requestOptions.qs, ...requestOptions.body }, + json: requestOptions.json, + }; + + if (Object.keys(requestOptions.qs).length !== 0) { + //@ts-ignore + newRequestOptions.qs = oauth.authorize(newRequestOptions as RequestOptions, token); + } else { + //@ts-ignore + newRequestOptions.form = oauth.authorize(newRequestOptions as RequestOptions, token); + } + + return this.helpers.request!(newRequestOptions) + .catch(async (error: IResponseError) => { + // Unknown error so simply throw it + throw error; + }); +} + + /** * Takes generic input data and brings it into the json format n8n uses. * @@ -177,20 +314,7 @@ export function getCredentials(workflow: Workflow, node: INode, type: string, ad const name = node.credentials[type]; - if (!additionalData.credentials[type]) { - throw new Error(`No credentials of type "${type}" exist.`); - } - if (!additionalData.credentials[type][name]) { - throw new Error(`No credentials with name "${name}" exist for type "${type}".`); - } - const credentialData = additionalData.credentials[type][name]; - - const credentials = new Credentials(name, type, credentialData.nodesAccess, credentialData.data); - const decryptedDataObject = credentials.getData(additionalData.encryptionKey, node.type); - - if (decryptedDataObject === null) { - throw new Error('Could not get the credentials'); - } + const decryptedDataObject = additionalData.credentialsHelper.getDecrypted(name, type); return decryptedDataObject; } @@ -406,6 +530,12 @@ export function getExecutePollFunctions(workflow: Workflow, node: INode, additio helpers: { prepareBinaryData, request: requestPromise, + requestOAuth2(this: IAllExecuteFunctions, credentialsType: string, requestOptions: OptionsWithUri | requestPromise.RequestPromiseOptions, tokenType?: string, property?: string): Promise { // tslint:disable-line:no-any + return requestOAuth2.call(this, credentialsType, requestOptions, node, additionalData, tokenType, property); + }, + requestOAuth1(this: IAllExecuteFunctions, credentialsType: string, requestOptions: OptionsWithUrl | requestPromise.RequestPromiseOptions): Promise { // tslint:disable-line:no-any + return requestOAuth1.call(this, credentialsType, requestOptions); + }, returnJsonArray, }, }; @@ -463,6 +593,12 @@ export function getExecuteTriggerFunctions(workflow: Workflow, node: INode, addi helpers: { prepareBinaryData, request: requestPromise, + requestOAuth2(this: IAllExecuteFunctions, credentialsType: string, requestOptions: OptionsWithUri | requestPromise.RequestPromiseOptions, tokenType?: string, property?: string): Promise { // tslint:disable-line:no-any + return requestOAuth2.call(this, credentialsType, requestOptions, node, additionalData, tokenType, property); + }, + requestOAuth1(this: IAllExecuteFunctions, credentialsType: string, requestOptions: OptionsWithUrl | requestPromise.RequestPromiseOptions): Promise { // tslint:disable-line:no-any + return requestOAuth1.call(this, credentialsType, requestOptions); + }, returnJsonArray, }, }; @@ -553,6 +689,12 @@ export function getExecuteFunctions(workflow: Workflow, runExecutionData: IRunEx helpers: { prepareBinaryData, request: requestPromise, + requestOAuth2(this: IAllExecuteFunctions, credentialsType: string, requestOptions: OptionsWithUri | requestPromise.RequestPromiseOptions, tokenType?: string, property?: string): Promise { // tslint:disable-line:no-any + return requestOAuth2.call(this, credentialsType, requestOptions, node, additionalData, tokenType, property); + }, + requestOAuth1(this: IAllExecuteFunctions, credentialsType: string, requestOptions: OptionsWithUrl | requestPromise.RequestPromiseOptions): Promise { // tslint:disable-line:no-any + return requestOAuth1.call(this, credentialsType, requestOptions); + }, returnJsonArray, }, }; @@ -645,6 +787,12 @@ export function getExecuteSingleFunctions(workflow: Workflow, runExecutionData: helpers: { prepareBinaryData, request: requestPromise, + requestOAuth2(this: IAllExecuteFunctions, credentialsType: string, requestOptions: OptionsWithUri | requestPromise.RequestPromiseOptions, tokenType?: string, property?: string): Promise { // tslint:disable-line:no-any + return requestOAuth2.call(this, credentialsType, requestOptions, node, additionalData, tokenType, property); + }, + requestOAuth1(this: IAllExecuteFunctions, credentialsType: string, requestOptions: OptionsWithUrl | requestPromise.RequestPromiseOptions): Promise { // tslint:disable-line:no-any + return requestOAuth1.call(this, credentialsType, requestOptions); + }, }, }; })(workflow, runExecutionData, connectionInputData, inputData, node, itemIndex); @@ -695,6 +843,12 @@ export function getLoadOptionsFunctions(workflow: Workflow, node: INode, additio }, helpers: { request: requestPromise, + requestOAuth2(this: IAllExecuteFunctions, credentialsType: string, requestOptions: OptionsWithUri | requestPromise.RequestPromiseOptions, tokenType?: string, property?: string): Promise { // tslint:disable-line:no-any + return requestOAuth2.call(this, credentialsType, requestOptions, node, additionalData, tokenType, property); + }, + requestOAuth1(this: IAllExecuteFunctions, credentialsType: string, requestOptions: OptionsWithUrl | requestPromise.RequestPromiseOptions): Promise { // tslint:disable-line:no-any + return requestOAuth1.call(this, credentialsType, requestOptions); + }, }, }; return that; @@ -756,6 +910,12 @@ export function getExecuteHookFunctions(workflow: Workflow, node: INode, additio }, helpers: { request: requestPromise, + requestOAuth2(this: IAllExecuteFunctions, credentialsType: string, requestOptions: OptionsWithUri | requestPromise.RequestPromiseOptions, tokenType?: string, property?: string): Promise { // tslint:disable-line:no-any + return requestOAuth2.call(this, credentialsType, requestOptions, node, additionalData, tokenType, property); + }, + requestOAuth1(this: IAllExecuteFunctions, credentialsType: string, requestOptions: OptionsWithUrl | requestPromise.RequestPromiseOptions): Promise { // tslint:disable-line:no-any + return requestOAuth1.call(this, credentialsType, requestOptions); + }, }, }; return that; @@ -844,6 +1004,12 @@ export function getExecuteWebhookFunctions(workflow: Workflow, node: INode, addi helpers: { prepareBinaryData, request: requestPromise, + requestOAuth2(this: IAllExecuteFunctions, credentialsType: string, requestOptions: OptionsWithUri | requestPromise.RequestPromiseOptions, tokenType?: string, property?: string): Promise { // tslint:disable-line:no-any + return requestOAuth2.call(this, credentialsType, requestOptions, node, additionalData, tokenType, property); + }, + requestOAuth1(this: IAllExecuteFunctions, credentialsType: string, requestOptions: OptionsWithUrl | requestPromise.RequestPromiseOptions): Promise { // tslint:disable-line:no-any + return requestOAuth1.call(this, credentialsType, requestOptions); + }, returnJsonArray, }, }; diff --git a/packages/core/src/UserSettings.ts b/packages/core/src/UserSettings.ts index af32d5e36c..211341ed25 100644 --- a/packages/core/src/UserSettings.ts +++ b/packages/core/src/UserSettings.ts @@ -143,7 +143,6 @@ export async function writeUserSettings(userSettings: IUserSettings, settingsPat */ export async function getUserSettings(settingsPath?: string, ignoreCache?: boolean): Promise { if (settingsCache !== undefined && ignoreCache !== true) { - return settingsCache; } diff --git a/packages/core/test/Helpers.ts b/packages/core/test/Helpers.ts index a0a68f4a42..790025dcda 100644 --- a/packages/core/test/Helpers.ts +++ b/packages/core/test/Helpers.ts @@ -1,6 +1,8 @@ import { set } from 'lodash'; import { + ICredentialDataDecryptedObject, + ICredentialsHelper, IExecuteWorkflowInfo, INodeExecutionData, INodeParameters, @@ -15,11 +17,25 @@ import { } from 'n8n-workflow'; import { + Credentials, IDeferredPromise, IExecuteFunctions, } from '../src'; +export class CredentialsHelper extends ICredentialsHelper { + getDecrypted(name: string, type: string): ICredentialDataDecryptedObject { + return {}; + } + + getCredentials(name: string, type: string): Credentials { + return new Credentials('', '', [], ''); + } + + async updateCredentials(name: string, type: string, data: ICredentialDataDecryptedObject): Promise {} +} + + class NodeTypesClass implements INodeTypes { nodeTypes: INodeTypeData = { @@ -275,6 +291,7 @@ export function WorkflowExecuteAdditionalData(waitPromise: IDeferredPromise => {}, // tslint:disable-line:no-any restApiUrl: '', diff --git a/packages/editor-ui/LICENSE.md b/packages/editor-ui/LICENSE.md index aac54547eb..24a7d38fc9 100644 --- a/packages/editor-ui/LICENSE.md +++ b/packages/editor-ui/LICENSE.md @@ -215,7 +215,7 @@ Licensor: n8n GmbH same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2020 n8n GmbH Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index 783a6ef1dc..aca35d6005 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -1,6 +1,6 @@ { "name": "n8n-editor-ui", - "version": "0.45.0", + "version": "0.47.0", "description": "Workflow Editor UI for n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", @@ -66,7 +66,7 @@ "lodash.debounce": "^4.0.8", "lodash.get": "^4.4.2", "lodash.set": "^4.3.2", - "n8n-workflow": "~0.31.0", + "n8n-workflow": "~0.33.0", "node-sass": "^4.12.0", "prismjs": "^1.17.1", "quill": "^2.0.0-dev.3", diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index e6e34fb9d5..63ea4223a9 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -145,6 +145,9 @@ export interface IRestApi { deleteExecutions(sendData: IExecutionDeleteFilter): Promise; retryExecution(id: string, loadWorkflow?: boolean): Promise; getTimezones(): Promise; + oAuth1CredentialAuthorize(sendData: ICredentialsResponse): Promise; + oAuth2CredentialAuthorize(sendData: ICredentialsResponse): Promise; + oAuth2Callback(code: string, state: string): Promise; } export interface IBinaryDisplayData { @@ -155,6 +158,13 @@ export interface IBinaryDisplayData { runIndex: number; } +export interface ICredentialsCreatedEvent { + data: ICredentialsDecryptedResponse; + options: { + closeDialog: boolean, + }; +} + export interface IStartRunData { workflowData: IWorkflowData; startNodes?: string[]; diff --git a/packages/editor-ui/src/components/CredentialsEdit.vue b/packages/editor-ui/src/components/CredentialsEdit.vue index 7be7a1326a..a69ad78e24 100644 --- a/packages/editor-ui/src/components/CredentialsEdit.vue +++ b/packages/editor-ui/src/components/CredentialsEdit.vue @@ -8,7 +8,7 @@ Credential type: - + -
-
+
Credential Data:
- - +
+ {{parameter.displayName}}: @@ -34,7 +33,46 @@ - +
+ + + + OAuth + + + + + + + Not all required credential properties are filled + + + + + + Is connected + + + + + + Is NOT connected + + +
+
+ + OAuth Callback URL +
+ +
+ {{oAuthCallbackUrl}} +
+
+
+ +
+
@@ -61,10 +99,10 @@
- + Save - + Create
@@ -75,11 +113,16 @@