From 9cb9804eeec1576d935817ecda6bd345480b97fa Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Sun, 23 Jun 2019 12:35:23 +0200 Subject: [PATCH] Initial commit to release --- .editorconfig | 8 + .gitignore | 11 + LICENSE | 230 ++ README.md | 33 + docker/compose/withMongo/.env | 6 + docker/compose/withMongo/README.md | 26 + docker/compose/withMongo/docker-compose.yml | 28 + docker/compose/withMongo/init-data.sh | 17 + docker/images/n8n/Dockerfile | 18 + docker/images/n8n/README.md | 84 + lerna.json | 6 + package.json | 14 + packages/cli/LICENSE | 230 ++ packages/cli/README.md | 47 + packages/cli/commands/run.ts | 151 ++ packages/cli/commands/start.ts | 182 ++ packages/cli/config/default.ts | 30 + packages/cli/index.ts | 58 + packages/cli/package.json | 93 + packages/cli/src/ActiveWorkflowRunner.ts | 314 +++ packages/cli/src/CredentialTypes.ts | 37 + packages/cli/src/Db.ts | 69 + packages/cli/src/GenericHelpers.ts | 48 + packages/cli/src/Interfaces.ts | 248 ++ packages/cli/src/LoadNodesAndCredentials.ts | 261 ++ packages/cli/src/NodeTypes.ts | 37 + packages/cli/src/Push.ts | 86 + packages/cli/src/ResponseHelper.ts | 176 ++ packages/cli/src/Server.ts | 997 ++++++++ packages/cli/src/TestWebhooks.ts | 208 ++ packages/cli/src/WebhookHelpers.ts | 334 +++ packages/cli/src/WorkflowCredentials.ts | 38 + .../cli/src/WorkflowExecuteAdditionalData.ts | 210 ++ packages/cli/src/WorkflowHelpers.ts | 152 ++ packages/cli/src/db/index.ts | 7 + .../cli/src/db/mongodb/CredentialsEntity.ts | 41 + .../cli/src/db/mongodb/ExecutionEntity.ts | 51 + packages/cli/src/db/mongodb/WorkflowEntity.ts | 48 + packages/cli/src/db/mongodb/index.ts | 3 + .../cli/src/db/sqlite/CredentialsEntity.ts | 44 + packages/cli/src/db/sqlite/ExecutionEntity.ts | 53 + packages/cli/src/db/sqlite/WorkflowEntity.ts | 55 + packages/cli/src/db/sqlite/index.ts | 3 + packages/cli/src/index.ts | 29 + packages/cli/tsconfig.json | 39 + packages/cli/tslint.json | 103 + packages/core/LICENSE | 230 ++ packages/core/README.md | 13 + packages/core/package.json | 60 + packages/core/src/ActiveExecutions.ts | 172 ++ packages/core/src/ActiveWebhooks.ts | 175 ++ packages/core/src/ActiveWorkflows.ts | 112 + packages/core/src/Constants.ts | 7 + packages/core/src/Credentials.ts | 120 + packages/core/src/DeferredPromise.ts | 14 + packages/core/src/Interfaces.ts | 115 + packages/core/src/LoadNodeParameterOptions.ts | 97 + packages/core/src/NodeExecuteFunctions.ts | 639 +++++ packages/core/src/UserSettings.ts | 234 ++ packages/core/src/WorkflowExecute.ts | 579 +++++ packages/core/src/index.ts | 24 + packages/core/test/Credentials.test.ts | 88 + packages/core/tsconfig.json | 34 + packages/core/tslint.json | 103 + packages/editor-ui/.browserslistrc | 3 + packages/editor-ui/.editorconfig | 8 + packages/editor-ui/.eslintrc.js | 23 + packages/editor-ui/.gitignore | 24 + packages/editor-ui/.npmignore | 33 + packages/editor-ui/LICENSE | 230 ++ packages/editor-ui/README.md | 52 + packages/editor-ui/babel.config.js | 10 + packages/editor-ui/cypress.json | 3 + packages/editor-ui/jest.config.js | 25 + packages/editor-ui/package.json | 70 + packages/editor-ui/postcss.config.js | 5 + packages/editor-ui/public/favicon-16x16.png | Bin 0 -> 342 bytes packages/editor-ui/public/favicon-32x32.png | Bin 0 -> 806 bytes packages/editor-ui/public/favicon.ico | Bin 0 -> 5430 bytes packages/editor-ui/public/index.html | 17 + packages/editor-ui/public/n8n-icon-small.png | Bin 0 -> 1161 bytes packages/editor-ui/src/App.vue | 47 + packages/editor-ui/src/Interface.ts | 365 +++ packages/editor-ui/src/assets/logo.png | Bin 0 -> 6849 bytes .../src/components/BinaryDataDisplay.vue | 135 + .../src/components/CollectionParameter.vue | 193 ++ .../src/components/CredentialsEdit.vue | 217 ++ .../src/components/CredentialsInput.vue | 270 ++ .../src/components/CredentialsList.vue | 184 ++ .../editor-ui/src/components/DataDisplay.vue | 103 + .../src/components/DisplayWithChange.vue | 121 + .../src/components/ExecutionsList.vue | 624 +++++ .../src/components/ExpressionEdit.vue | 147 ++ .../src/components/ExpressionInput.vue | 360 +++ .../components/FixedCollectionParameter.vue | 235 ++ .../editor-ui/src/components/MainHeader.vue | 314 +++ .../editor-ui/src/components/MainSidebar.vue | 459 ++++ .../src/components/MultipleParameter.vue | 171 ++ packages/editor-ui/src/components/Node.vue | 406 +++ .../src/components/NodeCreateItem.vue | 79 + .../src/components/NodeCreateList.vue | 160 ++ .../editor-ui/src/components/NodeCreator.vue | 104 + .../src/components/NodeCredentials.vue | 316 +++ .../editor-ui/src/components/NodeIcon.vue | 80 + .../editor-ui/src/components/NodeSettings.vue | 540 ++++ .../editor-ui/src/components/NodeWebhooks.vue | 222 ++ .../src/components/PageContentWrapper.vue | 39 + .../src/components/ParameterInput.vue | 633 +++++ .../src/components/ParameterInputFull.vue | 96 + .../src/components/ParameterInputList.vue | 228 ++ packages/editor-ui/src/components/RunData.vue | 628 +++++ .../editor-ui/src/components/TextEdit.vue | 66 + .../src/components/VariableSelector.vue | 590 +++++ .../src/components/VariableSelectorItem.vue | 153 ++ .../src/components/WorkflowActivator.vue | 179 ++ .../editor-ui/src/components/WorkflowOpen.vue | 140 ++ .../src/components/WorkflowSettings.vue | 283 +++ .../src/components/mixins/copyPaste.ts | 201 ++ .../src/components/mixins/genericHelpers.ts | 77 + .../src/components/mixins/mouseSelect.ts | 162 ++ .../src/components/mixins/moveNodeWorkflow.ts | 72 + .../src/components/mixins/nodeBase.ts | 293 +++ .../src/components/mixins/nodeHelpers.ts | 251 ++ .../src/components/mixins/nodeIndex.ts | 18 + .../src/components/mixins/pushConnection.ts | 178 ++ .../src/components/mixins/restApi.ts | 292 +++ .../src/components/mixins/showMessage.ts | 26 + .../src/components/mixins/workflowHelpers.ts | 432 ++++ .../src/components/mixins/workflowRun.ts | 175 ++ packages/editor-ui/src/constants.ts | 2 + packages/editor-ui/src/main.ts | 150 ++ .../editor-ui/src/n8n-theme-variables.scss | 37 + packages/editor-ui/src/n8n-theme.scss | 457 ++++ packages/editor-ui/src/router.ts | 45 + packages/editor-ui/src/shims-tsx.d.ts | 13 + packages/editor-ui/src/shims-vue.d.ts | 4 + packages/editor-ui/src/store.ts | 751 ++++++ packages/editor-ui/src/views/NodeView.vue | 1844 ++++++++++++++ packages/editor-ui/tests/e2e/.eslintrc.js | 12 + packages/editor-ui/tests/e2e/plugins/index.js | 24 + packages/editor-ui/tests/e2e/specs/test.js | 8 + .../editor-ui/tests/e2e/support/commands.js | 25 + packages/editor-ui/tests/e2e/support/index.js | 20 + packages/editor-ui/tests/unit/.eslintrc.js | 5 + packages/editor-ui/tests/unit/example.spec.ts | 12 + packages/editor-ui/tsconfig.json | 44 + packages/editor-ui/tslint.json | 103 + packages/editor-ui/vue.config.js | 32 + packages/node-dev/LICENSE | 230 ++ packages/node-dev/README.md | 41 + packages/node-dev/commands/build.ts | 49 + packages/node-dev/commands/new.ts | 160 ++ packages/node-dev/index.ts | 40 + packages/node-dev/package.json | 44 + packages/node-dev/src/Build.ts | 63 + packages/node-dev/src/Create.ts | 36 + packages/node-dev/src/Interfaces.ts | 4 + packages/node-dev/src/index.ts | 3 + packages/node-dev/src/tsconfig-build.json | 27 + .../node-dev/templates/credentials/simple.ts | 27 + packages/node-dev/templates/execute/simple.ts | 57 + packages/node-dev/templates/trigger/simple.ts | 83 + packages/node-dev/templates/webhook/simple.ts | 70 + packages/node-dev/tsconfig.json | 31 + packages/node-dev/tslint.json | 103 + packages/nodes-base/LICENSE | 230 ++ packages/nodes-base/README.md | 14 + .../credentials/AsanaApi.credentials.ts | 18 + .../credentials/ChargebeeApi.credentials.ts | 24 + .../credentials/DropboxApi.credentials.ts | 18 + .../credentials/GithubApi.credentials.ts | 24 + .../credentials/GoogleApi.credentials.ts | 26 + .../credentials/HttpBasicAuth.credentials.ts | 28 + .../credentials/HttpHeaderAuth.credentials.ts | 25 + .../credentials/Imap.credentials.ts | 46 + .../credentials/LinkFishApi.credentials.ts | 25 + .../credentials/MailgunApi.credentials.ts | 42 + .../credentials/NextCloudApi.credentials.ts | 32 + .../OpenWeatherMapApi.credentials.ts | 18 + .../credentials/Redis.credentials.ts | 33 + .../credentials/SlackApi.credentials.ts | 18 + .../credentials/Smtp.credentials.ts | 46 + .../credentials/TwilioApi.credentials.ts | 24 + packages/nodes-base/gulpfile.js | 8 + packages/nodes-base/nodes/Asana/Asana.node.ts | 422 ++++ .../nodes/Asana/AsanaTrigger.node.ts | 184 ++ .../nodes/Asana/GenericFunctions.ts | 56 + packages/nodes-base/nodes/Asana/asana.png | Bin 0 -> 2805 bytes .../nodes/Chargebee/Chargebee.node.ts | 507 ++++ .../nodes/Chargebee/ChargebeeTrigger.node.ts | 245 ++ .../nodes-base/nodes/Chargebee/chargebee.png | Bin 0 -> 1427 bytes packages/nodes-base/nodes/Cron.node.ts | 236 ++ .../nodes-base/nodes/Dropbox/Dropbox.node.ts | 542 ++++ packages/nodes-base/nodes/Dropbox/dropbox.png | Bin 0 -> 2559 bytes packages/nodes-base/nodes/EditImage.node.ts | 554 ++++ .../nodes-base/nodes/EmailReadImap.node.ts | 261 ++ packages/nodes-base/nodes/EmailSend.node.ts | 158 ++ .../nodes-base/nodes/ErrorTrigger.node.ts | 54 + .../nodes-base/nodes/ExecuteCommand.node.ts | 117 + packages/nodes-base/nodes/Function.node.ts | 71 + .../nodes-base/nodes/FunctionItem.node.ts | 82 + .../nodes/Github/GenericFunctions.ts | 80 + .../nodes-base/nodes/Github/Github.node.ts | 1152 +++++++++ .../nodes/Github/GithubTrigger.node.ts | 427 ++++ packages/nodes-base/nodes/Github/github.png | Bin 0 -> 2231 bytes .../nodes/GoogleSheets/GoogleSheets.node.ts | 220 ++ .../nodes/GoogleSheets/googlesheets.png | Bin 0 -> 1805 bytes packages/nodes-base/nodes/HttpRequest.node.ts | 444 ++++ packages/nodes-base/nodes/If.node.ts | 302 +++ packages/nodes-base/nodes/Interval.node.ts | 96 + .../nodes/LinkFish/LinkFish.node.ts | 338 +++ .../nodes-base/nodes/LinkFish/linkfish.png | Bin 0 -> 5766 bytes packages/nodes-base/nodes/Mailgun.node.ts | 184 ++ packages/nodes-base/nodes/Merge.node.ts | 154 ++ .../nodes/NextCloud/NextCloud.node.ts | 534 ++++ .../nodes-base/nodes/NextCloud/nextcloud.png | Bin 0 -> 1688 bytes packages/nodes-base/nodes/NoOp.node.ts | 32 + .../nodes-base/nodes/OpenWeatherMap.node.ts | 265 ++ .../nodes-base/nodes/ReadBinaryFile.node.ts | 71 + .../nodes-base/nodes/ReadBinaryFiles.node.ts | 74 + .../nodes-base/nodes/ReadFileFromUrl.node.ts | 80 + packages/nodes-base/nodes/ReadPdf.node.ts | 56 + packages/nodes-base/nodes/Redis/Redis.node.ts | 458 ++++ packages/nodes-base/nodes/Redis/redis.png | Bin 0 -> 2832 bytes packages/nodes-base/nodes/RenameKeys.node.ts | 100 + packages/nodes-base/nodes/RssFeedRead.node.ts | 74 + packages/nodes-base/nodes/Set.node.ts | 145 ++ packages/nodes-base/nodes/Slack/Slack.node.ts | 574 +++++ packages/nodes-base/nodes/Slack/slack.png | Bin 0 -> 2104 bytes .../nodes-base/nodes/SplitInBatches.node.ts | 71 + .../nodes-base/nodes/SpreadsheetFile.node.ts | 271 ++ packages/nodes-base/nodes/Start.node.ts | 32 + .../nodes/Twilio/GenericFunctions.ts | 62 + .../nodes-base/nodes/Twilio/Twilio.node.ts | 171 ++ packages/nodes-base/nodes/Twilio/twilio.png | Bin 0 -> 3522 bytes packages/nodes-base/nodes/Webhook.node.ts | 258 ++ .../nodes-base/nodes/WriteBinaryFile.node.ts | 76 + packages/nodes-base/package.json | 139 + packages/nodes-base/src/GoogleSheet.ts | 356 +++ packages/nodes-base/src/index.ts | 1 + packages/nodes-base/tsconfig.json | 30 + packages/nodes-base/tslint.json | 103 + packages/workflow/LICENSE | 230 ++ packages/workflow/README.md | 13 + packages/workflow/package.json | 52 + packages/workflow/src/Interfaces.ts | 552 ++++ packages/workflow/src/NodeHelpers.ts | 703 ++++++ packages/workflow/src/ObservableObject.ts | 54 + packages/workflow/src/Workflow.ts | 1021 ++++++++ packages/workflow/src/WorkflowDataProxy.ts | 280 +++ packages/workflow/src/index.ts | 10 + packages/workflow/test/Helpers.ts | 109 + packages/workflow/test/NodeHelpers.test.ts | 2240 +++++++++++++++++ .../workflow/test/ObservableObject.test.ts | 169 ++ packages/workflow/test/Workflow.test.ts | 1245 +++++++++ packages/workflow/tsconfig.json | 35 + packages/workflow/tslint.json | 103 + 257 files changed, 42436 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 docker/compose/withMongo/.env create mode 100644 docker/compose/withMongo/README.md create mode 100644 docker/compose/withMongo/docker-compose.yml create mode 100755 docker/compose/withMongo/init-data.sh create mode 100644 docker/images/n8n/Dockerfile create mode 100644 docker/images/n8n/README.md create mode 100644 lerna.json create mode 100644 package.json create mode 100644 packages/cli/LICENSE create mode 100644 packages/cli/README.md create mode 100644 packages/cli/commands/run.ts create mode 100644 packages/cli/commands/start.ts create mode 100644 packages/cli/config/default.ts create mode 100644 packages/cli/index.ts create mode 100644 packages/cli/package.json create mode 100644 packages/cli/src/ActiveWorkflowRunner.ts create mode 100644 packages/cli/src/CredentialTypes.ts create mode 100644 packages/cli/src/Db.ts create mode 100644 packages/cli/src/GenericHelpers.ts create mode 100644 packages/cli/src/Interfaces.ts create mode 100644 packages/cli/src/LoadNodesAndCredentials.ts create mode 100644 packages/cli/src/NodeTypes.ts create mode 100644 packages/cli/src/Push.ts create mode 100644 packages/cli/src/ResponseHelper.ts create mode 100644 packages/cli/src/Server.ts create mode 100644 packages/cli/src/TestWebhooks.ts create mode 100644 packages/cli/src/WebhookHelpers.ts create mode 100644 packages/cli/src/WorkflowCredentials.ts create mode 100644 packages/cli/src/WorkflowExecuteAdditionalData.ts create mode 100644 packages/cli/src/WorkflowHelpers.ts create mode 100644 packages/cli/src/db/index.ts create mode 100644 packages/cli/src/db/mongodb/CredentialsEntity.ts create mode 100644 packages/cli/src/db/mongodb/ExecutionEntity.ts create mode 100644 packages/cli/src/db/mongodb/WorkflowEntity.ts create mode 100644 packages/cli/src/db/mongodb/index.ts create mode 100644 packages/cli/src/db/sqlite/CredentialsEntity.ts create mode 100644 packages/cli/src/db/sqlite/ExecutionEntity.ts create mode 100644 packages/cli/src/db/sqlite/WorkflowEntity.ts create mode 100644 packages/cli/src/db/sqlite/index.ts create mode 100644 packages/cli/src/index.ts create mode 100644 packages/cli/tsconfig.json create mode 100644 packages/cli/tslint.json create mode 100644 packages/core/LICENSE create mode 100644 packages/core/README.md create mode 100644 packages/core/package.json create mode 100644 packages/core/src/ActiveExecutions.ts create mode 100644 packages/core/src/ActiveWebhooks.ts create mode 100644 packages/core/src/ActiveWorkflows.ts create mode 100644 packages/core/src/Constants.ts create mode 100644 packages/core/src/Credentials.ts create mode 100644 packages/core/src/DeferredPromise.ts create mode 100644 packages/core/src/Interfaces.ts create mode 100644 packages/core/src/LoadNodeParameterOptions.ts create mode 100644 packages/core/src/NodeExecuteFunctions.ts create mode 100644 packages/core/src/UserSettings.ts create mode 100644 packages/core/src/WorkflowExecute.ts create mode 100644 packages/core/src/index.ts create mode 100644 packages/core/test/Credentials.test.ts create mode 100644 packages/core/tsconfig.json create mode 100644 packages/core/tslint.json create mode 100644 packages/editor-ui/.browserslistrc create mode 100644 packages/editor-ui/.editorconfig create mode 100644 packages/editor-ui/.eslintrc.js create mode 100644 packages/editor-ui/.gitignore create mode 100644 packages/editor-ui/.npmignore create mode 100644 packages/editor-ui/LICENSE create mode 100644 packages/editor-ui/README.md create mode 100644 packages/editor-ui/babel.config.js create mode 100644 packages/editor-ui/cypress.json create mode 100644 packages/editor-ui/jest.config.js create mode 100644 packages/editor-ui/package.json create mode 100644 packages/editor-ui/postcss.config.js create mode 100644 packages/editor-ui/public/favicon-16x16.png create mode 100644 packages/editor-ui/public/favicon-32x32.png create mode 100644 packages/editor-ui/public/favicon.ico create mode 100644 packages/editor-ui/public/index.html create mode 100644 packages/editor-ui/public/n8n-icon-small.png create mode 100644 packages/editor-ui/src/App.vue create mode 100644 packages/editor-ui/src/Interface.ts create mode 100644 packages/editor-ui/src/assets/logo.png create mode 100644 packages/editor-ui/src/components/BinaryDataDisplay.vue create mode 100644 packages/editor-ui/src/components/CollectionParameter.vue create mode 100644 packages/editor-ui/src/components/CredentialsEdit.vue create mode 100644 packages/editor-ui/src/components/CredentialsInput.vue create mode 100644 packages/editor-ui/src/components/CredentialsList.vue create mode 100644 packages/editor-ui/src/components/DataDisplay.vue create mode 100644 packages/editor-ui/src/components/DisplayWithChange.vue create mode 100644 packages/editor-ui/src/components/ExecutionsList.vue create mode 100644 packages/editor-ui/src/components/ExpressionEdit.vue create mode 100644 packages/editor-ui/src/components/ExpressionInput.vue create mode 100644 packages/editor-ui/src/components/FixedCollectionParameter.vue create mode 100644 packages/editor-ui/src/components/MainHeader.vue create mode 100644 packages/editor-ui/src/components/MainSidebar.vue create mode 100644 packages/editor-ui/src/components/MultipleParameter.vue create mode 100644 packages/editor-ui/src/components/Node.vue create mode 100644 packages/editor-ui/src/components/NodeCreateItem.vue create mode 100644 packages/editor-ui/src/components/NodeCreateList.vue create mode 100644 packages/editor-ui/src/components/NodeCreator.vue create mode 100644 packages/editor-ui/src/components/NodeCredentials.vue create mode 100644 packages/editor-ui/src/components/NodeIcon.vue create mode 100644 packages/editor-ui/src/components/NodeSettings.vue create mode 100644 packages/editor-ui/src/components/NodeWebhooks.vue create mode 100644 packages/editor-ui/src/components/PageContentWrapper.vue create mode 100644 packages/editor-ui/src/components/ParameterInput.vue create mode 100644 packages/editor-ui/src/components/ParameterInputFull.vue create mode 100644 packages/editor-ui/src/components/ParameterInputList.vue create mode 100644 packages/editor-ui/src/components/RunData.vue create mode 100644 packages/editor-ui/src/components/TextEdit.vue create mode 100644 packages/editor-ui/src/components/VariableSelector.vue create mode 100644 packages/editor-ui/src/components/VariableSelectorItem.vue create mode 100644 packages/editor-ui/src/components/WorkflowActivator.vue create mode 100644 packages/editor-ui/src/components/WorkflowOpen.vue create mode 100644 packages/editor-ui/src/components/WorkflowSettings.vue create mode 100644 packages/editor-ui/src/components/mixins/copyPaste.ts create mode 100644 packages/editor-ui/src/components/mixins/genericHelpers.ts create mode 100644 packages/editor-ui/src/components/mixins/mouseSelect.ts create mode 100644 packages/editor-ui/src/components/mixins/moveNodeWorkflow.ts create mode 100644 packages/editor-ui/src/components/mixins/nodeBase.ts create mode 100644 packages/editor-ui/src/components/mixins/nodeHelpers.ts create mode 100644 packages/editor-ui/src/components/mixins/nodeIndex.ts create mode 100644 packages/editor-ui/src/components/mixins/pushConnection.ts create mode 100644 packages/editor-ui/src/components/mixins/restApi.ts create mode 100644 packages/editor-ui/src/components/mixins/showMessage.ts create mode 100644 packages/editor-ui/src/components/mixins/workflowHelpers.ts create mode 100644 packages/editor-ui/src/components/mixins/workflowRun.ts create mode 100644 packages/editor-ui/src/constants.ts create mode 100644 packages/editor-ui/src/main.ts create mode 100644 packages/editor-ui/src/n8n-theme-variables.scss create mode 100644 packages/editor-ui/src/n8n-theme.scss create mode 100644 packages/editor-ui/src/router.ts create mode 100644 packages/editor-ui/src/shims-tsx.d.ts create mode 100644 packages/editor-ui/src/shims-vue.d.ts create mode 100644 packages/editor-ui/src/store.ts create mode 100644 packages/editor-ui/src/views/NodeView.vue create mode 100644 packages/editor-ui/tests/e2e/.eslintrc.js create mode 100644 packages/editor-ui/tests/e2e/plugins/index.js create mode 100644 packages/editor-ui/tests/e2e/specs/test.js create mode 100644 packages/editor-ui/tests/e2e/support/commands.js create mode 100644 packages/editor-ui/tests/e2e/support/index.js create mode 100644 packages/editor-ui/tests/unit/.eslintrc.js create mode 100644 packages/editor-ui/tests/unit/example.spec.ts create mode 100644 packages/editor-ui/tsconfig.json create mode 100644 packages/editor-ui/tslint.json create mode 100644 packages/editor-ui/vue.config.js create mode 100644 packages/node-dev/LICENSE create mode 100644 packages/node-dev/README.md create mode 100644 packages/node-dev/commands/build.ts create mode 100644 packages/node-dev/commands/new.ts create mode 100644 packages/node-dev/index.ts create mode 100644 packages/node-dev/package.json create mode 100644 packages/node-dev/src/Build.ts create mode 100644 packages/node-dev/src/Create.ts create mode 100644 packages/node-dev/src/Interfaces.ts create mode 100644 packages/node-dev/src/index.ts create mode 100644 packages/node-dev/src/tsconfig-build.json create mode 100644 packages/node-dev/templates/credentials/simple.ts create mode 100644 packages/node-dev/templates/execute/simple.ts create mode 100644 packages/node-dev/templates/trigger/simple.ts create mode 100644 packages/node-dev/templates/webhook/simple.ts create mode 100644 packages/node-dev/tsconfig.json create mode 100644 packages/node-dev/tslint.json create mode 100644 packages/nodes-base/LICENSE create mode 100644 packages/nodes-base/README.md create mode 100644 packages/nodes-base/credentials/AsanaApi.credentials.ts create mode 100644 packages/nodes-base/credentials/ChargebeeApi.credentials.ts create mode 100644 packages/nodes-base/credentials/DropboxApi.credentials.ts create mode 100644 packages/nodes-base/credentials/GithubApi.credentials.ts create mode 100644 packages/nodes-base/credentials/GoogleApi.credentials.ts create mode 100644 packages/nodes-base/credentials/HttpBasicAuth.credentials.ts create mode 100644 packages/nodes-base/credentials/HttpHeaderAuth.credentials.ts create mode 100644 packages/nodes-base/credentials/Imap.credentials.ts create mode 100644 packages/nodes-base/credentials/LinkFishApi.credentials.ts create mode 100644 packages/nodes-base/credentials/MailgunApi.credentials.ts create mode 100644 packages/nodes-base/credentials/NextCloudApi.credentials.ts create mode 100644 packages/nodes-base/credentials/OpenWeatherMapApi.credentials.ts create mode 100644 packages/nodes-base/credentials/Redis.credentials.ts create mode 100644 packages/nodes-base/credentials/SlackApi.credentials.ts create mode 100644 packages/nodes-base/credentials/Smtp.credentials.ts create mode 100644 packages/nodes-base/credentials/TwilioApi.credentials.ts create mode 100644 packages/nodes-base/gulpfile.js create mode 100644 packages/nodes-base/nodes/Asana/Asana.node.ts create mode 100644 packages/nodes-base/nodes/Asana/AsanaTrigger.node.ts create mode 100644 packages/nodes-base/nodes/Asana/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/Asana/asana.png create mode 100644 packages/nodes-base/nodes/Chargebee/Chargebee.node.ts create mode 100644 packages/nodes-base/nodes/Chargebee/ChargebeeTrigger.node.ts create mode 100644 packages/nodes-base/nodes/Chargebee/chargebee.png create mode 100644 packages/nodes-base/nodes/Cron.node.ts create mode 100644 packages/nodes-base/nodes/Dropbox/Dropbox.node.ts create mode 100644 packages/nodes-base/nodes/Dropbox/dropbox.png create mode 100644 packages/nodes-base/nodes/EditImage.node.ts create mode 100644 packages/nodes-base/nodes/EmailReadImap.node.ts create mode 100644 packages/nodes-base/nodes/EmailSend.node.ts create mode 100644 packages/nodes-base/nodes/ErrorTrigger.node.ts create mode 100644 packages/nodes-base/nodes/ExecuteCommand.node.ts create mode 100644 packages/nodes-base/nodes/Function.node.ts create mode 100644 packages/nodes-base/nodes/FunctionItem.node.ts create mode 100644 packages/nodes-base/nodes/Github/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/Github/Github.node.ts create mode 100644 packages/nodes-base/nodes/Github/GithubTrigger.node.ts create mode 100644 packages/nodes-base/nodes/Github/github.png create mode 100644 packages/nodes-base/nodes/GoogleSheets/GoogleSheets.node.ts create mode 100644 packages/nodes-base/nodes/GoogleSheets/googlesheets.png create mode 100644 packages/nodes-base/nodes/HttpRequest.node.ts create mode 100644 packages/nodes-base/nodes/If.node.ts create mode 100644 packages/nodes-base/nodes/Interval.node.ts create mode 100644 packages/nodes-base/nodes/LinkFish/LinkFish.node.ts create mode 100644 packages/nodes-base/nodes/LinkFish/linkfish.png create mode 100644 packages/nodes-base/nodes/Mailgun.node.ts create mode 100644 packages/nodes-base/nodes/Merge.node.ts create mode 100644 packages/nodes-base/nodes/NextCloud/NextCloud.node.ts create mode 100644 packages/nodes-base/nodes/NextCloud/nextcloud.png create mode 100644 packages/nodes-base/nodes/NoOp.node.ts create mode 100644 packages/nodes-base/nodes/OpenWeatherMap.node.ts create mode 100644 packages/nodes-base/nodes/ReadBinaryFile.node.ts create mode 100644 packages/nodes-base/nodes/ReadBinaryFiles.node.ts create mode 100644 packages/nodes-base/nodes/ReadFileFromUrl.node.ts create mode 100644 packages/nodes-base/nodes/ReadPdf.node.ts create mode 100644 packages/nodes-base/nodes/Redis/Redis.node.ts create mode 100644 packages/nodes-base/nodes/Redis/redis.png create mode 100644 packages/nodes-base/nodes/RenameKeys.node.ts create mode 100644 packages/nodes-base/nodes/RssFeedRead.node.ts create mode 100644 packages/nodes-base/nodes/Set.node.ts create mode 100644 packages/nodes-base/nodes/Slack/Slack.node.ts create mode 100644 packages/nodes-base/nodes/Slack/slack.png create mode 100644 packages/nodes-base/nodes/SplitInBatches.node.ts create mode 100644 packages/nodes-base/nodes/SpreadsheetFile.node.ts create mode 100644 packages/nodes-base/nodes/Start.node.ts create mode 100644 packages/nodes-base/nodes/Twilio/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/Twilio/Twilio.node.ts create mode 100644 packages/nodes-base/nodes/Twilio/twilio.png create mode 100644 packages/nodes-base/nodes/Webhook.node.ts create mode 100644 packages/nodes-base/nodes/WriteBinaryFile.node.ts create mode 100644 packages/nodes-base/package.json create mode 100644 packages/nodes-base/src/GoogleSheet.ts create mode 100644 packages/nodes-base/src/index.ts create mode 100644 packages/nodes-base/tsconfig.json create mode 100644 packages/nodes-base/tslint.json create mode 100644 packages/workflow/LICENSE create mode 100644 packages/workflow/README.md create mode 100644 packages/workflow/package.json create mode 100644 packages/workflow/src/Interfaces.ts create mode 100644 packages/workflow/src/NodeHelpers.ts create mode 100644 packages/workflow/src/ObservableObject.ts create mode 100644 packages/workflow/src/Workflow.ts create mode 100644 packages/workflow/src/WorkflowDataProxy.ts create mode 100644 packages/workflow/src/index.ts create mode 100644 packages/workflow/test/Helpers.ts create mode 100644 packages/workflow/test/NodeHelpers.test.ts create mode 100644 packages/workflow/test/ObservableObject.test.ts create mode 100644 packages/workflow/test/Workflow.test.ts create mode 100644 packages/workflow/tsconfig.json create mode 100644 packages/workflow/tslint.json diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000000..bec7553240 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,8 @@ +root = true + +[*] +charset = utf-8 +indent_style = tab +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..91e093160a --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +node_modules +.DS_Store +.tmp +tmp +dist +npm-debug.log* +lerna-debug.log +package-lock.json +yarn.lock +google-generated-credentials.json +_START_PACKAGE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000..b3aadc2a0f --- /dev/null +++ b/LICENSE @@ -0,0 +1,230 @@ +“Commons Clause” License Condition v1.0 + +The Software is provided to you by the Licensor under the +License, as defined below, subject to the following condition. + +Without limiting other conditions in the License, the grant +of rights under the License will not include, and the License +does not grant to you, the right to Sell the Software. + +For purposes of the foregoing, “Sell” means practicing any or +all of the rights granted to you under the License to provide +to third parties, for a fee or other consideration (including +without limitation fees for hosting or consulting/ support +services related to the Software), a product or service whose +value derives, entirely or substantially, from the functionality +of the Software. Any license notice or attribution required by +the License must also include this Commons Clause License +Condition notice. + +Software: n8n + +License: Apache 2.0 + +Licensor: Jan Oberhauser + + +--------------------------------------------------------------------- + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000000..10296124f4 --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +# n8n - Workflow Automation Tool + +![n8n.io - Workflow Automation](https://n8n.io/n8n-logo.png) + +n8n is a tool which allows to easily and fast automate different taks. + +Is still in beta so can not guarantee that everything works perfectly. Also +is there currently not much documentation. That will hopefully change soon. + + +## Usage + +Information about how to install and use it can be found in the cli package [here](packages/cli/README) + +And information about how to run it in Docker [here](docker/images/n8n/README.md) + + +## Development Setup + +1. Clone the repository +2. Go into repository folder +3. Run: `npm install` +4. Run: `npx lerna bootstrap --hoist` +5. Run: `npm run build` or `npx lerna exec npm run build` (if lerna is not installed) + +## Start + +Execute: `npm run start` + + +## License + +[Apache 2.0 with Commons Clause](LICENSE) diff --git a/docker/compose/withMongo/.env b/docker/compose/withMongo/.env new file mode 100644 index 0000000000..0005b2cd9a --- /dev/null +++ b/docker/compose/withMongo/.env @@ -0,0 +1,6 @@ +MONGO_INITDB_ROOT_USERNAME=adminuser +MONGO_INITDB_ROOT_PASSWORD=JvsjjAYg12FJ90sdCBdsh322V +MONGO_INITDB_DATABASE=n8n + +MONGO_NON_ROOT_USERNAME=n8nuser +MONGO_NON_ROOT_PASSWORD=PLsQ8vHGShwDFdmSssb diff --git a/docker/compose/withMongo/README.md b/docker/compose/withMongo/README.md new file mode 100644 index 0000000000..bfb4b0f647 --- /dev/null +++ b/docker/compose/withMongo/README.md @@ -0,0 +1,26 @@ +# n8n with MongoDB + +Starts n8n with MongoDB as database. + + +## Start + +To start n8n with MongoDB simply start docker-compose by executing the following +command in the current folder. + + +**IMPORTANT:** But before you do that change the default users and passwords in the `.env` file! + +``` +docker-compose up -d +``` + +To stop it execute: + +``` +docker-compose stop +``` + +## Configuration + +The default name of the database, user and password for MongoDB can be changed in the `.env` file in the current directory. diff --git a/docker/compose/withMongo/docker-compose.yml b/docker/compose/withMongo/docker-compose.yml new file mode 100644 index 0000000000..e74da5bf24 --- /dev/null +++ b/docker/compose/withMongo/docker-compose.yml @@ -0,0 +1,28 @@ +version: '3.1' + +services: + + mongo: + image: mongo:4.0 + restart: always + environment: + - MONGO_INITDB_ROOT_USERNAME + - MONGO_INITDB_ROOT_PASSWORD + - MONGO_INITDB_DATABASE + - MONGO_NON_ROOT_USERNAME + - MONGO_NON_ROOT_PASSWORD + volumes: + - ./init-data.sh:/docker-entrypoint-initdb.d/init-data.sh + + n8n: + image: n8n + restart: always + ports: + - 5678:5678 + links: + - mongo + volumes: + - ~/.n8n:/root/.n8n + # Wait 5 seconds to start n8n to make sure that MongoDB is ready + # when n8n tries to connect to it + command: /bin/sh -c "sleep 5; n8n start --NODE_CONFIG='{\"database\":{\"type\":\"mongodb\", \"mongodbConfig\":{\"url\":\"mongodb://n8nuser:${MONGO_NON_ROOT_PASSWORD}@mongo:27017/${MONGO_INITDB_DATABASE}\"}}}'" diff --git a/docker/compose/withMongo/init-data.sh b/docker/compose/withMongo/init-data.sh new file mode 100755 index 0000000000..bf5c10c84e --- /dev/null +++ b/docker/compose/withMongo/init-data.sh @@ -0,0 +1,17 @@ +#!/bin/bash +set -e; + +# Create a default non-root role +MONGO_NON_ROOT_ROLE="${MONGO_NON_ROOT_ROLE:-readWrite}" + +if [ -n "${MONGO_NON_ROOT_USERNAME:-}" ] && [ -n "${MONGO_NON_ROOT_PASSWORD:-}" ]; then + "${mongo[@]}" "$MONGO_INITDB_DATABASE" <<-EOJS + db.createUser({ + user: $(_js_escape "$MONGO_NON_ROOT_USERNAME"), + pwd: $(_js_escape "$MONGO_NON_ROOT_PASSWORD"), + roles: [ { role: $(_js_escape "$MONGO_NON_ROOT_ROLE"), db: $(_js_escape "$MONGO_INITDB_DATABASE") } ] + }) + EOJS +else + echo "SETUP INFO: No Environment variables given!" +fi diff --git a/docker/images/n8n/Dockerfile b/docker/images/n8n/Dockerfile new file mode 100644 index 0000000000..96784bbb90 --- /dev/null +++ b/docker/images/n8n/Dockerfile @@ -0,0 +1,18 @@ +FROM mhart/alpine-node:10 + +# Update everything and install needed dependencies +RUN apk add --update \ + graphicsmagick + +# # Set a custom user to not have n8n run as root +USER root + +# Install n8n and the also temporary all the packages +# it needs to build it correctly. +RUN apk --update add --virtual build-dependencies python build-base && \ + npm_config_user=root npm install -g n8n && \ + apk del build-dependencies + +WORKDIR /data + +CMD "n8n" diff --git a/docker/images/n8n/README.md b/docker/images/n8n/README.md new file mode 100644 index 0000000000..ed960574bc --- /dev/null +++ b/docker/images/n8n/README.md @@ -0,0 +1,84 @@ +## n8n + +![n8n.io - Workflow Automation](https://n8n.io/n8n-logo.png) + +Run n8n in Docker. + +``` +docker run -it --rm \ + --name n8n \ + -p 5678:5678 \ + n8nio/n8n +``` + +You can then access n8n by opening: +[http://localhost:5678](http://localhost:5678) + + +## Start with tunnel + +To be able to use webhooks which all triggers of external services like Github +rely on n8n has to be reachable from the web. To make that easy n8n has a +special tunnel service which redirects requests from our servers to your local +n8n instance. + +To use it simply start n8n with `--tunnel` + +``` +docker run -it --rm \ + --name n8n \ + --init \ + -p 5678:5678 \ + -v ~/.n8n:/root/.n8n \ + n8nio/n8n \ + n8n start --tunnel +``` + +## Persist data + +The workflow data gets by default saved in an SQLite database in the user +folder (`/root/.n8n`). That folder also additionally contains the +settings like webhook URL and encryption key. + +``` +docker run -it --rm \ + --name n8n \ + -p 5678:5678 \ + -v ~/.n8n:/root/.n8n \ + n8nio/n8n +``` + +## Use with MongoDB + +Instead of SQLite, it is also possible to run n8n with MongoDB. + +It is important to still persist the data in the `/root/.n8` folder. The reason +is that it contains n8n user data. That is the name of the webhook +(in case) the n8n tunnel gets used and even more important the encryption key +for the credentials. If none gets found n8n creates automatically one on +startup. In case credentials are already saved with a different encryption key +it can not be used anymore as encrypting it is not possible anymore. + +Replace the following placeholders with the actual data: + - MONGO_DATABASE + - MONGO_HOST + - MONGO_PORT + - MONGO_USER + - MONGO_PASSWORD + +``` +docker run -it --rm \ + --name n8n \ + -p 5678:5678 \ + -v ~/.n8n:/root/.n8n \ + n8nio/n8n \ + n8n start \ + --NODE_CONFIG='{\"database\":{\"type\":\"mongodb\", \"mongodbConfig\":{\"url\":\"mongodb://MONGO_USER:MONGO_PASSWORD@MONGO_SERVER:MONGO_PORT/MONGO_DATABASE\"}}}'" +``` + +A full working setup with docker-compose can be found [here](../../compose/withMongo/README.md) + + +## License + +n8n is licensed under **Apache 2.0 with Commons Clause** diff --git a/lerna.json b/lerna.json new file mode 100644 index 0000000000..2b4a1377f3 --- /dev/null +++ b/lerna.json @@ -0,0 +1,6 @@ +{ + "packages": [ + "packages/*" + ], + "version": "independent" +} diff --git a/package.json b/package.json new file mode 100644 index 0000000000..7ff51f0b06 --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "n8n", + "private": true, + "scripts": { + "bootstrap": "lerna bootstrap --hoist --no-ci", + "build": "lerna exec npm run build", + "start": "cd packages/cli && node dist/index.js start", + "watch": "lerna run --parallel watch" + }, + "devDependencies": { + "lerna": "^3.13.1" + }, + "postcss": {} +} diff --git a/packages/cli/LICENSE b/packages/cli/LICENSE new file mode 100644 index 0000000000..b3aadc2a0f --- /dev/null +++ b/packages/cli/LICENSE @@ -0,0 +1,230 @@ +“Commons Clause” License Condition v1.0 + +The Software is provided to you by the Licensor under the +License, as defined below, subject to the following condition. + +Without limiting other conditions in the License, the grant +of rights under the License will not include, and the License +does not grant to you, the right to Sell the Software. + +For purposes of the foregoing, “Sell” means practicing any or +all of the rights granted to you under the License to provide +to third parties, for a fee or other consideration (including +without limitation fees for hosting or consulting/ support +services related to the Software), a product or service whose +value derives, entirely or substantially, from the functionality +of the Software. Any license notice or attribution required by +the License must also include this Commons Clause License +Condition notice. + +Software: n8n + +License: Apache 2.0 + +Licensor: Jan Oberhauser + + +--------------------------------------------------------------------- + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/cli/README.md b/packages/cli/README.md new file mode 100644 index 0000000000..3d933e139f --- /dev/null +++ b/packages/cli/README.md @@ -0,0 +1,47 @@ +# n8n - Workflow Automation Tool + +![n8n.io - Workflow Automation](https://n8n.io/n8n-logo.png) + +n8n is a tool which allows to easily and fast automate different taks. + +Is still in beta so can not guarantee that everything works perfectly. Also +is there currently not much documentation. That will hopefully change soon. + + +## Give n8n a spin + +To simply spin up n8n to have a look and give it spin you can simply run: + +``` +npx n8n +``` + +It will then download everything which is needed and start n8n. + +You can then access n8n by opening: +[http://localhost:5678](http://localhost:5678) + + +## Installation + +To fully install n8n globally execute: + +``` +npm install n8n -g +``` + +After the installation n8n can be started by simply typing in: +``` +n8n +``` + + +## License + +[Apache 2.0 with Commons Clause](LICENSE) + + +## Development + +When developing n8n can be started with `npm run start:dev`. +It will then automatically restart n8n every time a file changes. diff --git a/packages/cli/commands/run.ts b/packages/cli/commands/run.ts new file mode 100644 index 0000000000..ad195c6432 --- /dev/null +++ b/packages/cli/commands/run.ts @@ -0,0 +1,151 @@ +import Vorpal = require('vorpal'); +import { Args } from 'vorpal'; +import { promises as fs } from 'fs'; +import { + CredentialTypes, + Db, + IWorkflowBase, + LoadNodesAndCredentials, + NodeTypes, + GenericHelpers, + WorkflowHelpers, + WorkflowExecuteAdditionalData, +} from "../src"; +import { + ActiveExecutions, + UserSettings, + WorkflowExecute, +} from "n8n-core"; +import { + INode, + Workflow, +} from "n8n-workflow"; + + +module.exports = (vorpal: Vorpal) => { + return vorpal + .command('run') + // @ts-ignore + .description('Executes a given workflow') + .option('--file ', + 'The path to a workflow file to execute') + .option('--id ', + 'The id of the workflow to execute') + .option('\n') + // TODO: Add validation + // .validate((args: Args) => { + // }) + .action(async (args: Args) => { + // Start directly with the init of the database to improve startup time + const startDbInitPromise = Db.init(); + + // Load all node and credential types + const loadNodesAndCredentials = LoadNodesAndCredentials(); + const loadNodesAndCredentialsPromise = loadNodesAndCredentials.init(); + + if (!args.options.id && !args.options.file) { + GenericHelpers.logOutput(`Either option "--id" or "--file" have to be set!`); + return Promise.resolve(); + } + + if (args.options.id && args.options.file) { + GenericHelpers.logOutput(`Either "id" or "file" can be set never both!`); + return Promise.resolve(); + } + + let workflowId: string | undefined; + let workflowData: IWorkflowBase | undefined = undefined; + if (args.options.file) { + // Path to workflow is given + try { + workflowData = JSON.parse(await fs.readFile(args.options.file, 'utf8')); + } catch (error) { + if (error.code === 'ENOENT') { + GenericHelpers.logOutput(`The file "${args.options.file}" could not be found.`); + return; + } + + throw error; + } + + // Do a basic check if the data in the file looks right + // TODO: Later check with the help of TypeScript data if it is valid or not + if (workflowData === undefined || workflowData.nodes === undefined || workflowData.connections === undefined) { + GenericHelpers.logOutput(`The file "${args.options.file}" does not contain valid workflow data.`); + return; + } + workflowId = workflowData.id!.toString(); + } + + // Wait till the database is ready + await startDbInitPromise; + + if (args.options.id) { + // Id of workflow is given + workflowId = args.options.id; + workflowData = await Db.collections!.Workflow!.findOne(workflowId); + if (workflowData === undefined) { + GenericHelpers.logOutput(`The workflow with the id "${workflowId}" does not exist.`); + return; + } + + } + + // Make sure the settings exist + await UserSettings.prepareUserSettings(); + + // Wait till the n8n-packages have been read + await loadNodesAndCredentialsPromise; + + // Add the found types to an instance other parts of the application can use + const nodeTypes = NodeTypes(); + await nodeTypes.init(loadNodesAndCredentials.nodeTypes); + const credentialTypes = CredentialTypes(); + await credentialTypes.init(loadNodesAndCredentials.credentialTypes); + + if (!WorkflowHelpers.isWorkflowIdValid(workflowId)) { + workflowId = undefined; + } + + const workflowInstance = new Workflow(workflowId, workflowData!.nodes, workflowData!.connections, true, nodeTypes, workflowData!.staticData); + + // Check if the workflow contains the required "Start" node + // "requiredNodeTypes" are also defined in editor-ui/views/NodeView.vue + const requiredNodeTypes = ['n8n-nodes-base.start']; + let startNodeFound = false; + let node: INode; + for (const nodeName of Object.keys(workflowInstance.nodes)) { + node = workflowInstance.nodes[nodeName]; + if (requiredNodeTypes.includes(node.type)) { + startNodeFound = true; + } + } + + if (startNodeFound === false) { + // If the workflow does not contain a start-node we can not know what + // should be executed and with which data to start. + GenericHelpers.logOutput(`The workflow does not contain a "Start" node. So it can not be executed.`); + return Promise.resolve(); + } + + const mode = 'cli'; + const additionalData = await WorkflowExecuteAdditionalData.get(mode, workflowData!, workflowInstance); + const workflowExecute = new WorkflowExecute(additionalData, mode); + + try { + const executionId = await workflowExecute.run(workflowInstance); + + const activeExecutions = ActiveExecutions.getInstance(); + const data = activeExecutions.getPostExecutePromise(executionId); + + console.log('Execution was successfull:'); + console.log('===================================='); + console.log(JSON.stringify(data, null, 2)); + } catch (e) { + console.error('GOT ERROR'); + console.log('===================================='); + console.error(e); + return; + } + }); +}; diff --git a/packages/cli/commands/start.ts b/packages/cli/commands/start.ts new file mode 100644 index 0000000000..083c507fe8 --- /dev/null +++ b/packages/cli/commands/start.ts @@ -0,0 +1,182 @@ +import Vorpal = require('vorpal'); +import { Args } from 'vorpal'; +import { randomBytes } from 'crypto'; +import * as config from 'config'; + +const open = require('open'); + +import * as localtunnel from 'localtunnel'; +import { + ActiveWorkflowRunner, + CredentialTypes, + Db, + GenericHelpers, + LoadNodesAndCredentials, + NodeTypes, + TestWebhooks, + Server, +} from "../src"; +import { + UserSettings, +} from "n8n-core"; + +import { promisify } from "util"; +const tunnel = promisify(localtunnel); + +let activeWorkflowRunner: ActiveWorkflowRunner.ActiveWorkflowRunner | undefined; + + +/** + * Opens the UI in browser + * + */ +function openBrowser() { + const editorUrl = GenericHelpers.getBaseUrl(); + + open(editorUrl, { wait: true }) + .catch((error: Error) => { + console.log(`\nWas not able to open URL in browser. Please open manually by visiting:\n${editorUrl}\n`); + }); +} + + +module.exports = (vorpal: Vorpal) => { + return vorpal + .command('start') + // @ts-ignore + .description('Starts n8n. Makes Web-UI available and starts active workflows') + .option('-o --open', + 'Opens the UI automatically in browser') + .option('--tunnel', + 'Runs the webhooks via a hooks.n8n.cloud tunnel server') + .option('\n') + // TODO: Add validation + // .validate((args: Args) => { + // }) + .action((args: Args) => { + + if (process.pid === 1) { + console.error(`The n8n node process should not run as process with ID 1 because that will cause +problems with shutting everything down correctly. If started with docker use the +flag "--init" to fix this problem!`); + return; + } + + // TODO: Start here the the script in a subprocess which can get restarted when new nodes get added and so new packages have to get installed + + // npm install / rm (in other process) + // restart process depending on exit code (lets say 50 means restart) + + // Wrap that the process does not close but we can still use async + (async () => { + // Start directly with the init of the database to improve startup time + const startDbInitPromise = Db.init(); + + // Make sure the settings exist + const userSettings = await UserSettings.prepareUserSettings(); + + // Load all node and credential types + const loadNodesAndCredentials = LoadNodesAndCredentials(); + await loadNodesAndCredentials.init(); + + // Add the found types to an instance other parts of the application can use + const nodeTypes = NodeTypes(); + await nodeTypes.init(loadNodesAndCredentials.nodeTypes); + const credentialTypes = CredentialTypes(); + await credentialTypes.init(loadNodesAndCredentials.credentialTypes); + + // Wait till the database is ready + await startDbInitPromise; + + if (args.options.tunnel !== undefined) { + console.log('\nWaiting for tunnel ...'); + + if (userSettings.tunnelSubdomain === undefined) { + // When no tunnel subdomain did exist yet create a new random one + const availableCharacters = 'abcdefghijklmnopqrstuvwxyz0123456789'; + userSettings.tunnelSubdomain = Array.from({ length: 24 }).map(() => { + return availableCharacters.charAt(Math.floor(Math.random() * availableCharacters.length)); + }).join(''); + + await UserSettings.writeUserSettings(userSettings); + } + + const tunnelSettings: localtunnel.TunnelConfig = { + host: 'https://hooks.n8n.cloud', + subdomain: userSettings.tunnelSubdomain, + }; + + const port = config.get('urls.port') as number; + + // @ts-ignore + const webhookTunnel = await tunnel(port, tunnelSettings); + + process.env.WEBHOOK_TUNNEL_URL = webhookTunnel.url + '/'; + console.log(`Tunnel URL: ${process.env.WEBHOOK_TUNNEL_URL}\n`); + } + + Server.start(); + + // Start to get active workflows and run their triggers + activeWorkflowRunner = ActiveWorkflowRunner.getInstance(); + await activeWorkflowRunner.init(); + + const editorUrl = GenericHelpers.getBaseUrl(); + console.log(`\nEditor is now accessible via:\n${editorUrl}`); + + // Allow to open n8n editor by pressing "o" + if (Boolean(process.stdout.isTTY) && process.stdin.setRawMode) { + process.stdin.setRawMode(true); + process.stdin.resume(); + process.stdin.setEncoding('utf8'); + let inputText = ''; + + if (args.options.browser !== undefined) { + openBrowser(); + } + console.log(`\nPress "o" to open in Browser.`); + process.stdin.on("data", (key) => { + if (key === 'o') { + openBrowser(); + inputText = ''; + } else { + // When anything else got pressed, record it and send it on enter into the child process + if (key.charCodeAt(0) === 13) { + // send to child process and print in terminal + process.stdout.write('\n'); + inputText = ''; + } else { + // record it and write into terminal + inputText += key; + process.stdout.write(key); + } + } + }); + } + })(); + + + vorpal.sigint(async () => { + console.log(`\nStopping n8n...`); + + setTimeout(() => { + // In case that something goes wrong with shutdown we + // kill after max. 30 seconds no matter what + process.exit(); + }, 30000); + + const removePromises = []; + if (activeWorkflowRunner !== undefined) { + removePromises.push(activeWorkflowRunner.removeAll()); + } + + // Remove all test webhooks + const testWebhooks = TestWebhooks.getInstance(); + removePromises.push(testWebhooks.removeAll()); + + await Promise.all(removePromises); + + process.exit(); + }); + }); +}; diff --git a/packages/cli/config/default.ts b/packages/cli/config/default.ts new file mode 100644 index 0000000000..d732d2f37b --- /dev/null +++ b/packages/cli/config/default.ts @@ -0,0 +1,30 @@ +module.exports = { + urls: { + endpointRest: 'rest', + endpointWebhook: 'webhook', + endpointWebhookTest: 'webhook-test', + host: 'localhost', + port: 5678, + protocol: 'http', + }, + database: { + type: 'sqlite', // Available types: sqlite, mongodb + + // MongoDB specific settings + mongodbConfig: { + url: 'mongodb://user:password@localhost:27017/database', + }, + }, + + executions: { + saveManualRuns: false, + }, + + nodes: { + // Nodes not to load even if found + // exclude: [], + errorTriggerType: 'n8n-nodes-base.errorTrigger', + }, + + timezone: 'America/New_York', +}; diff --git a/packages/cli/index.ts b/packages/cli/index.ts new file mode 100644 index 0000000000..43e63cf857 --- /dev/null +++ b/packages/cli/index.ts @@ -0,0 +1,58 @@ +#!/usr/bin/env node + +import { join as pathJoin } from 'path'; + +// Make sure that it also find the config folder when it +// did get started from another folder that the root one. +process.env.NODE_CONFIG_DIR = process.env.NODE_CONFIG_DIR || pathJoin(__dirname, 'config'); + +import Vorpal = require('vorpal'); +import { GenericHelpers } from './src'; + +// Check if version should be displayed +const versionFlags = [ + '-v', + '-V', + '--version' +]; +if (versionFlags.includes(process.argv.slice(-1)[0])) { + console.log(require('../package').version); + process.exit(0); +} + +if (process.argv.length === 2) { + // When no command is given choose by default start + process.argv.push('start'); +} + +const command = process.argv[2]; + +// Check if the command the user did enter is supported else stop +const supportedCommands = [ + 'help', + 'run', + 'start', +]; + +if (!supportedCommands.includes(command)) { + GenericHelpers.logOutput(`The command "${command}" is not known!`); + process.argv.push('help'); +} + +const vorpal = new Vorpal(); +vorpal + .use(require('./commands/run.js')) + .use(require('./commands/start.js')) + .delimiter('') + .show() + .parse(process.argv); + + +process + .on('unhandledRejection', (reason, p) => { + console.error(reason, 'Unhandled Rejection at Promise', p); + }) + .on('uncaughtException', err => { + console.error(err, 'Uncaught Exception thrown'); + process.exit(1); + }); diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 0000000000..06084d0edb --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,93 @@ +{ + "name": "n8n", + "version": "0.1.2", + "description": "n8n Workflow Automation Tool", + "license": "SEE LICENSE IN LICENSE", + "author": { + "name": "Jan Oberhauser", + "email": "jan@n8n.io" + }, + "main": "dist/index", + "types": "dist/src/index.d.ts", + "scripts": { + "build": "tsc", + "start": "node dist/index.js start", + "start:dev": "nodemon", + "test": "jest", + "tslint": "tslint -p tsconfig.json -c tslint.json", + "watch": "tsc --watch" + }, + "bin": { + "n8n": "./dist/index.js" + }, + "keywords": [ + "automate", + "automation", + "IaaS", + "iPaaS", + "n8n", + "workflow" + ], + "engines": { + "node": ">=8.0.0" + }, + "files": [ + "dist" + ], + "devDependencies": { + "@types/config": "0.0.34", + "@types/connect-history-api-fallback": "^1.3.1", + "@types/express": "^4.16.1", + "@types/jest": "^23.3.2", + "@types/localtunnel": "^1.9.0", + "@types/node": "^10.10.1", + "@types/open": "^6.1.0", + "@types/parseurl": "^1.3.1", + "@types/request-promise-native": "^1.0.15", + "@types/vorpal": "^1.11.0", + "jest": "^23.6.0", + "nodemon": "^1.19.1", + "sails-disk": "^1.0.1", + "ts-jest": "^23.10.1", + "tslint": "^5.11.0", + "typescript": "~3.3.0" + }, + "dependencies": { + "body-parser": "^1.18.3", + "config": "^3.0.1", + "connect-history-api-fallback": "^1.6.0", + "express": "^4.16.4", + "flatted": "^2.0.0", + "glob-promise": "^3.4.0", + "google-timezones-json": "^1.0.2", + "localtunnel": "^1.9.1", + "mongodb": "^3.2.3", + "n8n-core": "^0.1.0", + "n8n-editor-ui": "^0.1.0", + "n8n-nodes-base": "^0.1.0", + "n8n-workflow": "^0.1.0", + "open": "^6.1.0", + "request-promise-native": "^1.0.7", + "sqlite3": "^4.0.6", + "sse-channel": "^3.1.1", + "typeorm": "^0.2.16", + "vorpal": "^1.12.0" + }, + "jest": { + "transform": { + "^.+\\.tsx?$": "ts-jest" + }, + "testURL": "http://localhost/", + "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$", + "testPathIgnorePatterns": [ + "/dist/", + "/node_modules/" + ], + "moduleFileExtensions": [ + "ts", + "tsx", + "js", + "json" + ] + } +} diff --git a/packages/cli/src/ActiveWorkflowRunner.ts b/packages/cli/src/ActiveWorkflowRunner.ts new file mode 100644 index 0000000000..512349e82e --- /dev/null +++ b/packages/cli/src/ActiveWorkflowRunner.ts @@ -0,0 +1,314 @@ +import { + IActivationError, + Db, + NodeTypes, + IResponseCallbackData, + IWorkflowDb, + ResponseHelper, + WebhookHelpers, + WorkflowHelpers, + WorkflowExecuteAdditionalData, +} from './'; + +import { + ActiveWorkflows, + ActiveWebhooks, +} from 'n8n-core'; + +import { + IWebhookData, + IWorkflowExecuteAdditionalData as IWorkflowExecuteAdditionalDataWorkflow, + WebhookHttpMethod, + Workflow, + WorkflowExecuteMode, +} from 'n8n-workflow'; + +import * as express from 'express'; + + +export class ActiveWorkflowRunner { + private activeWorkflows: ActiveWorkflows | null = null; + private activeWebhooks: ActiveWebhooks | null = null; + private activationErrors: { + [key: string]: IActivationError; + } = {}; + + + async init() { + // Get the active workflows from database + const workflowsData: IWorkflowDb[] = await Db.collections.Workflow!.find({ active: true }) as IWorkflowDb[]; + + this.activeWebhooks = new ActiveWebhooks(); + + // Add them as active workflows + this.activeWorkflows = new ActiveWorkflows(); + + if (workflowsData.length !== 0) { + console.log('\n ================================'); + console.log(' Start Active Workflows:'); + console.log(' ================================'); + + for (const workflowData of workflowsData) { + console.log(` - ${workflowData.name}`); + try { + await this.add(workflowData.id.toString(), workflowData); + console.log(` => Started`); + } catch (error) { + console.log(` => ERROR: Workflow could not be activated:`); + console.log(` ${error.message}`); + } + } + } + } + + + /** + * Removes all the currently active workflows + * + * @returns {Promise} + * @memberof ActiveWorkflowRunner + */ + async removeAll(): Promise { + if (this.activeWorkflows === null) { + return; + } + + const activeWorkflows = this.activeWorkflows.allActiveWorkflows(); + + const removePromises = []; + for (const workflowId of activeWorkflows) { + removePromises.push(this.remove(workflowId)); + } + + await Promise.all(removePromises); + return; + } + + + /** + * Checks if a webhook for the given method and path exists and executes the workflow. + * + * @param {WebhookHttpMethod} httpMethod + * @param {string} path + * @param {express.Request} req + * @param {express.Response} res + * @returns {Promise} + * @memberof ActiveWorkflowRunner + */ + async executeWebhook(httpMethod: WebhookHttpMethod, path: string, req: express.Request, res: express.Response): Promise { + if (this.activeWorkflows === null) { + throw new ResponseHelper.ReponseError('The "activeWorkflows" instance did not get initialized yet.', 404, 404); + } + + const webhookData: IWebhookData | undefined = this.activeWebhooks!.get(httpMethod, path); + + if (webhookData === undefined) { + // The requested webhook is not registred + throw new ResponseHelper.ReponseError('The requested webhook is not registred.', 404, 404); + } + + // Get the node which has the webhook defined to know where to start from and to + // get additional data + const workflowStartNode = webhookData.workflow.getNode(webhookData.node); + if (workflowStartNode === null) { + throw new ResponseHelper.ReponseError('Could not find node to process webhook.', 404, 404); + } + const executionMode = 'webhook'; + + const workflowData = await Db.collections.Workflow!.findOne(webhookData.workflow.id!); + + if (workflowData === undefined) { + throw new ResponseHelper.ReponseError(`Could not find workflow with id "${webhookData.workflow.id}"`, 404, 404); + } + + return new Promise((resolve, reject) => { + WebhookHelpers.executeWebhook(webhookData, workflowData, workflowStartNode, executionMode, undefined, req, res, (error: Error | null, data: object) => { + if (error !== null) { + return reject(error); + } + resolve(data); + }); + }); + } + + + /** + * Returns the ids of the currently active workflows + * + * @returns {string[]} + * @memberof ActiveWorkflowRunner + */ + getActiveWorkflows(): string[] { + if (this.activeWorkflows === null) { + return []; + } + + return this.activeWorkflows.allActiveWorkflows(); + } + + + /** + * Returns if the workflow is active + * + * @param {string} id The id of the workflow to check + * @returns {boolean} + * @memberof ActiveWorkflowRunner + */ + isActive(id: string): boolean { + if (this.activeWorkflows !== null) { + return this.activeWorkflows.isActive(id); + } + + return false; + } + + + /** + * Return error if there was a problem activating the workflow + * + * @param {string} id The id of the workflow to return the error of + * @returns {(IActivationError | undefined)} + * @memberof ActiveWorkflowRunner + */ + getActivationError(id: string): IActivationError | undefined { + if (this.activationErrors[id] === undefined) { + return undefined; + } + + return this.activationErrors[id]; + } + + + /** + * Adds all the webhooks of the workflow + * + * @param {Workflow} workflow + * @param {IWorkflowExecuteAdditionalDataWorkflow} additionalData + * @param {WorkflowExecuteMode} mode + * @returns {Promise} + * @memberof ActiveWorkflowRunner + */ + async addWorkflowWebhooks(workflow: Workflow, additionalData: IWorkflowExecuteAdditionalDataWorkflow, mode: WorkflowExecuteMode): Promise { + const webhooks = WebhookHelpers.getWorkflowWebhooks(workflow, additionalData); + + for (const webhookData of webhooks) { + await this.activeWebhooks!.add(webhookData, mode); + } + } + + + /** + * Remove all the webhooks of the workflow + * + * @param {string} workflowId + * @returns + * @memberof ActiveWorkflowRunner + */ + removeWorkflowWebhooks(workflowId: string): Promise { + return this.activeWebhooks!.removeByWorkflowId(workflowId); + } + + + /** + * Makes a workflow active + * + * @param {string} workflowId The id of the workflow to activate + * @param {IWorkflowDb} [workflowData] If workflowData is given it saves the DB query + * @returns {Promise} + * @memberof ActiveWorkflowRunner + */ + async add(workflowId: string, workflowData?: IWorkflowDb): Promise { + if (this.activeWorkflows === null) { + throw new Error(`The "activeWorkflows" instance did not get initialized yet.`); + } + + let workflowInstance: Workflow; + try { + if (workflowData === undefined) { + workflowData = await Db.collections.Workflow!.findOne(workflowId) as IWorkflowDb; + } + + if (!workflowData) { + throw new Error(`Could not find workflow with id "${workflowId}".`); + } + const nodeTypes = NodeTypes(); + workflowInstance = new Workflow(workflowId, workflowData.nodes, workflowData.connections, workflowData.active, nodeTypes, workflowData.staticData, workflowData.settings); + + const canBeActivated = workflowInstance.checkIfWorkflowCanBeActivated(['n8n-nodes-base.start']); + if (canBeActivated === false) { + throw new Error(`The workflow can not be activated because it does not contain any nodes which could start the workflow. Only workflows which have trigger or webhook nodes can be activated.`); + } + + const mode = 'trigger'; + const additionalData = await WorkflowExecuteAdditionalData.get(mode, workflowData, workflowInstance); + + // Add the workflows which have webhooks defined + await this.addWorkflowWebhooks(workflowInstance, additionalData, mode); + + await this.activeWorkflows.add(workflowId, workflowInstance, additionalData); + + if (this.activationErrors[workflowId] !== undefined) { + // If there were any activation errors delete them + delete this.activationErrors[workflowId]; + } + } catch (error) { + // There was a problem activating the workflow + + // Save the error + this.activationErrors[workflowId] = { + time: new Date().getTime(), + error: { + message: error.message, + }, + }; + + throw error; + } + + await WorkflowHelpers.saveStaticData(workflowInstance!); + } + + + /** + * Makes a workflow inactive + * + * @param {string} workflowId The id of the workflow to deactivate + * @returns {Promise} + * @memberof ActiveWorkflowRunner + */ + async remove(workflowId: string): Promise { + if (this.activeWorkflows !== null) { + const workflowData = this.activeWorkflows.get(workflowId); + + // Remove all the webhooks of the workflow + await this.removeWorkflowWebhooks(workflowId); + + if (workflowData) { + // Save the static workflow data if needed + await WorkflowHelpers.saveStaticData(workflowData.workflow); + } + + if (this.activationErrors[workflowId] !== undefined) { + // If there were any activation errors delete them + delete this.activationErrors[workflowId]; + } + + // Remove the workflow from the "list" of active workflows + return this.activeWorkflows.remove(workflowId); + } + + throw new Error(`The "activeWorkflows" instance did not get initialized yet.`); + } +} + + + +let workflowRunnerInstance: ActiveWorkflowRunner | undefined; + +export function getInstance(): ActiveWorkflowRunner { + if (workflowRunnerInstance === undefined) { + workflowRunnerInstance = new ActiveWorkflowRunner(); + } + + return workflowRunnerInstance; +} diff --git a/packages/cli/src/CredentialTypes.ts b/packages/cli/src/CredentialTypes.ts new file mode 100644 index 0000000000..dc14801cbc --- /dev/null +++ b/packages/cli/src/CredentialTypes.ts @@ -0,0 +1,37 @@ +import { + ICredentialType, + ICredentialTypes as ICredentialTypesInterface, +} from 'n8n-workflow'; + + +class CredentialTypesClass implements ICredentialTypesInterface { + + credentialTypes: { + [key: string]: ICredentialType + } = {}; + + + async init(credentialTypes: { [key: string]: ICredentialType }): Promise { + this.credentialTypes = credentialTypes; + } + + getAll(): ICredentialType[] { + return Object.values(this.credentialTypes); + } + + getByName(credentialType: string): ICredentialType { + return this.credentialTypes[credentialType]; + } +} + + + +let credentialTypesInstance: CredentialTypesClass | undefined; + +export function CredentialTypes(): CredentialTypesClass { + if (credentialTypesInstance === undefined) { + credentialTypesInstance = new CredentialTypesClass(); + } + + return credentialTypesInstance; +} diff --git a/packages/cli/src/Db.ts b/packages/cli/src/Db.ts new file mode 100644 index 0000000000..cb0ce3f914 --- /dev/null +++ b/packages/cli/src/Db.ts @@ -0,0 +1,69 @@ +import { + IDatabaseCollections, + DatabaseType, +} from './'; + +import { + UserSettings, +} from "n8n-core"; + +import { + ConnectionOptions, + createConnection, + getRepository, +} from "typeorm"; + +import * as config from 'config'; + + +import { + MongoDb, + SQLite, +} from './db'; + +export let collections: IDatabaseCollections = { + Credentials: null, + Execution: null, + Workflow: null, +}; + +import * as path from 'path'; + +export async function init(): Promise { + const dbType = config.get('database.type') as DatabaseType; + const n8nFolder = UserSettings.getUserN8nFolderPath(); + + let entities; + let connectionOptions: ConnectionOptions; + + if (dbType === 'mongodb') { + entities = MongoDb; + connectionOptions = { + type: 'mongodb', + url: config.get('database.mongodbConfig.url') as string, + useNewUrlParser: true, + }; + } else if (dbType === 'sqlite') { + entities = SQLite; + connectionOptions = { + type: 'sqlite', + database: path.join(n8nFolder, 'database.sqlite'), + }; + } else { + throw new Error(`The database "${dbType}" is currently not supported!`); + } + + Object.assign(connectionOptions, { + entities: Object.values(entities), + synchronize: true, + logging: false + }); + + await createConnection(connectionOptions); + + collections.Credentials = getRepository(entities.CredentialsEntity); + collections.Execution = getRepository(entities.ExecutionEntity); + collections.Workflow = getRepository(entities.WorkflowEntity); + + return collections; +} diff --git a/packages/cli/src/GenericHelpers.ts b/packages/cli/src/GenericHelpers.ts new file mode 100644 index 0000000000..30cc4dcba0 --- /dev/null +++ b/packages/cli/src/GenericHelpers.ts @@ -0,0 +1,48 @@ +import * as config from 'config'; +import * as express from 'express'; + + +/** + * Displays a message to the user + * + * @export + * @param {string} message The message to display + * @param {string} [level='log'] + */ +export function logOutput(message: string, level = 'log'): void { + if (level === 'log') { + console.log(message); + } else if (level === 'error') { + console.error(message); + } +} + + +/** + * Returns the base URL n8n is reachable from + * + * @export + * @returns {string} + */ +export function getBaseUrl(): string { + const protocol = config.get('urls.protocol') as string; + const host = config.get('urls.host') as string; + const port = config.get('urls.port') as number; + + if (protocol === 'http' && port === 80 || protocol === 'https' && port === 443) { + return `${protocol}://${host}/`; + } + return `${protocol}://${host}:${port}/`; +} + + +/** + * Returns the session id if one is set + * + * @export + * @param {express.Request} req + * @returns {(string | undefined)} + */ +export function getSessionId(req: express.Request): string | undefined { + return req.headers.sessionid as string | undefined; +} diff --git a/packages/cli/src/Interfaces.ts b/packages/cli/src/Interfaces.ts new file mode 100644 index 0000000000..d15424daed --- /dev/null +++ b/packages/cli/src/Interfaces.ts @@ -0,0 +1,248 @@ +import { + IConnections, + ICredentialsDecrypted, + ICredentialsEncrypted, + IDataObject, + IExecutionError, + INode, + IRun, + IRunExecutionData, + ITaskData, + IWorkflowSettings, + WorkflowExecuteMode, +} from 'n8n-workflow'; + +import { ObjectID, Repository } from "typeorm"; + + +import { Url } from 'url'; +import { Request } from 'express'; + +export interface IActivationError { + time: number; + error: { + message: string; + }; +} + +export interface ICustomRequest extends Request { + parsedUrl: Url | undefined; +} + + +export interface IDatabaseCollections { + Credentials: Repository | null; + Execution: Repository | null; + Workflow: Repository | null; +} + + +export interface IWorkflowBase { + id?: number | string | ObjectID; + name: string; + active: boolean; + createdAt: number | string; + updatedAt: number | string; + nodes: INode[]; + connections: IConnections; + settings?: IWorkflowSettings; + staticData?: IDataObject; +} + + +// Almost identical to editor-ui.Interfaces.ts +export interface IWorkflowDb extends IWorkflowBase { + id: number | string | ObjectID; +} + +export interface IWorkflowResponse extends IWorkflowBase { + id: string; +} + +export interface IWorkflowShortResponse { + id: string; + name: string; + active: boolean; + createdAt: number | string; + updatedAt: number | string; +} + +export interface ICredentialsBase { + createdAt: number | string; + updatedAt: number | string; +} + +export interface ICredentialsDb extends ICredentialsBase, ICredentialsEncrypted{ + id: number | string | ObjectID; +} + +export interface ICredentialsResponse extends ICredentialsDb { + id: string; +} + +export interface ICredentialsDecryptedDb extends ICredentialsBase, ICredentialsDecrypted { + id: number | string | ObjectID; +} + +export interface ICredentialsDecryptedResponse extends ICredentialsDecryptedDb { + id: string; +} + +export type DatabaseType = 'mongodb' | 'sqlite'; + +export interface IExecutionBase { + id?: number | string | ObjectID; + mode: WorkflowExecuteMode; + startedAt: number; + stoppedAt: number; + workflowId?: string; // To be able to filter executions easily // + finished: boolean; + retryOf?: number | string | ObjectID; // If it is a retry, the id of the execution it is a retry of. + retrySuccessId?: number | string | ObjectID; // If it failed and a retry did succeed. The id of the successful retry. +} + +// Data in regular format with references +export interface IExecutionDb extends IExecutionBase { + data: IRunExecutionData; + workflowData?: IWorkflowBase; +} + +export interface IExecutionPushResponse { + executionId?: string; + waitingForWebhook?: boolean; +} + +export interface IExecutionResponse extends IExecutionBase { + id: string; + data: IRunExecutionData; + retryOf?: string; + retrySuccessId?: string; + workflowData: IWorkflowBase; +} + +// Flatted data to save memory when saving in database or transfering +// via REST API +export interface IExecutionFlatted extends IExecutionBase { + data: string; + workflowData: IWorkflowBase; +} + +export interface IExecutionFlattedDb extends IExecutionBase { + id: number | string | ObjectID; + data: string; + workflowData: IWorkflowBase; +} + +export interface IExecutionFlattedResponse extends IExecutionFlatted { + id: string; + retryOf?: string; +} + +export interface IExecutionsListResponse { + count: number; + // results: IExecutionShortResponse[]; + results: IExecutionsSummary[]; +} + +export interface IExecutionsStopData { + finished?: boolean; + mode: WorkflowExecuteMode; + startedAt: number | string; + stoppedAt: number | string; +} + +export interface IExecutionsSummary { + id: string; + mode: WorkflowExecuteMode; + finished?: boolean; + retryOf?: string; + retrySuccessId?: string; + startedAt: number | string; + stoppedAt?: number | string; + workflowId: string; + workflowName?: string; +} + +export interface IExecutionDeleteFilter { + deleteBefore?: number; + filters?: IDataObject; + ids?: string[]; +} + +export interface IN8nConfig { + database: IN8nConfigDatabase; + nodes?: IN8nConfigNodes; +} + +export interface IN8nConfigDatabase { + type: DatabaseType; + mongodbConfig?: { + url: string; + }; +} + +export interface IN8nConfigNodes { + exclude?: string[]; +} + + +export interface IN8nUISettings { + endpointWebhook: string; + endpointWebhookTest: string; + saveManualRuns: boolean; + timezone: string; + urlBaseWebhook: string; +} + + +export interface IPushData { + data: IPushDataExecutionFinished | IPushDataNodeExecuteAfter | IPushDataNodeExecuteBefore | IPushDataTestWebhook; + type: IPushDataType; +} + +export type IPushDataType = 'executionFinished' | 'nodeExecuteAfter' | 'nodeExecuteBefore' | 'testWebhookDeleted' | 'testWebhookReceived'; + + +export interface IPushDataExecutionFinished { + data: IRun; + executionId: string; +} + + +export interface IPushDataNodeExecuteAfter { + data: ITaskData; + executionId: string; + nodeName: string; +} + + +export interface IPushDataNodeExecuteBefore { + executionId: string; + nodeName: string; +} + + +export interface IPushDataTestWebhook { + workflowId: string; +} + + +export interface IResponseCallbackData { + data?: IDataObject | IDataObject[]; + noWebhookResponse?: boolean; +} + + +export interface IWorkflowErrorData { + [key: string]: IDataObject | string | number | IExecutionError; + execution: { + id?: string; + error: IExecutionError; + lastNodeExecuted: string; + mode: WorkflowExecuteMode; + }; + workflow: { + id?: string; + name: string; + }; +} diff --git a/packages/cli/src/LoadNodesAndCredentials.ts b/packages/cli/src/LoadNodesAndCredentials.ts new file mode 100644 index 0000000000..4f66510b04 --- /dev/null +++ b/packages/cli/src/LoadNodesAndCredentials.ts @@ -0,0 +1,261 @@ +import { + CUSTOM_EXTENSION_ENV, + UserSettings, +} from 'n8n-core'; +import { + ICredentialType, + INodeType, +} from 'n8n-workflow'; +import { + IN8nConfigNodes, +} from './'; + +import { promises as fs } from 'fs'; +import * as path from 'path'; +import * as glob from 'glob-promise'; + +import * as config from 'config'; + + + +class LoadNodesAndCredentialsClass { + nodeTypes: { + [key: string]: INodeType + } = {}; + + credentialTypes: { + [key: string]: ICredentialType + } = {}; + + excludeNodes: string[] | undefined = undefined; + + nodeModulesPath = ''; + + async init(directory?: string) { + // Get the path to the node-modules folder to be later able + // to load the credentials and nodes + const checkPaths = [ + // In case "n8n" package is in same node_modules folder. + path.join(__dirname, '..', '..', '..', 'n8n-workflow'), + // In case "n8n" package is the root and the packages are + // in the "node_modules" folder underneath it. + path.join(__dirname, '..', '..', 'node_modules', 'n8n-workflow'), + ]; + for (const checkPath of checkPaths) { + try { + await fs.access(checkPath); + // Folder exists, so use it. + this.nodeModulesPath = path.dirname(checkPath); + break; + } catch (error) { + // Folder does not exist so get next one + continue; + } + } + + if (this.nodeModulesPath === '') { + throw new Error('Could not find "node_modules" folder!'); + } + + const nodeSettings = config.get('nodes') as IN8nConfigNodes | undefined; + if (nodeSettings !== undefined && nodeSettings.exclude !== undefined) { + this.excludeNodes = nodeSettings.exclude; + } + + // Get all the installed packages which contain n8n nodes + const packages = await this.getN8nNodePackages(); + + for (const packageName of packages) { + await this.loadDataFromPackage(packageName); + } + + // Read nodes and credentials from custom directories + const customDirectories = []; + + // Add "custom" folder in user-n8n folder + customDirectories.push(UserSettings.getUserN8nFolderCustomExtensionPath()); + + // Add folders from special environment variable + if (process.env[CUSTOM_EXTENSION_ENV] !== undefined) { + const customExtensionFolders = process.env[CUSTOM_EXTENSION_ENV]!.split(';'); + customDirectories.push.apply(customDirectories, customExtensionFolders); + } + + for (const directory of customDirectories) { + await this.loadDataFromDirectory('CUSTOM', directory); + } + } + + + /** + * Returns all the names of the packages which could + * contain n8n nodes + * + * @returns {Promise} + * @memberof LoadNodesAndCredentialsClass + */ + async getN8nNodePackages(): Promise { + const packages: string[] = []; + for (const file of await fs.readdir(this.nodeModulesPath)) { + if (file.indexOf('n8n-nodes-') !== 0) { + continue; + } + + // Check if it is really a folder + if (!(await fs.stat(path.join(this.nodeModulesPath, file))).isDirectory()) { + continue; + } + + packages.push(file); + } + + return packages; + } + + + /** + * Loads credentials from a file + * + * @param {string} credentialName The name of the credentials + * @param {string} filePath The file to read credentials from + * @returns {Promise} + * @memberof N8nPackagesInformationClass + */ + async loadCredentialsFromFile(credentialName: string, filePath: string): Promise { + const tempModule = require(filePath); + + let tempCredential: ICredentialType; + try { + tempCredential = new tempModule[credentialName]() as ICredentialType; + } catch (e) { + if (e instanceof TypeError) { + throw new Error(`Class with name "${credentialName}" could not be found. Please check if the class is named correctly!`); + } else { + throw e; + } + } + + this.credentialTypes[credentialName] = tempCredential; + } + + + /** + * Loads a node from a file + * + * @param {string} packageName The package name to set for the found nodes + * @param {string} nodeName Tha name of the node + * @param {string} filePath The file to read node from + * @returns {Promise} + * @memberof N8nPackagesInformationClass + */ + async loadNodeFromFile(packageName: string, nodeName: string, filePath: string): Promise { + let tempNode: INodeType; + let fullNodeName: string; + + const tempModule = require(filePath); + try { + tempNode = new tempModule[nodeName]() as INodeType; + } catch (error) { + console.error(`Error loading node "${nodeName}" from: "${filePath}"`); + throw error; + } + + fullNodeName = packageName + '.' + tempNode.description.name; + tempNode.description.name = fullNodeName; + + if (tempNode.description.icon !== undefined && + tempNode.description.icon.startsWith('file:')) { + // If a file icon gets used add the full path + tempNode.description.icon = 'file:' + path.join(path.dirname(filePath), tempNode.description.icon.substr(5)); + } + + // Check if the node should be skipped + if (this.excludeNodes !== undefined && this.excludeNodes.includes(fullNodeName)) { + return; + } + + this.nodeTypes[fullNodeName] = tempNode; + } + + + /** + * Loads nodes and credentials from the given directory + * + * @param {string} setPackageName The package name to set for the found nodes + * @param {string} directory The directory to look in + * @returns {Promise} + * @memberof N8nPackagesInformationClass + */ + async loadDataFromDirectory(setPackageName: string, directory: string): Promise { + const files = await glob(path.join(directory, '*\.@(node|credentials)\.js')); + + let fileName: string; + let type: string; + + const loadPromises = []; + for (const filePath of files) { + [fileName, type] = path.parse(filePath).name.split('.'); + + if (type === 'node') { + loadPromises.push(this.loadNodeFromFile(setPackageName, fileName, filePath)); + } else if (type === 'credentials') { + loadPromises.push(this.loadCredentialsFromFile(fileName, filePath)); + } + } + + await Promise.all(loadPromises); + } + + + /** + * Loads nodes and credentials from the package with the given name + * + * @param {string} packageName The name to read data from + * @returns {Promise} + * @memberof N8nPackagesInformationClass + */ + async loadDataFromPackage(packageName: string): Promise { + // Get the absolute path of the package + const packagePath = path.join(this.nodeModulesPath, packageName); + + // Read the data from the package.json file to see if any n8n data is defiend + const packageFileString = await fs.readFile(path.join(packagePath, 'package.json'), 'utf8'); + const packageFile = JSON.parse(packageFileString); + if (!packageFile.hasOwnProperty('n8n')) { + return; + } + + let tempPath: string, filePath: string; + + // Read all node types + let fileName: string, type: string; + if (packageFile.n8n.hasOwnProperty('nodes') && Array.isArray(packageFile.n8n.nodes)) { + for (filePath of packageFile.n8n.nodes) { + tempPath = path.join(packagePath, filePath); + [fileName, type] = path.parse(filePath).name.split('.'); + await this.loadNodeFromFile(packageName, fileName, tempPath); + } + } + + // Read all credential types + if (packageFile.n8n.hasOwnProperty('credentials') && Array.isArray(packageFile.n8n.credentials)) { + for (filePath of packageFile.n8n.credentials) { + tempPath = path.join(packagePath, filePath); + [fileName, type] = path.parse(filePath).name.split('.'); + this.loadCredentialsFromFile(fileName, tempPath); + } + } + } +} + + + +let packagesInformationInstance: LoadNodesAndCredentialsClass | undefined; + +export function LoadNodesAndCredentials(): LoadNodesAndCredentialsClass { + if (packagesInformationInstance === undefined) { + packagesInformationInstance = new LoadNodesAndCredentialsClass(); + } + + return packagesInformationInstance; +} diff --git a/packages/cli/src/NodeTypes.ts b/packages/cli/src/NodeTypes.ts new file mode 100644 index 0000000000..e321cce6c5 --- /dev/null +++ b/packages/cli/src/NodeTypes.ts @@ -0,0 +1,37 @@ +import { + INodeType, + INodeTypes, +} from 'n8n-workflow'; + + +class NodeTypesClass implements INodeTypes { + + nodeTypes: { + [key: string]: INodeType + } = {}; + + + async init(nodeTypes: {[key: string]: INodeType }): Promise { + this.nodeTypes = nodeTypes; + } + + getAll(): INodeType[] { + return Object.values(this.nodeTypes); + } + + getByName(nodeType: string): INodeType | undefined { + return this.nodeTypes[nodeType]; + } +} + + + +let nodeTypesInstance: NodeTypesClass | undefined; + +export function NodeTypes(): NodeTypesClass { + if (nodeTypesInstance === undefined) { + nodeTypesInstance = new NodeTypesClass(); + } + + return nodeTypesInstance; +} diff --git a/packages/cli/src/Push.ts b/packages/cli/src/Push.ts new file mode 100644 index 0000000000..b10c3f7266 --- /dev/null +++ b/packages/cli/src/Push.ts @@ -0,0 +1,86 @@ +// @ts-ignore +import * as sseChannel from 'sse-channel'; +import * as express from 'express'; + +import { + IPushData, + IPushDataType, +} from '.'; + +export class Push { + private channel: sseChannel; + private connections: { + [key: string]: express.Response; + } = {}; + + + constructor() { + this.channel = new sseChannel({ + cors: { + // Allow access also from frontend when developing + origins: ['http://localhost:8080'], + }, + }); + + this.channel.on('disconnect', (channel: string, res: express.Response) => { + if (res.req !== undefined) { + delete this.connections[res.req.query.sessionId]; + } + }); + } + + + /** + * Adds a new push connection + * + * @param {string} sessionId The id of the session + * @param {express.Request} req The request + * @param {express.Response} res The response + * @memberof Push + */ + add(sessionId: string, req: express.Request, res: express.Response) { + if (this.connections[sessionId] !== undefined) { + // Make sure to remove existing connection with the same session + // id if one exists already + this.connections[sessionId].end(); + this.channel.removeClient(this.connections[sessionId]); + } + + this.connections[sessionId] = res; + this.channel.addClient(req, res); + } + + + /** + * Sends data to the client which is connected via a specific session + * + * @param {string} sessionId The session id of client to send data to + * @param {string} type Type of data to send + * @param {*} data + * @memberof Push + */ + send(sessionId: string, type: IPushDataType, data: any) { // tslint:disable-line:no-any + if (this.connections[sessionId] === undefined) { + // TODO: Log that properly! + console.error(`The session "${sessionId}" is not registred.`); + return; + } + + const sendData: IPushData = { + type, + data, + }; + + this.channel.send(JSON.stringify(sendData)); + } +} + +let activePushInstance: Push | undefined; + +export function getInstance(): Push { + if (activePushInstance === undefined) { + activePushInstance = new Push(); + } + + return activePushInstance; +} diff --git a/packages/cli/src/ResponseHelper.ts b/packages/cli/src/ResponseHelper.ts new file mode 100644 index 0000000000..66b503f326 --- /dev/null +++ b/packages/cli/src/ResponseHelper.ts @@ -0,0 +1,176 @@ +import { Request, Response } from 'express'; +import { parse, stringify } from 'flatted'; + +import { + IExecutionDb, + IExecutionFlatted, + IExecutionFlattedDb, + IExecutionResponse, + IWorkflowDb, +} from './'; + +/** + * Special Error which allows to return also an error code and http status code + * + * @export + * @class ReponseError + * @extends {Error} + */ +export class ReponseError extends Error { + + // The HTTP status code of response + httpStatusCode?: number; + + // The error code in the resonse + errorCode?: number; + + /** + * Creates an instance of ReponseError. + * @param {string} message The error message + * @param {number} [errorCode] The error code which can be used by frontend to identify the actual error + * @param {number} [httpStatusCode] The HTTP status code the response should have + * @memberof ReponseError + */ + constructor(message: string, errorCode?: number, httpStatusCode?: number) { + super(message); + this.name = 'ReponseError'; + + if (errorCode) { + this.errorCode = errorCode; + } + if (httpStatusCode) { + this.httpStatusCode = httpStatusCode; + } + } +} + + +export function sendSuccessResponse(res: Response, data: any, raw?: boolean) { // tslint:disable-line:no-any + res.setHeader('Content-Type', 'application/json'); + + if (raw === true) { + res.send(JSON.stringify(data)); + return; + } else { + res.send(JSON.stringify({ + data + })); + } +} + + +export function sendErrorResponse(res: Response, error: ReponseError) { + let httpStatusCode = 500; + if (error.httpStatusCode) { + httpStatusCode = error.httpStatusCode; + } + + if (process.env.NODE_ENV !== 'production') { + console.error('ERROR RESPONSE'); + console.error(error); + } + + const response = { + code: 0, + message: 'Unknown error', + }; + + if (error.errorCode) { + response.code = error.errorCode; + } + if (error.message) { + response.message = error.message; + } + if (error.stack && process.env.NODE_ENV !== 'production') { + // @ts-ignore + response.stack = error.stack; + } + + res.status(httpStatusCode).send(JSON.stringify(response)); +} + + +/** + * A helper function which does not just allow to return Promises it also makes sure that + * all the responses have the same format + * + * + * @export + * @param {(req: Request, res: Response) => Promise} processFunction The actual function to process the request + * @returns + */ + +export function send(processFunction: (req: Request, res: Response) => Promise) { // tslint:disable-line:no-any + + return async (req: Request, res: Response) => { + try { + const data = await processFunction(req, res); + + // Success response + sendSuccessResponse(res, data); + } catch (error) { + // Error response + sendErrorResponse(res, error); + } + }; +} + + +/** + * Flattens the Execution data. + * As it contains a lot of references which normally would be saved as duplicate data + * with regular JSON.stringify it gets flattened which keeps the references in place. + * + * @export + * @param {IExecutionDb} fullExecutionData The data to flatten + * @returns {IExecutionFlatted} + */ +export function flattenExecutionData(fullExecutionData: IExecutionDb): IExecutionFlatted { + // Flatten the data + const returnData: IExecutionFlatted = Object.assign({}, { + data: stringify(fullExecutionData.data), + mode: fullExecutionData.mode, + startedAt: fullExecutionData.startedAt, + stoppedAt: fullExecutionData.stoppedAt, + finished: fullExecutionData.finished ? fullExecutionData.finished : false, + workflowId: fullExecutionData.workflowId, + workflowData: fullExecutionData.workflowData!, + }); + + if (fullExecutionData.id !== undefined) { + returnData.id = fullExecutionData.id!.toString(); + } + + if (fullExecutionData.retryOf !== undefined) { + returnData.retryOf = fullExecutionData.retryOf!.toString(); + } + + if (fullExecutionData.retrySuccessId !== undefined) { + returnData.retrySuccessId = fullExecutionData.retrySuccessId!.toString(); + } + + return returnData; +} + + +/** + * Unflattens the Execution data. + * + * @export + * @param {IExecutionFlattedDb} fullExecutionData The data to unflatten + * @returns {IExecutionResponse} + */ +export function unflattenExecutionData(fullExecutionData: IExecutionFlattedDb): IExecutionResponse { + + const returnData: IExecutionResponse = Object.assign({}, { + id: fullExecutionData.id.toString(), + workflowData: fullExecutionData.workflowData as IWorkflowDb, + data: parse(fullExecutionData.data), + mode: fullExecutionData.mode, + startedAt: fullExecutionData.startedAt, + stoppedAt: fullExecutionData.stoppedAt, + finished: fullExecutionData.finished ? fullExecutionData.finished : false + }); + + return returnData; +} diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts new file mode 100644 index 0000000000..5fc4e5e4cd --- /dev/null +++ b/packages/cli/src/Server.ts @@ -0,0 +1,997 @@ +import * as express from 'express'; +import * as bodyParser from 'body-parser'; +import * as history from 'connect-history-api-fallback'; +import * as requestPromise from 'request-promise-native'; + +import { + IActivationError, + ActiveWorkflowRunner, + ICustomRequest, + ICredentialsDb, + ICredentialsDecryptedDb, + ICredentialsDecryptedResponse, + ICredentialsResponse, + CredentialTypes, + Db, + IExecutionDeleteFilter, + IExecutionFlatted, + IExecutionFlattedDb, + IExecutionFlattedResponse, + IExecutionPushResponse, + IExecutionsListResponse, + IExecutionsStopData, + IExecutionsSummary, + IN8nUISettings, + IWorkflowBase, + IWorkflowShortResponse, + IWorkflowResponse, + NodeTypes, + Push, + ResponseHelper, + TestWebhooks, + WebhookHelpers, + WorkflowExecuteAdditionalData, + WorkflowHelpers, + GenericHelpers, +} from './'; + +import { + ActiveExecutions, + Credentials, + LoadNodeParameterOptions, + UserSettings, + WorkflowExecute, +} from 'n8n-core'; + +import { + ICredentialType, + IDataObject, + INodeCredentials, + INodeTypeDescription, + INodePropertyOptions, + IRunData, + Workflow, +} from 'n8n-workflow'; + +import { + FindManyOptions, + LessThan, + LessThanOrEqual, +} from 'typeorm'; + +import * as parseUrl from 'parseurl'; +import * as config from 'config'; +// @ts-ignore +import * as timezones from 'google-timezones-json'; + +class App { + + app: express.Application; + activeWorkflowRunner: ActiveWorkflowRunner.ActiveWorkflowRunner; + testWebhooks: TestWebhooks.TestWebhooks; + endpointWebhook: string; + endpointWebhookTest: string; + saveManualRuns: boolean; + timezone: string; + activeExecutionsInstance: ActiveExecutions.ActiveExecutions; + push: Push.Push; + + constructor() { + this.app = express(); + + this.endpointWebhook = config.get('urls.endpointWebhook') as string; + this.endpointWebhookTest = config.get('urls.endpointWebhookTest') as string; + this.saveManualRuns = config.get('executions.saveManualRuns') as boolean; + this.timezone = config.get('timezone') as string; + + this.config(); + this.activeWorkflowRunner = ActiveWorkflowRunner.getInstance(); + this.testWebhooks = TestWebhooks.getInstance(); + this.push = Push.getInstance(); + + this.activeExecutionsInstance = ActiveExecutions.getInstance(); + } + + + /** + * Returns the current epoch time + * + * @returns {number} + * @memberof App + */ + getCurrentDate(): number { + return Math.floor(new Date().getTime()); + } + + + private config(): void { + + // Get push connections + this.app.use((req: express.Request, res: express.Response, next: express.NextFunction) => { + if (req.url.indexOf('/rest/push') === 0) { + // TODO: Later also has to add some kind of authentication token + if (req.query.sessionId === undefined) { + next(new Error('The query parameter "sessionId" is missing!')); + return; + } + + this.push.add(req.query.sessionId, req, res); + return; + } + next(); + }); + + // Make sure that each request has the "parsedUrl" parameter + this.app.use((req: express.Request, res: express.Response, next: express.NextFunction) => { + (req as ICustomRequest).parsedUrl = parseUrl(req); + next(); + }); + + // Support application/json type post data + this.app.use(bodyParser.json({ limit: "16mb" })); + + // Make sure that Vue history mode works properly + this.app.use(history({ + rewrites: [ + { + from: new RegExp(`^\/(rest|${this.endpointWebhook}|${this.endpointWebhookTest})\/.*$`), + to: (context) => { + return context.parsedUrl!.pathname!.toString(); + } + } + ] + })); + + //support application/x-www-form-urlencoded post data + this.app.use(bodyParser.urlencoded({ extended: false })); + + this.app.use((req: express.Request, res: express.Response, next: express.NextFunction) => { + // Allow access also from frontend when developing + res.header('Access-Control-Allow-Origin', 'http://localhost:8080'); + res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, PUT, PATCH, DELETE'); + res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, sessionid'); + next(); + }); + + + this.app.use((req: express.Request, res: express.Response, next: express.NextFunction) => { + if (Db.collections.Workflow === null) { + const error = new ResponseHelper.ReponseError('Database is not ready!', undefined, 503); + return ResponseHelper.sendErrorResponse(res, error); + } + + next(); + }); + + + + // ---------------------------------------- + // Workflow + // ---------------------------------------- + + + // Creates a new workflow + this.app.post('/rest/workflows', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + + const newWorkflowData = req.body; + + newWorkflowData.createdAt = this.getCurrentDate(); + newWorkflowData.updatedAt = this.getCurrentDate(); + + newWorkflowData.id = undefined; + + // Save the workflow in DB + const result = await Db.collections.Workflow!.save(newWorkflowData); + + // Convert to response format in which the id is a string + (result as IWorkflowBase as IWorkflowResponse).id = result.id.toString(); + return result as IWorkflowBase as IWorkflowResponse; + + })); + + + // Reads and returns workflow data from an URL + this.app.get('/rest/workflows/from-url', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + if (req.query.url === undefined) { + throw new ResponseHelper.ReponseError(`The parameter "url" is missing!`, undefined, 400); + } + if (!req.query.url.match(/^http[s]?:\/\/.*\.json$/i)) { + throw new ResponseHelper.ReponseError(`The parameter "url" is not valid! It does not seem to be a URL pointing to a n8n workflow JSON file.`, undefined, 400); + } + const data = await requestPromise.get(req.query.url); + + let workflowData: IWorkflowResponse | undefined; + try { + workflowData = JSON.parse(data); + } catch (error) { + throw new ResponseHelper.ReponseError(`The URL does not point to valid JSON file!`, undefined, 400); + } + + // Do a very basic check if it is really a n8n-workflow-json + if (workflowData === undefined || workflowData.nodes === undefined || !Array.isArray(workflowData.nodes) || + workflowData.connections === undefined || typeof workflowData.connections !== 'object' || + Array.isArray(workflowData.connections)) { + throw new ResponseHelper.ReponseError(`The data in the file does not seem to be a n8n workflow JSON file!`, undefined, 400); + } + + return workflowData; + })); + + + // Returns workflows + this.app.get('/rest/workflows', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + const findQuery = {} as FindManyOptions; + if (req.query.filter) { + findQuery.where = JSON.parse(req.query.filter); + } + + // Return only the fields we need + findQuery.select = ['id', 'name', 'active', 'createdAt', 'updatedAt']; + + const results = await Db.collections.Workflow!.find(findQuery); + + for (const entry of results) { + (entry as unknown as IWorkflowShortResponse).id = entry.id.toString(); + } + + return results as unknown as IWorkflowShortResponse[]; + })); + + + // Returns a specific workflow + this.app.get('/rest/workflows/:id', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + const result = await Db.collections.Workflow!.findOne(req.params.id); + + if (result === undefined) { + return undefined; + } + + // Convert to response format in which the id is a string + (result as IWorkflowBase as IWorkflowResponse).id = result.id.toString(); + return result as IWorkflowBase as IWorkflowResponse; + })); + + + // 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 id = req.params.id; + + if (this.activeWorkflowRunner.isActive(id)) { + // When workflow gets saved always remove it as the triggers could have been + // changed and so the changes would not take effect + await this.activeWorkflowRunner.remove(id); + } + + if (newWorkflowData.settings) { + if (newWorkflowData.settings.timezone === 'DEFAULT') { + // Do not save the default timezone + delete newWorkflowData.settings.timezone; + } + if (newWorkflowData.settings.saveManualRuns === 'DEFAULT') { + // Do not save when default got set + delete newWorkflowData.settings.saveManualRuns; + } + } + + + newWorkflowData.updatedAt = this.getCurrentDate(); + + await Db.collections.Workflow!.update(id, newWorkflowData); + + // We sadly get nothing back from "update". Neither if it updated a record + // nor the new value. So query now the hopefully updated entry. + const reponseData = await Db.collections.Workflow!.findOne(id); + + if (reponseData === undefined) { + throw new ResponseHelper.ReponseError(`Workflow with id "${id}" could not be found to be updated.`, undefined, 400); + } + + if (reponseData.active === true) { + // When the workflow is supposed to be active add it again + try { + await this.activeWorkflowRunner.add(id); + } catch (error) { + // If workflow could not be activated set it again to inactive + newWorkflowData.active = false; + await Db.collections.Workflow!.update(id, newWorkflowData); + + // Also set it in the returned data + reponseData.active = false; + + // Now return the original error for UI to display + throw error; + } + } + + // Convert to response format in which the id is a string + (reponseData as IWorkflowBase as IWorkflowResponse).id = reponseData.id.toString(); + return reponseData as IWorkflowBase as IWorkflowResponse; + })); + + + // Deletes a specific workflow + this.app.delete('/rest/workflows/:id', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + const id = req.params.id; + + if (this.activeWorkflowRunner.isActive(id)) { + // Before deleting a workflow deactivate it + await this.activeWorkflowRunner.remove(id); + } + + await Db.collections.Workflow!.delete(id); + + return true; + })); + + + this.app.post('/rest/workflows/run', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + const workflowData = req.body.workflowData; + const runData: IRunData | undefined = req.body.runData; + const startNodes: string[] | undefined = req.body.startNodes; + const destinationNode: string | undefined = req.body.destinationNode; + const nodeTypes = NodeTypes(); + const executionMode = 'manual'; + + const sessionId = GenericHelpers.getSessionId(req); + + // Do not supply the saved static data! Tests always run with initially empty static data. + // The reason is that it contains information like webhook-ids. If a workflow is currently + // active it would see its id and would so not create an own test-webhook. Additionally would + // it also delete the webhook at the service in the end. So that the active workflow would end + // up without still being active but not receiving and webhook requests anymore as it does + // not exist anymore. + const workflowInstance = new Workflow(workflowData.id, workflowData.nodes, workflowData.connections, false, nodeTypes, undefined, workflowData.settings); + + const additionalData = await WorkflowExecuteAdditionalData.get(executionMode, workflowData, workflowInstance, sessionId); + + const workflowExecute = new WorkflowExecute(additionalData, executionMode); + + let executionId: string; + + if (runData === undefined || startNodes === undefined || startNodes.length === 0 || destinationNode === undefined) { + // Execute all nodes + + if (WorkflowHelpers.isWorkflowIdValid(workflowData.id) === true) { + // Webhooks can only be tested with saved workflows + const needsWebhook = await this.testWebhooks.needsWebhookData(workflowData, workflowInstance, additionalData, executionMode, sessionId, destinationNode); + if (needsWebhook === true) { + return { + waitingForWebhook: true, + }; + } + } + + // Can execute without webhook so go on + executionId = await workflowExecute.run(workflowInstance, undefined, destinationNode); + } else { + // Execute only the nodes between start and destination nodes + executionId = await workflowExecute.runPartialWorkflow(workflowInstance, runData, startNodes, destinationNode); + } + + return { + executionId, + }; + })); + + + // Returns parameter values which normally get loaded from an external API or + // get generated dynamically + this.app.get('/rest/node-parameter-options', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + const nodeType = req.query.nodeType; + let credentials: INodeCredentials | undefined = undefined; + if (req.query.credentials !== undefined) { + credentials = JSON.parse(req.query.credentials); + } + const methodName = req.query.methodName; + + const nodeTypes = NodeTypes(); + const executionMode = 'manual'; + + const sessionId = GenericHelpers.getSessionId(req); + + const loadDataInstance = new LoadNodeParameterOptions(nodeType, nodeTypes, credentials); + + const workflowData = loadDataInstance.getWorkflowData() as IWorkflowBase; + const additionalData = await WorkflowExecuteAdditionalData.get(executionMode, workflowData, loadDataInstance.workflow, sessionId); + + return loadDataInstance.getOptions(methodName, additionalData); + })); + + + // Returns all the node-types + this.app.get('/rest/node-types', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + + const returnData: INodeTypeDescription[] = []; + + const nodeTypes = NodeTypes(); + + const allNodes = nodeTypes.getAll(); + + allNodes.forEach((nodeData) => { + returnData.push(nodeData.description); + }); + + return returnData; + })); + + + + // ---------------------------------------- + // Node-Types + // ---------------------------------------- + + + // Returns the node icon + this.app.get('/rest/node-icon/:nodeType', async (req: express.Request, res: express.Response): Promise => { + const nodeTypeName = req.params.nodeType; + + const nodeTypes = NodeTypes(); + const nodeType = nodeTypes.getByName(nodeTypeName); + + if (nodeType === undefined) { + res.status(404).send('The nodeType is not known.'); + return; + } + + if (nodeType.description.icon === undefined) { + res.status(404).send('No icon found for node.'); + return; + } + + if (!nodeType.description.icon.startsWith('file:')) { + res.status(404).send('Node does not have a file icon.'); + return; + } + + const filepath = nodeType.description.icon.substr(5); + + res.sendFile(filepath); + }); + + + + // ---------------------------------------- + // Active Workflows + // ---------------------------------------- + + + // Returns the active workflow ids + this.app.get('/rest/active', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + return this.activeWorkflowRunner.getActiveWorkflows(); + })); + + + // Returns if the workflow with the given id had any activation errors + this.app.get('/rest/active/error/:id', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + const id = req.params.id; + return this.activeWorkflowRunner.getActivationError(id); + })); + + + + // ---------------------------------------- + // Credentials + // ---------------------------------------- + + + // Deletes a specific credential + this.app.delete('/rest/credentials/:id', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + const id = req.params.id; + + await Db.collections.Credentials!.delete({ id }); + + return true; + })); + + // Creates new credentials + this.app.post('/rest/credentials', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + const incomingData = req.body; + + // Add the added date for node access permissions + for (const nodeAccess of incomingData.nodesAccess) { + nodeAccess.date = this.getCurrentDate(); + } + + const encryptionKey = await UserSettings.getEncryptionKey(); + if (encryptionKey === undefined) { + throw new Error('No encryption key got found to encrypt the credentials!'); + } + + // Encrypt the data + const credentials = new Credentials(incomingData.name, incomingData.type, incomingData.nodesAccess); + credentials.setData(incomingData.data, encryptionKey); + const newCredentialsData = credentials.getDataToSave() as ICredentialsDb; + + // Add special database related data + newCredentialsData.createdAt = this.getCurrentDate(); + newCredentialsData.updatedAt = this.getCurrentDate(); + + // 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); + + // Convert to response format in which the id is a string + (result as unknown as ICredentialsResponse).id = result.id.toString(); + return result as unknown as ICredentialsResponse; + })); + + + // Updates existing credentials + this.app.patch('/rest/credentials/:id', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + const incomingData = req.body; + + const id = req.params.id; + + // Add the date for newly added node access permissions + for (const nodeAccess of incomingData.nodesAccess) { + if (!nodeAccess.date) { + nodeAccess.date = this.getCurrentDate(); + } + } + + const encryptionKey = await UserSettings.getEncryptionKey(); + if (encryptionKey === undefined) { + throw new Error('No encryption key got found to encrypt the credentials!'); + } + + // Encrypt the data + const credentials = new Credentials(incomingData.name, incomingData.type, incomingData.nodesAccess); + credentials.setData(incomingData.data, 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(id, newCredentialsData); + + // We sadly get nothing back from "update". Neither if it updated a record + // nor the new value. So query now the hopefully updated entry. + const reponseData = await Db.collections.Credentials!.findOne(id); + + if (reponseData === undefined) { + throw new ResponseHelper.ReponseError(`Credentials with id "${id}" could not be found to be updated.`, undefined, 400); + } + + // Remove the encrypted data as it is not needed in the frontend + reponseData.data = ''; + + // Convert to response format in which the id is a string + (reponseData as unknown as ICredentialsResponse).id = reponseData.id.toString(); + return reponseData as unknown as ICredentialsResponse; + })); + + + // Returns specific credentials + this.app.get('/rest/credentials/:id', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + const findQuery = {} as FindManyOptions; + + // Make sure the variable has an expected value + if (req.query.includeData === 'true') { + req.query.includeData = true; + } else { + req.query.includeData = false; + } + + if (req.query.includeData !== true) { + // Return only the fields we need + findQuery.select = ['id', 'name', 'type', 'nodesAccess', 'createdAt', 'updatedAt']; + } + + const result = await Db.collections.Credentials!.findOne(req.params.id); + + if (result === undefined) { + return result; + } + + let encryptionKey = undefined; + if (req.query.includeData === true) { + encryptionKey = await UserSettings.getEncryptionKey(); + if (encryptionKey === undefined) { + throw new Error('No encryption key got found to decrypt the credentials!'); + } + + const credentials = new Credentials(result.name, result.type, result.nodesAccess, result.data); + (result as ICredentialsDecryptedDb).data = credentials.getData(encryptionKey!); + } + + (result as ICredentialsDecryptedResponse).id = result.id.toString(); + + return result as ICredentialsDecryptedResponse; + })); + + + // Returns all the saved credentials + this.app.get('/rest/credentials', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + const findQuery = {} as FindManyOptions; + if (req.query.filter) { + findQuery.where = JSON.parse(req.query.filter); + if ((findQuery.where! as IDataObject).id !== undefined) { + // No idea if multiple where parameters make db search + // slower but to be sure that that is not the case we + // remove all unnecessary fields in case the id is defined. + findQuery.where = { id: (findQuery.where! as IDataObject).id }; + } + } + + findQuery.select = ['id', 'name', 'type', 'nodesAccess', 'createdAt', 'updatedAt']; + + const results = await Db.collections.Credentials!.find(findQuery) as unknown as ICredentialsResponse[]; + + let encryptionKey = undefined; + if (req.query.includeData === true) { + encryptionKey = await UserSettings.getEncryptionKey(); + if (encryptionKey === undefined) { + throw new Error('No encryption key got found to decrypt the credentials!'); + } + } + + let result; + for (result of results) { + (result as ICredentialsDecryptedResponse).id = result.id.toString(); + } + + return results; + })); + + + + // ---------------------------------------- + // Credential-Types + // ---------------------------------------- + + + // Returns all the credential types which are defined in the loaded n8n-modules + this.app.get('/rest/credential-types', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + + const returnData: ICredentialType[] = []; + + const credentialTypes = CredentialTypes(); + + credentialTypes.getAll().forEach((credentialData) => { + returnData.push(credentialData); + }); + + return returnData; + })); + + + + // ---------------------------------------- + // Executions + // ---------------------------------------- + + + // Returns all finished executions + this.app.get('/rest/executions', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + let filter: any = {}; // tslint:disable-line:no-any + + if (req.query.filter) { + filter = JSON.parse(req.query.filter); + } + + let limit = 20; + if (req.query.limit) { + limit = parseInt(req.query.limit, 10); + } + + const countFilter = JSON.parse(JSON.stringify(filter)); + if (req.query.lastStartedAt) { + filter.startedAt = LessThan(req.query.lastStartedAt); + } + + const resultsPromise = Db.collections.Execution!.find({ + where: filter, + order: { + startedAt: "DESC", + }, + take: limit, + }); + + const countPromise = Db.collections.Execution!.count(countFilter); + + const results: IExecutionFlattedDb[] = await resultsPromise; + const count = await countPromise; + + const returnResults: IExecutionsSummary[] = []; + + for (const result of results) { + returnResults.push({ + id: result.id!.toString(), + finished: result.finished, + mode: result.mode, + retryOf: result.retryOf ? result.retryOf.toString() : undefined, + retrySuccessId: result.retrySuccessId ? result.retrySuccessId.toString() : undefined, + startedAt: result.startedAt, + stoppedAt: result.stoppedAt, + workflowId: result.workflowData!.id!.toString(), + workflowName: result.workflowData!.name, + }); + } + + return { + count, + results: returnResults, + }; + })); + + + // Returns a specific execution + this.app.get('/rest/executions/:id', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + const result = await Db.collections.Execution!.findOne(req.params.id); + + if (result === undefined) { + return undefined; + } + + // Convert to response format in which the id is a string + (result as IExecutionFlatted as IExecutionFlattedResponse).id = result.id.toString(); + return result as IExecutionFlatted as IExecutionFlattedResponse; + })); + + + // Retries a failed execution + this.app.post('/rest/executions/:id/retry', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + // Get the data to execute + const fullExecutionDataFlatted = await Db.collections.Execution!.findOne(req.params.id); + + if (fullExecutionDataFlatted === undefined) { + throw new ResponseHelper.ReponseError(`The execution with the id "${req.params.id}" does not exist.`, 404, 404); + } + + const fullExecutionData = ResponseHelper.unflattenExecutionData(fullExecutionDataFlatted); + + if (fullExecutionData.finished === true) { + throw new Error('The execution did succeed and can so not be retried.'); + } + + const executionMode = 'retry'; + + const nodeTypes = NodeTypes(); + const workflowInstance = new Workflow(req.params.id, fullExecutionData.workflowData.nodes, fullExecutionData.workflowData.connections, false, nodeTypes, fullExecutionData.workflowData.staticData, fullExecutionData.workflowData.settings); + + const additionalData = await WorkflowExecuteAdditionalData.get(executionMode, fullExecutionData.workflowData, workflowInstance, undefined, req.params.id); + const workflowExecute = new WorkflowExecute(additionalData, executionMode); + + return workflowExecute.runExecutionData(workflowInstance, fullExecutionData.data); + })); + + + // Delete Executions + // INFORMATION: We use POST instead of DELETE to not run into any issues + // with the query data getting to long + this.app.post('/rest/executions/delete', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + const deleteData = req.body as IExecutionDeleteFilter; + + if (deleteData.deleteBefore !== undefined) { + const filters = { + startedAt: LessThanOrEqual(deleteData.deleteBefore), + }; + if (deleteData.filters !== undefined) { + Object.assign(filters, deleteData.filters); + } + + await Db.collections.Execution!.delete(filters); + } else if (deleteData.ids !== undefined) { + // Deletes all executions with the given ids + await Db.collections.Execution!.delete(deleteData.ids); + } else { + throw new Error('Required body-data "ids" or "deleteBefore" is missing!'); + } + })); + + + // ---------------------------------------- + // Executing Workflows + // ---------------------------------------- + + + // Returns all the currently working executions + // this.app.get('/rest/executions-current', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + this.app.get('/rest/executions-current', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + const executingWorkflows = this.activeExecutionsInstance.getActiveExecutions(); + + const returnData: IExecutionsSummary[] = []; + + let filter: any = {}; // tslint:disable-line:no-any + if (req.query.filter) { + filter = JSON.parse(req.query.filter); + } + + for (const data of executingWorkflows) { + if (filter.workflowId !== undefined && filter.workflowId !== data.workflowId) { + continue; + } + returnData.push( + { + id: data.id.toString(), + workflowId: data.workflowId, + mode:data.mode, + startedAt: data.startedAt, + } + ); + } + + return returnData; + })); + + + // Forces the execution to stop + this.app.post('/rest/executions-current/:id/stop', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + const executionId = req.params.id; + + // Stopt he execution and wait till it is done and we got the data + const result = await this.activeExecutionsInstance.stopExecution(executionId); + + if (result === undefined) { + throw new Error(`The execution id "${executionId}" could not be found.`); + } + + const returnData: IExecutionsStopData = { + mode: result.mode, + startedAt: result.startedAt, + stoppedAt: result.stoppedAt, + finished: result.finished, + }; + + return returnData; + })); + + + // Removes a test webhook + this.app.delete('/rest/test-webhook/:id', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + const workflowId = req.params.id; + return this.testWebhooks.cancelTestWebhook(workflowId); + })); + + + + // ---------------------------------------- + // Options + // ---------------------------------------- + + // Returns all the available timezones + this.app.get('/rest/options/timezones', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + return timezones; + })); + + + + + // ---------------------------------------- + // Settings + // ---------------------------------------- + + + // Returns the settings which are needed in the UI + this.app.get('/rest/settings', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + return { + endpointWebhook: this.endpointWebhook, + endpointWebhookTest: this.endpointWebhookTest, + saveManualRuns: this.saveManualRuns, + timezone: this.timezone, + urlBaseWebhook: WebhookHelpers.getWebhookBaseUrl(), + }; + })); + + + + // ---------------------------------------- + // Webhooks + // ---------------------------------------- + + + // GET webhook requests + this.app.get(`/${this.endpointWebhook}/*`, async (req: express.Request, res: express.Response) => { + console.log('\n*** WEBHOOK CALLED (GET) ***'); + + // Cut away the "/webhook/" to get the registred part of the url + const requestUrl = (req as ICustomRequest).parsedUrl!.pathname!.slice(this.endpointWebhook.length + 2); + + let response; + try { + response = await this.activeWorkflowRunner.executeWebhook('GET', requestUrl, req, res); + } catch (error) { + ResponseHelper.sendErrorResponse(res, error); + return ; + } + + if (response.noWebhookResponse === true) { + // Nothing else to do as the response got already sent + return; + } + + ResponseHelper.sendSuccessResponse(res, response.data, true); + }); + + + // POST webhook requests + this.app.post(`/${this.endpointWebhook}/*`, async (req: express.Request, res: express.Response) => { + console.log('\n*** WEBHOOK CALLED (POST) ***'); + + // Cut away the "/webhook/" to get the registred part of the url + const requestUrl = (req as ICustomRequest).parsedUrl!.pathname!.slice(this.endpointWebhook.length + 2); + + let response; + try { + response = await this.activeWorkflowRunner.executeWebhook('POST', requestUrl, req, res); + } catch (error) { + ResponseHelper.sendErrorResponse(res, error); + return; + } + + if (response.noWebhookResponse === true) { + // Nothing else to do as the response got already sent + return; + } + + ResponseHelper.sendSuccessResponse(res, response.data, true); + }); + + + // GET webhook requests (test for UI) + this.app.get(`/${this.endpointWebhookTest}/*`, async (req: express.Request, res: express.Response) => { + console.log('\n*** WEBHOOK-TEST CALLED (GET) ***'); + + // Cut away the "/webhook-test/" to get the registred part of the url + const requestUrl = (req as ICustomRequest).parsedUrl!.pathname!.slice(this.endpointWebhookTest.length + 2); + + let response; + try { + response = await this.testWebhooks.callTestWebhook('GET', requestUrl, req, res); + } catch (error) { + ResponseHelper.sendErrorResponse(res, error); + return; + } + + if (response.noWebhookResponse === true) { + // Nothing else to do as the response got already sent + return; + } + + ResponseHelper.sendSuccessResponse(res, response.data, true); + }); + + + // POST webhook requests (test for UI) + this.app.post(`/${this.endpointWebhookTest}/*`, async (req: express.Request, res: express.Response) => { + console.log('\n*** WEBHOOK-TEST CALLED (POST) ***'); + + // Cut away the "/webhook-test/" to get the registred part of the url + const requestUrl = (req as ICustomRequest).parsedUrl!.pathname!.slice(this.endpointWebhookTest.length + 2); + + let response; + try { + response = await this.testWebhooks.callTestWebhook('POST', requestUrl, req, res); + } catch (error) { + ResponseHelper.sendErrorResponse(res, error); + return; + } + + if (response.noWebhookResponse === true) { + // Nothing else to do as the response got already sent + return; + } + + ResponseHelper.sendSuccessResponse(res, response.data, true); + }); + + + // Serve the website + this.app.use('/', express.static(__dirname + '/../../node_modules/n8n-editor-ui/dist', { index: 'index.html' })); + } + +} + +export function start() { + const PORT = config.get('urls.port'); + + const app = new App().app; + + app.listen(PORT, () => { + console.log('n8n ready on port ' + PORT); + }); +} diff --git a/packages/cli/src/TestWebhooks.ts b/packages/cli/src/TestWebhooks.ts new file mode 100644 index 0000000000..82a11d70e2 --- /dev/null +++ b/packages/cli/src/TestWebhooks.ts @@ -0,0 +1,208 @@ +import * as express from 'express'; + +import { + IResponseCallbackData, + Push, + ResponseHelper, + WebhookHelpers, + IWorkflowDb, +} from './'; + +import { + ActiveWebhooks, +} from 'n8n-core'; + +import { + IWebhookData, + IWorkflowExecuteAdditionalData, + WebhookHttpMethod, + Workflow, + WorkflowExecuteMode, +} from 'n8n-workflow'; + +const pushInstance = Push.getInstance(); + + + +export class TestWebhooks { + + private testWebhookData: { + [key: string]: { + sessionId?: string; + timeout: NodeJS.Timeout, + workflowData: IWorkflowDb; + }; + } = {}; + private activeWebhooks: ActiveWebhooks | null = null; + + + constructor() { + this.activeWebhooks = new ActiveWebhooks(); + this.activeWebhooks.testWebhooks = true; + } + + + /** + * Executes a test-webhook and returns the data. It also makes sure that the + * data gets additionally send to the UI. After the request got handled it + * automatically remove the test-webhook. + * + * @param {WebhookHttpMethod} httpMethod + * @param {string} path + * @param {express.Request} request + * @param {express.Response} response + * @returns {Promise} + * @memberof TestWebhooks + */ + async callTestWebhook(httpMethod: WebhookHttpMethod, path: string, request: express.Request, response: express.Response): Promise { + const webhookData: IWebhookData | undefined = this.activeWebhooks!.get(httpMethod, path); + + if (webhookData === undefined) { + // The requested webhook is not registred + throw new ResponseHelper.ReponseError('The requested webhook is not registred.', 404, 404); + } + + // Get the node which has the webhook defined to know where to start from and to + // get additional data + const workflowStartNode = webhookData.workflow.getNode(webhookData.node); + if (workflowStartNode === null) { + throw new ResponseHelper.ReponseError('Could not find node to process webhook.', 404, 404); + } + + const webhookKey = this.activeWebhooks!.getWebhookKey(webhookData.httpMethod, webhookData.path); + + return new Promise(async (resolve, reject) => { + try { + const executionMode = 'manual'; + + const executionId = await WebhookHelpers.executeWebhook(webhookData, this.testWebhookData[webhookKey].workflowData, workflowStartNode, executionMode, this.testWebhookData[webhookKey].sessionId, request, response, (error: Error | null, data: IResponseCallbackData) => { + if (error !== null) { + return reject(error); + } + resolve(data); + }); + + if (executionId === undefined) { + // The workflow did not run as the request was probably setup related + // or a ping so do not resolve the promise and wait for the real webhook + // request instead. + return; + } + + // Inform editor-ui that webhook got received + if (this.testWebhookData[webhookKey].sessionId !== undefined) { + pushInstance.send(this.testWebhookData[webhookKey].sessionId!, 'testWebhookReceived', { workflowId: webhookData.workflow.id }); + } + + } catch (error) { + // Delete webhook also if an error is thrown + } + + // Remove the webhook + clearTimeout(this.testWebhookData[webhookKey].timeout); + delete this.testWebhookData[webhookKey]; + this.activeWebhooks!.removeByWorkflowId(webhookData.workflow.id!.toString()); + }); + } + + + /** + * Checks if it has to wait for webhook data to execute the workflow. If yes it waits + * for it and resolves with the result of the workflow if not it simply resolves + * with undefined + * + * @param {IWorkflowDb} workflowData + * @param {Workflow} workflow + * @returns {(Promise)} + * @memberof TestWebhooks + */ + async needsWebhookData(workflowData: IWorkflowDb, workflow: Workflow, additionalData: IWorkflowExecuteAdditionalData, mode: WorkflowExecuteMode, sessionId?: string, destinationNode?: string): Promise { + const webhooks = WebhookHelpers.getWorkflowWebhooks(workflow, additionalData, destinationNode); + + if (webhooks.length === 0) { + // No Webhooks found + return false; + } + + // Remove test-webhooks automatically if they do not get called (after 120 seconds) + const timeout = setTimeout(() => { + this.cancelTestWebhook(workflowData.id.toString()); + }, 120000); + + let key: string; + for (const webhookData of webhooks) { + await this.activeWebhooks!.add(webhookData, mode); + key = this.activeWebhooks!.getWebhookKey(webhookData.httpMethod, webhookData.path); + this.testWebhookData[key] = { + sessionId, + timeout, + workflowData, + }; + } + + return true; + } + + + /** + * Removes a test webhook of the workflow with the given id + * + * @param {string} workflowId + * @returns {boolean} + * @memberof TestWebhooks + */ + cancelTestWebhook(workflowId: string): boolean { + let foundWebhook = false; + for (const webhookKey of Object.keys(this.testWebhookData)) { + const webhookData = this.testWebhookData[webhookKey]; + + if (webhookData.workflowData.id.toString() !== workflowId) { + continue; + } + + foundWebhook = true; + + clearTimeout(this.testWebhookData[webhookKey].timeout); + + // Inform editor-ui that webhook got received + if (this.testWebhookData[webhookKey].sessionId !== undefined) { + try { + pushInstance.send(this.testWebhookData[webhookKey].sessionId!, 'testWebhookDeleted', { workflowId }); + } catch (error) { + // Could not inform editor, probably is not connected anymore. So sipmly go on. + } + } + + // Remove the webhook + delete this.testWebhookData[webhookKey]; + this.activeWebhooks!.removeByWorkflowId(workflowId); + } + + return foundWebhook; + } + + + /** + * Removes all the currently active test webhooks + */ + async removeAll(): Promise { + if (this.activeWebhooks === null) { + return; + } + + return this.activeWebhooks.removeAll(); + } + +} + + + +let testWebhooksInstance: TestWebhooks | undefined; + +export function getInstance(): TestWebhooks { + if (testWebhooksInstance === undefined) { + testWebhooksInstance = new TestWebhooks(); + } + + return testWebhooksInstance; +} diff --git a/packages/cli/src/WebhookHelpers.ts b/packages/cli/src/WebhookHelpers.ts new file mode 100644 index 0000000000..613ebf8fad --- /dev/null +++ b/packages/cli/src/WebhookHelpers.ts @@ -0,0 +1,334 @@ +import * as express from 'express'; + +import { + GenericHelpers, + IExecutionDb, + IResponseCallbackData, + IWorkflowDb, + ResponseHelper, + WorkflowExecuteAdditionalData, +} from './'; + +import { + BINARY_ENCODING, + ActiveExecutions, + NodeExecuteFunctions, + WorkflowExecute, +} from 'n8n-core'; + +import { + IBinaryKeyData, + IDataObject, + IExecuteData, + INode, + IRun, + IRunExecutionData, + ITaskData, + IWebhookData, + IWorkflowExecuteAdditionalData, + NodeHelpers, + Workflow, + WorkflowExecuteMode, +} from 'n8n-workflow'; + +const activeExecutions = ActiveExecutions.getInstance(); + + +/** + * Returns the data of the last executed node + * + * @export + * @param {IRun} inputData + * @returns {(ITaskData | undefined)} + */ +export function getDataLastExecutedNodeData(inputData: IRun): ITaskData | undefined { + const runData = inputData.data.resultData.runData; + const lastNodeExecuted = inputData.data.resultData.lastNodeExecuted; + + if (lastNodeExecuted === undefined) { + return undefined; + } + + if (runData[lastNodeExecuted] === undefined) { + return undefined; + } + + return runData[lastNodeExecuted][runData[lastNodeExecuted].length - 1]; +} + + +/** + * Returns all the webhooks which should be created for the give workflow + * + * @export + * @param {string} workflowId + * @param {Workflow} workflow + * @returns {IWebhookData[]} + */ +export function getWorkflowWebhooks(workflow: Workflow, additionalData: IWorkflowExecuteAdditionalData, destinationNode?: string): IWebhookData[] { + // Check all the nodes in the workflow if they have webhooks + + const returnData: IWebhookData[] = []; + + let parentNodes: string[] | undefined; + if (destinationNode !== undefined) { + parentNodes = workflow.getParentNodes(destinationNode); + } + + for (const node of Object.values(workflow.nodes)) { + if (parentNodes !== undefined && !parentNodes.includes(node.name)) { + // If parentNodes are given check only them if they have webhooks + // and no other ones + continue; + } + returnData.push.apply(returnData, NodeHelpers.getNodeWebhooks(workflow, node, additionalData)); + } + + return returnData; +} + + + /** + * Executes a webhook + * + * @export + * @param {IWebhookData} webhookData + * @param {IWorkflowDb} workflowData + * @param {INode} workflowStartNode + * @param {WorkflowExecuteMode} executionMode + * @param {(string | undefined)} sessionId + * @param {express.Request} req + * @param {express.Response} res + * @param {((error: Error | null, data: IResponseCallbackData) => void)} responseCallback + * @returns {(Promise)} + */ + export async function executeWebhook(webhookData: IWebhookData, workflowData: IWorkflowDb, workflowStartNode: INode, executionMode: WorkflowExecuteMode, sessionId: string | undefined, req: express.Request, res: express.Response, responseCallback: (error: Error | null, data: IResponseCallbackData) => void): Promise { + // Get the nodeType to know which responseMode is set + const nodeType = webhookData.workflow.nodeTypes.getByName(workflowStartNode.type); + if (nodeType === undefined) { + const errorMessage = `The type of the webhook node "${workflowStartNode.name}" is not known.`; + responseCallback(new Error(errorMessage), {}); + throw new ResponseHelper.ReponseError(errorMessage, 500, 500); + } + + // Get the responseMode + const reponseMode = webhookData.workflow.getWebhookParameterValue(workflowStartNode, webhookData.webhookDescription, 'reponseMode', 'onReceived'); + + if (!['onReceived', 'lastNode'].includes(reponseMode as string)) { + // If the mode is not known we error. Is probably best like that instead of using + // the default that people know as early as possible (probably already testing phase) + // that something does not resolve properly. + const errorMessage = `The response mode ${reponseMode} is not valid!.`; + responseCallback(new Error(errorMessage), {}); + throw new ResponseHelper.ReponseError(errorMessage, 500, 500); + } + + // Prepare everything that is needed to run the workflow + const additionalData = await WorkflowExecuteAdditionalData.get(executionMode, workflowData, webhookData.workflow, sessionId); + const workflowExecute = new WorkflowExecute(additionalData, executionMode); + + // Add the Response and Request so that this data can be accessed in the node + additionalData.httpRequest = req; + additionalData.httpResponse = res; + + let didSendResponse = false; + try { + // Run the webhook function to see what should be returned and if + // the workflow should be executed or not + const webhookResultData = await webhookData.workflow.runWebhook(workflowStartNode, additionalData, NodeExecuteFunctions, executionMode); + + if (webhookResultData.noWebhookResponse === true) { + // The response got already send + responseCallback(null, { + noWebhookResponse: true, + }); + didSendResponse = true; + } + + if (webhookResultData.workflowData === undefined) { + // Workflow should not run + if (webhookResultData.webhookResponse !== undefined) { + // Data to respond with is given + responseCallback(null, { + data: webhookResultData.webhookResponse + }); + } else { + // Send default response + responseCallback(null, { + data: { + message: 'Webhook call got received.', + }, + }); + } + return; + } + + // Now that we know that the workflow should run we can return the default respons + // directly if responseMode it set to "onReceived" and a respone should be sent + if (reponseMode === 'onReceived' && didSendResponse === false) { + // Return response directly and do not wait for the workflow to finish + if (webhookResultData.webhookResponse !== undefined) { + // Data to respond with is given + responseCallback(null, { + data: webhookResultData.webhookResponse, + }); + } else { + responseCallback(null, { + data: { + message: 'Workflow got started.', + } + }); + } + + didSendResponse = true; + } + + // Initialize the data of the webhook node + const nodeExecutionStack: IExecuteData[] = []; + nodeExecutionStack.push( + { + node: workflowStartNode, + data: { + main: webhookResultData.workflowData, + }, + }, + ); + + const runExecutionData: IRunExecutionData = { + startData: { + }, + resultData: { + runData: {}, + }, + executionData: { + contextData: {}, + nodeExecutionStack, + waitingExecution: {}, + }, + }; + + // Start now to run the workflow + const executionId = await workflowExecute.runExecutionData(webhookData.workflow, runExecutionData); + + // Get a promise which resolves when the workflow did execute and send then response + const executePromise = activeExecutions.getPostExecutePromise(executionId) as Promise; + executePromise.then((data) => { + if (data === undefined) { + if (didSendResponse === false) { + responseCallback(null, { + data: { + message: 'Workflow did execute sucessfully but no data got returned.', + } + }); + didSendResponse = true; + } + return undefined; + } + + const returnData = getDataLastExecutedNodeData(data); + if (returnData === undefined) { + if (didSendResponse === false) { + responseCallback(null, { + data: { + message: 'Workflow did execute sucessfully but the last node did not return any data.', + } + }); + } + didSendResponse = true; + return data; + } else if (returnData.error !== undefined) { + if (didSendResponse === false) { + responseCallback(null, { + data: { + message: 'Workflow did error.', + } + }); + } + didSendResponse = true; + return data; + } + + const reponseData = webhookData.workflow.getWebhookParameterValue(workflowStartNode, webhookData.webhookDescription, 'reponseData', 'firstEntryJson'); + + if (didSendResponse === false) { + let data: IDataObject | IDataObject[]; + + if (reponseData === 'firstEntryJson') { + // Return the JSON data of the first entry + data = returnData.data!.main[0]![0].json; + } else if (reponseData === 'firstEntryBinary') { + // Return the binary data of the first entry + data = returnData.data!.main[0]![0]; + if (data.binary === undefined) { + responseCallback(new Error('No binary data to return got found.'), {}); + } + + const responseBinaryPropertyName = webhookData.workflow.getWebhookParameterValue(workflowStartNode, webhookData.webhookDescription, 'responseBinaryPropertyName', 'data'); + + if (responseBinaryPropertyName === undefined) { + responseCallback(new Error('No "responseBinaryPropertyName" is set.'), {}); + } + + const binaryData = (data.binary as IBinaryKeyData)[responseBinaryPropertyName as string]; + if (binaryData === undefined) { + responseCallback(new Error(`The binary property "${responseBinaryPropertyName}" which should be returned does not exist.`), {}); + } + + // Send the webhook response manually + res.setHeader('Content-Type', binaryData.mimeType); + res.end(Buffer.from(binaryData.data, BINARY_ENCODING)); + + responseCallback(null, { + noWebhookResponse: true, + }); + } else { + // Return the JSON data of all the entries + data = []; + for (const entry of returnData.data!.main[0]!) { + data.push(entry.json); + } + } + + responseCallback(null, { + data, + }); + } + didSendResponse = true; + + return data; + }) + .catch((e) => { + if (didSendResponse === false) { + responseCallback(new Error('There was a problem executing the workflow.'), {}); + } + + throw new ResponseHelper.ReponseError(e.message, 500, 500); + }); + + return executionId; + + } catch (e) { + if (didSendResponse === false) { + responseCallback(new Error('There was a problem executing the workflow.'), {}); + } + + throw new ResponseHelper.ReponseError(e.message, 500, 500); + } +} + + +/** + * Returns the base URL of the webhooks + * + * @export + * @returns + */ +export function getWebhookBaseUrl() { + let urlBaseWebhook = GenericHelpers.getBaseUrl(); + + if (process.env.WEBHOOK_TUNNEL_URL !== undefined) { + urlBaseWebhook = process.env.WEBHOOK_TUNNEL_URL; + } + + return urlBaseWebhook; +} diff --git a/packages/cli/src/WorkflowCredentials.ts b/packages/cli/src/WorkflowCredentials.ts new file mode 100644 index 0000000000..f46e7661bd --- /dev/null +++ b/packages/cli/src/WorkflowCredentials.ts @@ -0,0 +1,38 @@ +import { + Db, +} from './'; +import { + INode, + IWorkflowCredentials +} from 'n8n-workflow'; + + +export async function WorkflowCredentials(nodes: INode[]): Promise { + // Go through all nodes to find which credentials are needed to execute the workflow + const returnCredentials: IWorkflowCredentials = {}; + + let node, type, name, foundCredentials; + for (node of nodes) { + if (!node.credentials) { + continue; + } + + for (type of Object.keys(node.credentials)) { + if (!returnCredentials.hasOwnProperty(type)) { + returnCredentials[type] = {}; + } + name = node.credentials[type]; + + if (!returnCredentials[type].hasOwnProperty(name)) { + foundCredentials = await Db.collections.Credentials!.find({ name, type }); + if (!foundCredentials.length) { + throw new Error(`Could not find credentials for type "${type}" with name "${name}".`); + } + returnCredentials[type][name] = foundCredentials[0]; + } + } + + } + + return returnCredentials; +} diff --git a/packages/cli/src/WorkflowExecuteAdditionalData.ts b/packages/cli/src/WorkflowExecuteAdditionalData.ts new file mode 100644 index 0000000000..dae0880df6 --- /dev/null +++ b/packages/cli/src/WorkflowExecuteAdditionalData.ts @@ -0,0 +1,210 @@ +import { + Db, + IExecutionDb, + IExecutionFlattedDb, + IPushDataExecutionFinished, + IPushDataNodeExecuteAfter, + IPushDataNodeExecuteBefore, + IWorkflowBase, + Push, + ResponseHelper, + WebhookHelpers, + WorkflowCredentials, + WorkflowHelpers, +} from './'; + +import { + UserSettings, +} from "n8n-core"; + +import { + IRun, + ITaskData, + IWorkflowExecuteAdditionalData, + WorkflowExecuteMode, + Workflow, +} from 'n8n-workflow'; + +import * as config from 'config'; + +const pushInstance = Push.getInstance(); + + +/** + * Checks if there was an error and if errorWorkflow is defined. If so it collects + * all the data and executes it + * + * @param {IWorkflowBase} workflowData The workflow which got executed + * @param {IRun} fullRunData The run which produced the error + * @param {WorkflowExecuteMode} mode The mode in which the workflow which did error got started in + * @param {string} [executionId] The id the execution got saved as + */ +function executeErrorWorkflow(workflowData: IWorkflowBase, fullRunData: IRun, mode: WorkflowExecuteMode, executionId?: string): void { + // Check if there was an error and if so if an errorWorkflow is set + if (fullRunData.data.resultData.error !== undefined && workflowData.settings !== undefined && workflowData.settings.errorWorkflow) { + const workflowErrorData = { + execution: { + id: executionId, + error: fullRunData.data.resultData.error, + lastNodeExecuted: fullRunData.data.resultData.lastNodeExecuted!, + mode, + }, + workflow: { + id: workflowData.id !== undefined ? workflowData.id.toString() as string : undefined, + name: workflowData.name, + } + }; + // Run the error workflow + WorkflowHelpers.executeErrorWorkflow(workflowData.settings.errorWorkflow as string, workflowErrorData); + } +} + + +const hooks = (mode: WorkflowExecuteMode, workflowData: IWorkflowBase, workflowInstance: Workflow, sessionId?: string, retryOf?: string) => { + return { + nodeExecuteBefore: [ + async (executionId: string, nodeName: string): Promise => { + if (sessionId === undefined) { + return; + } + + const sendData: IPushDataNodeExecuteBefore = { + executionId, + nodeName, + }; + + pushInstance.send(sessionId, 'nodeExecuteBefore', sendData); + }, + ], + nodeExecuteAfter: [ + async (executionId: string, nodeName: string, data: ITaskData): Promise => { + if (sessionId === undefined) { + return; + } + + const sendData: IPushDataNodeExecuteAfter = { + executionId, + nodeName, + data, + }; + + pushInstance.send(sessionId, 'nodeExecuteAfter', sendData); + }, + ], + workflowExecuteAfter: [ + async (fullRunData: IRun, executionId: string): Promise => { + try { + if (sessionId !== undefined) { + // Clone the object except the runData. That one is not supposed + // to be send. Because that data got send piece by piece after + // each node which finished executing + const pushRunData = { + ...fullRunData, + data: { + ...fullRunData.data, + resultData: { + ...fullRunData.data.resultData, + runData: {}, + }, + }, + }; + + // Push data to editor-ui once workflow finished + const sendData: IPushDataExecutionFinished = { + executionId, + data: pushRunData, + }; + + pushInstance.send(sessionId, 'executionFinished', sendData); + } + + const workflowSavePromise = WorkflowHelpers.saveStaticData(workflowInstance); + + let saveManualRuns = config.get('executions.saveManualRuns') as boolean; + if (workflowInstance.settings !== undefined && workflowInstance.settings.saveManualRuns !== undefined) { + // Apply to workflow override + saveManualRuns = workflowInstance.settings.saveManualRuns as boolean; + } + + if (mode === 'manual' && saveManualRuns === false) { + if (workflowSavePromise !== undefined) { + // If workflow had to be saved wait till it is done + await workflowSavePromise; + } + + // For now do not save manual executions + // TODO: Later that should be configurable. Think about what to do + // with the workflow.id when not saved yet or currently differes from saved version (save diff?!?!) + + executeErrorWorkflow(workflowData, fullRunData, mode); + return; + } + + // TODO: Should maybe have different log-modes like + // to save all data, only first input, only last node output, .... + // or depending on success to only save all on error to be + // able to start it again where it ended (but would then also have to save active data) + const fullExecutionData: IExecutionDb = { + data: fullRunData.data, + mode: fullRunData.mode, + finished: fullRunData.finished ? fullRunData.finished : false, + startedAt: fullRunData.startedAt, + stoppedAt: fullRunData.stoppedAt, + workflowData, + }; + + if (retryOf !== undefined) { + fullExecutionData.retryOf = retryOf.toString(); + } + + if (workflowData.id !== undefined && WorkflowHelpers.isWorkflowIdValid(workflowData.id.toString()) === true) { + fullExecutionData.workflowId = workflowData.id.toString(); + } + + const executionData = ResponseHelper.flattenExecutionData(fullExecutionData); + + // Save the Execution in DB + const executionResult = await Db.collections.Execution!.save(executionData as IExecutionFlattedDb); + + if (fullRunData.finished === true && retryOf !== undefined) { + // If the retry was successful save the reference it on the original execution + // await Db.collections.Execution!.save(executionData as IExecutionFlattedDb); + await Db.collections.Execution!.update(retryOf, { retrySuccessId: executionResult.id }); + } + + if (workflowSavePromise !== undefined) { + // If workflow had to be saved wait till it is done + await workflowSavePromise; + } + + executeErrorWorkflow(workflowData, fullRunData, mode, executionResult ? executionResult.id as string : undefined); + } catch (error) { + executeErrorWorkflow(workflowData, fullRunData, mode); + } + }, + ] + }; +}; + + +export async function get(mode: WorkflowExecuteMode, workflowData: IWorkflowBase, workflowInstance: Workflow, sessionId?: string, retryOf?: string): Promise { + const urlBaseWebhook = WebhookHelpers.getWebhookBaseUrl(); + + const timezone = config.get('timezone') as string; + const webhookBaseUrl = urlBaseWebhook + config.get('urls.endpointWebhook') as string; + const webhookTestBaseUrl = urlBaseWebhook + config.get('urls.endpointWebhookTest') as string; + + const encryptionKey = await UserSettings.getEncryptionKey(); + if (encryptionKey === undefined) { + throw new Error('No encryption key got found to decrypt the credentials!'); + } + + return { + credentials: await WorkflowCredentials(workflowData.nodes), + hooks: hooks(mode, workflowData, workflowInstance, sessionId, retryOf), + encryptionKey, + timezone, + webhookBaseUrl, + webhookTestBaseUrl, + }; +} diff --git a/packages/cli/src/WorkflowHelpers.ts b/packages/cli/src/WorkflowHelpers.ts new file mode 100644 index 0000000000..fdf7c7f79e --- /dev/null +++ b/packages/cli/src/WorkflowHelpers.ts @@ -0,0 +1,152 @@ +import { + Db, + IWorkflowErrorData, + NodeTypes, + WorkflowExecuteAdditionalData, +} from './'; + +import { + WorkflowExecute, +} from 'n8n-core'; + +import { + IExecuteData, + INode, + IRunExecutionData, + Workflow, +} from 'n8n-workflow'; + +import * as config from 'config'; + +const ERROR_TRIGGER_TYPE = config.get('nodes.errorTriggerType') as string; + +/** + * Returns if the given id is a valid workflow id + * + * @param {(string | null | undefined)} id The id to check + * @returns {boolean} + * @memberof App + */ +export function isWorkflowIdValid (id: string | null | undefined | number): boolean { + if (typeof id === 'string') { + id = parseInt(id, 10); + } + + if (isNaN(id as number)) { + return false; + + } + return true; +} + + + +/** + * Executes the error workflow + * + * @export + * @param {string} workflowId The id of the error workflow + * @param {IWorkflowErrorData} workflowErrorData The error data + * @returns {Promise} + */ +export async function executeErrorWorkflow(workflowId: string, workflowErrorData: IWorkflowErrorData): Promise { + // Wrap everything in try/catch to make sure that no errors bubble up and all get caught here + try { + const workflowData = await Db.collections.Workflow!.findOne({ id: workflowId }); + + if (workflowData === undefined) { + // The error workflow could not be found + console.error(`ERROR: Calling Error Workflow for "${workflowErrorData.workflow.id}". Could not find error workflow "${workflowId}"`); + return; + } + + const executionMode = 'error'; + const nodeTypes = NodeTypes(); + + const workflowInstance = new Workflow(workflowId, workflowData.nodes, workflowData.connections, workflowData.active, nodeTypes, undefined, workflowData.settings); + + + let node: INode; + let workflowStartNode: INode | undefined; + for (const nodeName of Object.keys(workflowInstance.nodes)) { + node = workflowInstance.nodes[nodeName]; + if (node.type === ERROR_TRIGGER_TYPE) { + workflowStartNode = node; + } + } + + if (workflowStartNode === undefined) { + console.error(`ERROR: Calling Error Workflow for "${workflowErrorData.workflow.id}". Could not find "${ERROR_TRIGGER_TYPE}" in workflow "${workflowId}"`); + return; + } + + const additionalData = await WorkflowExecuteAdditionalData.get(executionMode, workflowData, workflowInstance); + + // Can execute without webhook so go on + const workflowExecute = new WorkflowExecute(additionalData, executionMode); + + // Initialize the data of the webhook node + const nodeExecutionStack: IExecuteData[] = []; + nodeExecutionStack.push( + { + node: workflowStartNode, + data: { + main: [ + [ + { + json: workflowErrorData + } + ] + ], + }, + }, + ); + + const runExecutionData: IRunExecutionData = { + startData: { + }, + resultData: { + runData: {}, + }, + executionData: { + contextData: {}, + nodeExecutionStack, + waitingExecution: {}, + }, + }; + + // Start now to run the workflow + await workflowExecute.runExecutionData(workflowInstance, runExecutionData); + + } catch (error) { + console.error(`ERROR: Calling Error Workflow for "${workflowErrorData.workflow.id}": ${error.message}`); + } +} + + + +/** + * Saves the static data if it changed + * + * @export + * @param {Workflow} workflow + * @returns {Promise } + */ +export async function saveStaticData(workflow: Workflow): Promise { + if (workflow.staticData.__dataChanged === true) { + // Static data of workflow changed and so has to be saved + if (isWorkflowIdValid(workflow.id) === true) { + // Workflow is saved so update in database + try { + await Db.collections.Workflow! + .update(workflow.id!, { + staticData: workflow.staticData, + }); + workflow.staticData.__dataChanged = false; + } catch (e) { + // TODO: Add proper logging! + console.error(`There was a problem saving the workflow with id "${workflow.id}" to save changed staticData: ${e.message}`); + } + } + } +} diff --git a/packages/cli/src/db/index.ts b/packages/cli/src/db/index.ts new file mode 100644 index 0000000000..f38130f797 --- /dev/null +++ b/packages/cli/src/db/index.ts @@ -0,0 +1,7 @@ +import * as MongoDb from './mongodb'; +import * as SQLite from './sqlite'; + +export { + MongoDb, + SQLite, +}; diff --git a/packages/cli/src/db/mongodb/CredentialsEntity.ts b/packages/cli/src/db/mongodb/CredentialsEntity.ts new file mode 100644 index 0000000000..f02e778792 --- /dev/null +++ b/packages/cli/src/db/mongodb/CredentialsEntity.ts @@ -0,0 +1,41 @@ +import { + ICredentialNodeAccess, +} from 'n8n-workflow'; + +import { + ICredentialsDb, +} from '../../'; + +import { + Column, + Entity, + Index, + ObjectID, + ObjectIdColumn, +} from "typeorm"; + +@Entity() +export class CredentialsEntity implements ICredentialsDb { + + @ObjectIdColumn() + id: ObjectID; + + @Column() + name: string; + + @Column() + data: string; + + @Index() + @Column() + type: string; + + @Column('json') + nodesAccess: ICredentialNodeAccess[]; + + @Column() + createdAt: number; + + @Column() + updatedAt: number; +} diff --git a/packages/cli/src/db/mongodb/ExecutionEntity.ts b/packages/cli/src/db/mongodb/ExecutionEntity.ts new file mode 100644 index 0000000000..7dc89927de --- /dev/null +++ b/packages/cli/src/db/mongodb/ExecutionEntity.ts @@ -0,0 +1,51 @@ +import { + WorkflowExecuteMode, +} from 'n8n-workflow'; + +import { + IExecutionFlattedDb, + IWorkflowDb, +} from '../../'; + +import { + Column, + Entity, + Index, + ObjectID, + ObjectIdColumn, + } from "typeorm"; + +@Entity() +export class ExecutionEntity implements IExecutionFlattedDb { + + @ObjectIdColumn() + id: ObjectID; + + @Column() + data: string; + + @Column() + finished: boolean; + + @Column() + mode: WorkflowExecuteMode; + + @Column() + retryOf: string; + + @Column() + retrySuccessId: string; + + @Column() + startedAt: number; + + @Column() + stoppedAt: number; + + @Column('json') + workflowData: IWorkflowDb; + + @Index() + @Column() + workflowId: string; +} diff --git a/packages/cli/src/db/mongodb/WorkflowEntity.ts b/packages/cli/src/db/mongodb/WorkflowEntity.ts new file mode 100644 index 0000000000..dce1c4cbe4 --- /dev/null +++ b/packages/cli/src/db/mongodb/WorkflowEntity.ts @@ -0,0 +1,48 @@ +import { + IConnections, + IDataObject, + INode, + IWorkflowSettings, +} from 'n8n-workflow'; + +import { + IWorkflowDb, +} from '../../'; + +import { + Column, + Entity, + ObjectID, + ObjectIdColumn, +} from "typeorm"; + +@Entity() +export class WorkflowEntity implements IWorkflowDb { + + @ObjectIdColumn() + id: ObjectID; + + @Column() + name: string; + + @Column() + active: boolean; + + @Column('json') + nodes: INode[]; + + @Column('json') + connections: IConnections; + + @Column() + createdAt: number; + + @Column() + updatedAt: number; + + @Column('json') + settings?: IWorkflowSettings; + + @Column('json') + staticData?: IDataObject; +} diff --git a/packages/cli/src/db/mongodb/index.ts b/packages/cli/src/db/mongodb/index.ts new file mode 100644 index 0000000000..164d67fd0c --- /dev/null +++ b/packages/cli/src/db/mongodb/index.ts @@ -0,0 +1,3 @@ +export * from './CredentialsEntity'; +export * from './ExecutionEntity'; +export * from './WorkflowEntity'; diff --git a/packages/cli/src/db/sqlite/CredentialsEntity.ts b/packages/cli/src/db/sqlite/CredentialsEntity.ts new file mode 100644 index 0000000000..557562bf30 --- /dev/null +++ b/packages/cli/src/db/sqlite/CredentialsEntity.ts @@ -0,0 +1,44 @@ +import { + ICredentialNodeAccess, +} from 'n8n-workflow'; + +import { + ICredentialsDb, +} from '../../'; + +import { + Column, + Entity, + Index, + PrimaryGeneratedColumn, +} from "typeorm"; + +@Entity() +export class CredentialsEntity implements ICredentialsDb { + + @PrimaryGeneratedColumn() + id: number; + + @Column({ + length: 128 + }) + name: string; + + @Column('text') + data: string; + + @Index() + @Column({ + length: 32 + }) + type: string; + + @Column('simple-json') + nodesAccess: ICredentialNodeAccess[]; + + @Column() + createdAt: number; + + @Column() + updatedAt: number; +} diff --git a/packages/cli/src/db/sqlite/ExecutionEntity.ts b/packages/cli/src/db/sqlite/ExecutionEntity.ts new file mode 100644 index 0000000000..c4e755db21 --- /dev/null +++ b/packages/cli/src/db/sqlite/ExecutionEntity.ts @@ -0,0 +1,53 @@ +import { + WorkflowExecuteMode, +} from 'n8n-workflow'; + +import { + IExecutionFlattedDb, + IWorkflowDb, +} from '../../'; + +import { + Column, + Entity, + Index, + ManyToOne, + PrimaryGeneratedColumn, + } from "typeorm"; + +import { WorkflowEntity } from './WorkflowEntity'; + +@Entity() +export class ExecutionEntity implements IExecutionFlattedDb { + + @PrimaryGeneratedColumn() + id: number; + + @Column('text') + data: string; + + @Column() + finished: boolean; + + @Column() + mode: WorkflowExecuteMode; + + @Column({ nullable: true }) + retryOf: string; + + @Column({ nullable: true }) + retrySuccessId: string; + + @Column() + startedAt: number; + + @Column() + stoppedAt: number; + + @Column('simple-json') + workflowData: IWorkflowDb; + + @Index() + @Column({ nullable: true }) + workflowId: string; +} diff --git a/packages/cli/src/db/sqlite/WorkflowEntity.ts b/packages/cli/src/db/sqlite/WorkflowEntity.ts new file mode 100644 index 0000000000..4e8b330af0 --- /dev/null +++ b/packages/cli/src/db/sqlite/WorkflowEntity.ts @@ -0,0 +1,55 @@ +import { + IConnections, + IDataObject, + INode, + IWorkflowSettings, +} from 'n8n-workflow'; + +import { + IWorkflowDb, +} from '../../'; + +import { + Column, + Entity, + PrimaryGeneratedColumn, +} from "typeorm"; + +@Entity() +export class WorkflowEntity implements IWorkflowDb { + + @PrimaryGeneratedColumn() + id: number; + + @Column({ + length: 128 + }) + name: string; + + @Column() + active: boolean; + + @Column('simple-json') + nodes: INode[]; + + @Column('simple-json') + connections: IConnections; + + @Column() + createdAt: number; + + @Column() + updatedAt: number; + + @Column({ + type: 'simple-json', + nullable: true, + }) + settings?: IWorkflowSettings; + + @Column({ + type: 'simple-json', + nullable: true, + }) + staticData?: IDataObject; +} diff --git a/packages/cli/src/db/sqlite/index.ts b/packages/cli/src/db/sqlite/index.ts new file mode 100644 index 0000000000..164d67fd0c --- /dev/null +++ b/packages/cli/src/db/sqlite/index.ts @@ -0,0 +1,3 @@ +export * from './CredentialsEntity'; +export * from './ExecutionEntity'; +export * from './WorkflowEntity'; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts new file mode 100644 index 0000000000..26acf451a2 --- /dev/null +++ b/packages/cli/src/index.ts @@ -0,0 +1,29 @@ +export * from './CredentialTypes'; +export * from './Interfaces'; +export * from './LoadNodesAndCredentials'; +export * from './NodeTypes'; +export * from './WorkflowCredentials'; + + +import * as ActiveWorkflowRunner from './ActiveWorkflowRunner'; +import * as Db from './Db'; +import * as GenericHelpers from './GenericHelpers'; +import * as Push from './Push'; +import * as ResponseHelper from './ResponseHelper'; +import * as Server from './Server'; +import * as TestWebhooks from './TestWebhooks'; +import * as WebhookHelpers from './WebhookHelpers'; +import * as WorkflowExecuteAdditionalData from './WorkflowExecuteAdditionalData'; +import * as WorkflowHelpers from './WorkflowHelpers'; +export { + ActiveWorkflowRunner, + Db, + GenericHelpers, + Push, + ResponseHelper, + Server, + TestWebhooks, + WebhookHelpers, + WorkflowExecuteAdditionalData, + WorkflowHelpers, +}; diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 0000000000..07a1bac94d --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,39 @@ +{ + "compilerOptions": { + "lib": [ + "es2017" + ], + "types": [ + "node", + "jest" + ], + "module": "commonjs", + "noImplicitAny": true, + "removeComments": true, + "strictNullChecks": true, + "forceConsistentCasingInFileNames": true, + "noImplicitReturns": true, + // Have to deactivate for TypeORM + // "strict": true, + "preserveConstEnums": true, + "declaration": true, + "outDir": "./dist/", + "target": "es2017", + "sourceMap": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true + }, + "include": [ + "**/*.d.ts", + "commands/**/*", + "index.ts", + "config/**/*", + "src/**/*", + "test/**/*", + ], + "exclude": [ + "dist/**/*", + "node_modules/**/*", + "**/*.spec.ts" + ] +} diff --git a/packages/cli/tslint.json b/packages/cli/tslint.json new file mode 100644 index 0000000000..7eb9d0110e --- /dev/null +++ b/packages/cli/tslint.json @@ -0,0 +1,103 @@ +{ + "linterOptions": { + "exclude": [ + "node_modules/**/*" + ] + }, + "defaultSeverity": "error", + "jsRules": {}, + "rules": { + "array-type": [ + true, + "array-simple" + ], + "arrow-return-shorthand": true, + "ban": [ + true, + { + "name": "Array", + "message": "tsstyle#array-constructor" + } + ], + "ban-types": [ + true, + [ + "Object", + "Use {} instead." + ], + [ + "String", + "Use 'string' instead." + ], + [ + "Number", + "Use 'number' instead." + ], + [ + "Boolean", + "Use 'boolean' instead." + ] + ], + "class-name": true, + "curly": [ + true, + "ignore-same-line" + ], + "forin": true, + "jsdoc-format": true, + "label-position": true, + "member-access": [ + true, + "no-public" + ], + "new-parens": true, + "no-angle-bracket-type-assertion": true, + "no-any": true, + "no-arg": true, + "no-conditional-assignment": true, + "no-construct": true, + "no-debugger": true, + "no-default-export": true, + "no-duplicate-variable": true, + "no-inferrable-types": true, + "no-namespace": [ + true, + "allow-declarations" + ], + "no-reference": true, + "no-string-throw": true, + "no-unused-expression": true, + "no-var-keyword": true, + "object-literal-shorthand": true, + "only-arrow-functions": [ + true, + "allow-declarations", + "allow-named-functions" + ], + "prefer-const": true, + "radix": true, + "semicolon": [ + true, + "always", + "ignore-bound-class-methods" + ], + "switch-default": true, + "triple-equals": [ + true, + "allow-null-check" + ], + "use-isnan": true, + "quotes": [ + "error", + "single" + ], + "variable-name": [ + true, + "check-format", + "ban-keywords", + "allow-leading-underscore", + "allow-trailing-underscore" + ] + }, + "rulesDirectory": [] +} diff --git a/packages/core/LICENSE b/packages/core/LICENSE new file mode 100644 index 0000000000..b3aadc2a0f --- /dev/null +++ b/packages/core/LICENSE @@ -0,0 +1,230 @@ +“Commons Clause” License Condition v1.0 + +The Software is provided to you by the Licensor under the +License, as defined below, subject to the following condition. + +Without limiting other conditions in the License, the grant +of rights under the License will not include, and the License +does not grant to you, the right to Sell the Software. + +For purposes of the foregoing, “Sell” means practicing any or +all of the rights granted to you under the License to provide +to third parties, for a fee or other consideration (including +without limitation fees for hosting or consulting/ support +services related to the Software), a product or service whose +value derives, entirely or substantially, from the functionality +of the Software. Any license notice or attribution required by +the License must also include this Commons Clause License +Condition notice. + +Software: n8n + +License: Apache 2.0 + +Licensor: Jan Oberhauser + + +--------------------------------------------------------------------- + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/core/README.md b/packages/core/README.md new file mode 100644 index 0000000000..226ed6ffa6 --- /dev/null +++ b/packages/core/README.md @@ -0,0 +1,13 @@ +# n8n-core + +![n8n.io - Workflow Automation](https://n8n.io/n8n-logo.png) + +Core components for n8n + +``` +npm install n8n-core +``` + +## License + +[Apache 2.0 with Commons Clause](LICENSE) diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000000..690e9c682e --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,60 @@ +{ + "name": "n8n-core", + "version": "0.1.0", + "description": "Core functionality of n8n", + "license": "SEE LICENSE IN LICENSE", + "author": { + "name": "Jan Oberhauser", + "email": "jan@n8n.io" + }, + "main": "dist/src/index", + "types": "dist/src/index.d.ts", + "scripts": { + "build": "tsc", + "tslint": "tslint -p tsconfig.json -c tslint.json", + "watch": "tsc --watch", + "test": "jest" + }, + "files": [ + "dist" + ], + "devDependencies": { + "@types/crypto-js": "^3.1.43", + "@types/express": "^4.16.1", + "@types/jest": "^23.3.2", + "@types/lodash.get": "^4.4.5", + "@types/mmmagic": "^0.4.29", + "@types/node": "^10.10.1", + "@types/request-promise-native": "^1.0.15", + "jest": "^23.6.0", + "source-map-support": "^0.5.9", + "ts-jest": "^23.10.1", + "tslint": "^5.11.0", + "typescript": "~3.3.0" + }, + "dependencies": { + "crypto-js": "^3.1.9-1", + "lodash.get": "^4.4.2", + "mmmagic": "^0.5.2", + "n8n-workflow": "^0.1.0", + "request-promise-native": "^1.0.7" + }, + "jest": { + "transform": { + "^.+\\.tsx?$": "ts-jest" + }, + "testURL": "http://localhost/", + "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$", + "testPathIgnorePatterns": [ + "/dist/", + "/node_modules/" + ], + "moduleFileExtensions": [ + "ts", + "tsx", + "js", + "json", + "node" + ] + } +} diff --git a/packages/core/src/ActiveExecutions.ts b/packages/core/src/ActiveExecutions.ts new file mode 100644 index 0000000000..1e840d7d72 --- /dev/null +++ b/packages/core/src/ActiveExecutions.ts @@ -0,0 +1,172 @@ +import { + IRun, + IRunExecutionData, + Workflow, + WorkflowExecuteMode, +} from 'n8n-workflow'; + +import { + createDeferredPromise, + IExecutingWorkflowData, + IExecutionsCurrentSummary, +} from '.'; + + +export class ActiveExecutions { + private nextId = 1; + private activeExecutions: { + [index: string]: IExecutingWorkflowData; + } = {}; + private stopExecutions: string[] = []; + + + + /** + * Add a new active execution + * + * @param {Workflow} workflow + * @param {IRunExecutionData} runExecutionData + * @param {WorkflowExecuteMode} mode + * @returns {string} + * @memberof ActiveExecutions + */ + add(workflow: Workflow, runExecutionData: IRunExecutionData, mode: WorkflowExecuteMode): string { + const executionId = this.nextId++; + + this.activeExecutions[executionId] = { + runExecutionData, + startedAt: new Date().getTime(), + mode, + workflow, + postExecutePromises: [], + }; + + return executionId.toString(); + } + + + /** + * Remove an active execution + * + * @param {string} executionId + * @param {IRun} fullRunData + * @returns {void} + * @memberof ActiveExecutions + */ + remove(executionId: string, fullRunData: IRun): void { + if (this.activeExecutions[executionId] === undefined) { + return; + } + + // Resolve all the waiting promises + for (const promise of this.activeExecutions[executionId].postExecutePromises) { + promise.resolve(fullRunData); + } + + // Remove from the list of active executions + delete this.activeExecutions[executionId]; + + const stopExecutionIndex = this.stopExecutions.indexOf(executionId); + if (stopExecutionIndex !== -1) { + // If it was on the stop-execution list remove it + this.stopExecutions.splice(stopExecutionIndex, 1); + } + } + + + /** + * Forces an execution to stop + * + * @param {string} executionId The id of the execution to stop + * @returns {(Promise)} + * @memberof ActiveExecutions + */ + async stopExecution(executionId: string): Promise { + if (this.activeExecutions[executionId] === undefined) { + // There is no execution running with that id + return; + } + + if (!this.stopExecutions.includes(executionId)) { + // Add the execution to the stop list if it is not already on it + this.stopExecutions.push(executionId); + } + + return this.getPostExecutePromise(executionId); + } + + + + /** + * Returns a promise which will resolve with the data of the execution + * with the given id + * + * @param {string} executionId The id of the execution to wait for + * @returns {Promise} + * @memberof ActiveExecutions + */ + async getPostExecutePromise(executionId: string): Promise { + // Create the promise which will be resolved when the execution finished + const waitPromise = await createDeferredPromise(); + + if (this.activeExecutions[executionId] === undefined) { + throw new Error(`There is no active execution with id "${executionId}".`); + } + + this.activeExecutions[executionId].postExecutePromises.push(waitPromise); + + return waitPromise.promise(); + } + + + + /** + * Returns if the execution should be stopped + * + * @param {string} executionId The execution id to check + * @returns {boolean} + * @memberof ActiveExecutions + */ + shouldBeStopped(executionId: string): boolean { + return this.stopExecutions.includes(executionId); + } + + + + /** + * Returns all the currently active executions + * + * @returns {IExecutionsCurrentSummary[]} + * @memberof ActiveExecutions + */ + getActiveExecutions(): IExecutionsCurrentSummary[] { + const returnData: IExecutionsCurrentSummary[] = []; + + let executionData; + for (const id of Object.keys(this.activeExecutions)) { + executionData = this.activeExecutions[id]; + returnData.push( + { + id, + startedAt: executionData.startedAt, + mode: executionData.mode, + workflowId: executionData.workflow.id!, + } + ); + } + + return returnData; + } +} + + + +let activeExecutionsInstance: ActiveExecutions | undefined; + +export function getInstance(): ActiveExecutions { + if (activeExecutionsInstance === undefined) { + activeExecutionsInstance = new ActiveExecutions(); + } + + return activeExecutionsInstance; +} diff --git a/packages/core/src/ActiveWebhooks.ts b/packages/core/src/ActiveWebhooks.ts new file mode 100644 index 0000000000..6468044d47 --- /dev/null +++ b/packages/core/src/ActiveWebhooks.ts @@ -0,0 +1,175 @@ +import { + IWebhookData, + WebhookHttpMethod, + WorkflowExecuteMode, +} from 'n8n-workflow'; + +import { + NodeExecuteFunctions, +} from './'; + + +export class ActiveWebhooks { + private workflowWebhooks: { + [key: string]: IWebhookData[]; + } = {}; + + private webhookUrls: { + [key: string]: IWebhookData; + } = {}; + + testWebhooks = false; + + + /** + * Adds a new webhook + * + * @param {IWebhookData} webhookData + * @param {WorkflowExecuteMode} mode + * @returns {Promise} + * @memberof ActiveWebhooks + */ + async add(webhookData: IWebhookData, mode: WorkflowExecuteMode): Promise { + if (webhookData.workflow.id === undefined) { + throw new Error('Webhooks can only be added for saved workflows as an id is needed!'); + } + + if (this.workflowWebhooks[webhookData.workflow.id] === undefined) { + this.workflowWebhooks[webhookData.workflow.id] = []; + } + + // Make the webhook available directly because sometimes to create it successfully + // it gets called + this.webhookUrls[this.getWebhookKey(webhookData.httpMethod, webhookData.path)] = webhookData; + + const webhookExists = await webhookData.workflow.runWebhookMethod('checkExists', webhookData, NodeExecuteFunctions, mode, this.testWebhooks); + if (webhookExists === false) { + // If webhook does not exist yet create it + await webhookData.workflow.runWebhookMethod('create', webhookData, NodeExecuteFunctions, mode, this.testWebhooks); + } + + // Run the "activate" hooks on the nodes + await webhookData.workflow.runNodeHooks('activate', webhookData, NodeExecuteFunctions, mode); + + this.workflowWebhooks[webhookData.workflow.id].push(webhookData); + } + + + /** + * Returns webhookData if a webhook with matches is currently registered + * + * @param {WebhookHttpMethod} httpMethod + * @param {string} path + * @returns {(IWebhookData | undefined)} + * @memberof ActiveWebhooks + */ + get(httpMethod: WebhookHttpMethod, path: string): IWebhookData | undefined { + const webhookKey = this.getWebhookKey(httpMethod, path); + if (this.webhookUrls[webhookKey] === undefined) { + return undefined; + } + + return this.webhookUrls[webhookKey]; + } + + + /** + * Returns key to uniquely identify a webhook + * + * @param {WebhookHttpMethod} httpMethod + * @param {string} path + * @returns {string} + * @memberof ActiveWebhooks + */ + getWebhookKey(httpMethod: WebhookHttpMethod, path: string): string { + return `${httpMethod}|${path}`; + } + + + /** + * Removes all webhooks of a workflow + * + * @param {string} workflowId + * @returns {boolean} + * @memberof ActiveWebhooks + */ + async removeByWorkflowId(workflowId: string): Promise { + if (this.workflowWebhooks[workflowId] === undefined) { + // If it did not exist then there is nothing to remove + return false; + } + + const webhooks = this.workflowWebhooks[workflowId]; + + const mode = 'internal'; + + // Go through all the registered webhooks of the workflow and remove them + for (const webhookData of webhooks) { + await webhookData.workflow.runWebhookMethod('delete', webhookData, NodeExecuteFunctions, mode, this.testWebhooks); + + // Run the "deactivate" hooks on the nodes + await webhookData.workflow.runNodeHooks('deactivate', webhookData, NodeExecuteFunctions, mode); + + delete this.webhookUrls[this.getWebhookKey(webhookData.httpMethod, webhookData.path)]; + } + + // Remove also the workflow-webhook entry + delete this.workflowWebhooks[workflowId]; + + return true; + } + + + /** + * Removes all the currently active webhooks + */ + async removeAll(): Promise { + const workflowIds = Object.keys(this.workflowWebhooks); + + const removePromises = []; + for (const workflowId of workflowIds) { + removePromises.push(this.removeByWorkflowId(workflowId)); + } + + await Promise.all(removePromises); + return; + } + + + // /** + // * Removes a single webhook by its key. + // * Currently not used, runNodeHooks for "deactivate" is missing + // * + // * @param {string} webhookKey + // * @returns {boolean} + // * @memberof ActiveWebhooks + // */ + // removeByWebhookKey(webhookKey: string): boolean { + // if (this.webhookUrls[webhookKey] === undefined) { + // // If it did not exist then there is nothing to remove + // return false; + // } + + // const webhookData = this.webhookUrls[webhookKey]; + + // // Remove from workflow-webhooks + // const workflowWebhooks = this.workflowWebhooks[webhookData.workflowId]; + // for (let index = 0; index < workflowWebhooks.length; index++) { + // if (workflowWebhooks[index].path === webhookData.path) { + // workflowWebhooks.splice(index, 1); + // break; + // } + // } + + // if (workflowWebhooks.length === 0) { + // // When there are no webhooks left for any workflow remove it totally + // delete this.workflowWebhooks[webhookData.workflowId]; + // } + + // // Remove from webhook urls + // delete this.webhookUrls[webhookKey]; + + // return true; + // } + +} diff --git a/packages/core/src/ActiveWorkflows.ts b/packages/core/src/ActiveWorkflows.ts new file mode 100644 index 0000000000..4c654eb489 --- /dev/null +++ b/packages/core/src/ActiveWorkflows.ts @@ -0,0 +1,112 @@ +import { + ITriggerResponse, + IWorkflowExecuteAdditionalData, + Workflow, +} from 'n8n-workflow'; + +import { + NodeExecuteFunctions, +} from './'; + + +export interface WorkflowData { + workflow: Workflow; + triggerResponse?: ITriggerResponse; +} + +export class ActiveWorkflows { + private workflowData: { + [key: string]: WorkflowData; + } = {}; + + + /** + * Returns if the workflow is active + * + * @param {string} id The id of the workflow to check + * @returns {boolean} + * @memberof ActiveWorkflows + */ + isActive(id: string): boolean { + return this.workflowData.hasOwnProperty(id); + } + + + /** + * Returns the ids of the currently active workflows + * + * @returns {string[]} + * @memberof ActiveWorkflows + */ + allActiveWorkflows(): string[] { + return Object.keys(this.workflowData); + } + + + /** + * Returns the Workflow data for the workflow with + * the given id if it is currently active + * + * @param {string} id + * @returns {(WorkflowData | undefined)} + * @memberof ActiveWorkflows + */ + get(id: string): WorkflowData | undefined { + return this.workflowData[id]; + } + + + /** + * Makes a workflow active + * + * @param {string} id The id of the workflow to activate + * @param {Workflow} workflow The workflow to activate + * @param {IWorkflowExecuteAdditionalData} additionalData The additional data which is needed to run workflows + * @returns {Promise} + * @memberof ActiveWorkflows + */ + async add(id: string, workflow: Workflow, additionalData: IWorkflowExecuteAdditionalData): Promise { + console.log('ADD ID (active): ' + id); + + this.workflowData[id] = { + workflow + }; + const triggerNodes = workflow.getTriggerNodes(); + + let triggerResponse: ITriggerResponse | undefined; + for (const triggerNode of triggerNodes) { + triggerResponse = await workflow.runTrigger(triggerNode, NodeExecuteFunctions, additionalData, 'trigger'); + if (triggerResponse !== undefined) { + // If a response was given save it + this.workflowData[id].triggerResponse = triggerResponse; + } + } + } + + + + /** + * Makes a workflow inactive + * + * @param {string} id The id of the workflow to deactivate + * @returns {Promise} + * @memberof ActiveWorkflows + */ + async remove(id: string): Promise { + console.log('REMOVE ID (active): ' + id); + + if (!this.isActive(id)) { + // Workflow is currently not registered + throw new Error(`The workflow with the id "${id}" is currently not active and can so not be removed`); + } + + const workflowData = this.workflowData[id]; + + if (workflowData.triggerResponse && workflowData.triggerResponse.closeFunction) { + await workflowData.triggerResponse.closeFunction(); + } + + delete this.workflowData[id]; + } + +} diff --git a/packages/core/src/Constants.ts b/packages/core/src/Constants.ts new file mode 100644 index 0000000000..1596c0c737 --- /dev/null +++ b/packages/core/src/Constants.ts @@ -0,0 +1,7 @@ +export const BINARY_ENCODING = 'base64'; +export const CUSTOM_EXTENSION_ENV = 'N8N_CUSTOM_EXTENSIONS'; +export const ENCRYPTION_KEY_ENV_OVERWRITE = 'N8N_ENCRYPTION_KEY'; +export const EXTENSIONS_SUBDIRECTORY = 'custom'; +export const USER_FOLDER_ENV_OVERWRITE = 'N8N_USER_FOLDER'; +export const USER_SETTINGS_FILE_NAME = 'config'; +export const USER_SETTINGS_SUBFOLDER = '.n8n'; diff --git a/packages/core/src/Credentials.ts b/packages/core/src/Credentials.ts new file mode 100644 index 0000000000..2559d78dda --- /dev/null +++ b/packages/core/src/Credentials.ts @@ -0,0 +1,120 @@ +import { + ICredentialDataDecryptedObject, + CredentialInformation, + 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; + } + + + /** + * Returns if the given nodeType has access to data + */ + hasNodeAccess(nodeType: string): boolean { + for (const accessData of this.nodesAccess) { + + if (accessData.nodeType === nodeType) { + return true; + } + } + + return false; + } + + + /** + * Sets new credential object + */ + setData(data: ICredentialDataDecryptedObject, encryptionKey: string): void { + this.data = AES.encrypt(JSON.stringify(data), encryptionKey).toString(); + } + + + /** + * Sets new credentials for given key + */ + setDataKey(key: string, data: CredentialInformation, encryptionKey: string): void { + let fullData; + try { + fullData = this.getData(encryptionKey); + } catch (e) { + fullData = {}; + } + + fullData[key] = data; + + return this.setData(fullData, encryptionKey); + } + + + /** + * Returns the decrypted credential object + */ + getData(encryptionKey: string, nodeType?: string): ICredentialDataDecryptedObject { + if (nodeType && !this.hasNodeAccess(nodeType)) { + throw new Error(`The node of type "${nodeType}" does not have access to credentials "${this.name}" of type "${this.type}".`); + } + + if (this.data === undefined) { + throw new Error('No data is set so nothing can be returned.'); + } + + const decryptedData = AES.decrypt(this.data, encryptionKey); + + try { + return JSON.parse(decryptedData.toString(enc.Utf8)); + } catch (e) { + throw new Error('Credentials could not be decrypted. The reason is that probably a different "encryptionKey" got used to encrypt the data than now to decrypt it.'); + } + } + + + /** + * Returns the decrypted credentials for given key + */ + getDataKey(key: string, encryptionKey: string, nodeType?: string): CredentialInformation { + const fullData = this.getData(encryptionKey, nodeType); + + if (fullData === null) { + throw new Error(`No data got set.`); + } + + if (!fullData.hasOwnProperty(key)) { + throw new Error(`No data for key "${key}" exists.`); + } + + return fullData[key]; + } + + + /** + * Returns the encrypted credentials to be saved + */ + getDataToSave(): ICredentialsEncrypted { + if (this.data === undefined) { + throw new Error(`No credentials got set to save.`); + } + + return { + name: this.name, + type: this.type, + data: this.data, + nodesAccess: this.nodesAccess, + }; + } +} diff --git a/packages/core/src/DeferredPromise.ts b/packages/core/src/DeferredPromise.ts new file mode 100644 index 0000000000..56d333ba82 --- /dev/null +++ b/packages/core/src/DeferredPromise.ts @@ -0,0 +1,14 @@ +// From: https://gist.github.com/compulim/8b49b0a744a3eeb2205e2b9506201e50 +export interface IDeferredPromise { + promise: () => Promise; + reject: (error: Error) => void; + resolve: (result: T) => void; +} + +export function createDeferredPromise(): Promise> { + return new Promise>(resolveCreate => { + const promise = new Promise((resolve, reject) => { + resolveCreate({ promise: () => promise, resolve, reject }); + }); + }); +} diff --git a/packages/core/src/Interfaces.ts b/packages/core/src/Interfaces.ts new file mode 100644 index 0000000000..7cced88dfe --- /dev/null +++ b/packages/core/src/Interfaces.ts @@ -0,0 +1,115 @@ +import { + IBinaryData, + ICredentialType, + IDataObject, + IExecuteFunctions as IExecuteFunctionsBase, + IExecuteSingleFunctions as IExecuteSingleFunctionsBase, + IHookFunctions as IHookFunctionsBase, + ILoadOptionsFunctions as ILoadOptionsFunctionsBase, + INodeExecutionData, + INodeType, + IRun, + IRunExecutionData, + ITriggerFunctions as ITriggerFunctionsBase, + IWebhookFunctions as IWebhookFunctionsBase, + IWorkflowSettings as IWorkflowSettingsWorkflow, + Workflow, + WorkflowExecuteMode, + } from 'n8n-workflow'; + +import { + IDeferredPromise +} from '.'; + +import * as request from 'request'; +import * as requestPromise from 'request-promise-native'; + +interface Constructable { + new(): T; +} + + +export interface IExecuteFunctions extends IExecuteFunctionsBase { + helpers: { + prepareBinaryData(binaryData: Buffer, filePath?: string, mimeType?: string): Promise; + request: request.RequestAPI, + returnJsonArray(jsonData: IDataObject | IDataObject[]): INodeExecutionData[]; + }; +} + + +export interface IExecuteSingleFunctions extends IExecuteSingleFunctionsBase { + helpers: { + prepareBinaryData(binaryData: Buffer, filePath?: string, mimeType?: string): Promise; + request: request.RequestAPI < requestPromise.RequestPromise, requestPromise.RequestPromiseOptions, request.RequiredUriUrl >, + }; +} + +export interface IExecutingWorkflowData { + runExecutionData: IRunExecutionData; + startedAt: number; + mode: WorkflowExecuteMode; + workflow: Workflow; + postExecutePromises: Array>; +} + +export interface IExecutionsCurrentSummary { + id: string; + startedAt: number; + mode: WorkflowExecuteMode; + workflowId: string; +} + +export interface ITriggerFunctions extends ITriggerFunctionsBase { + helpers: { + prepareBinaryData(binaryData: Buffer, filePath?: string, mimeType?: string): Promise; + request: request.RequestAPI, + returnJsonArray(jsonData: IDataObject | IDataObject[]): INodeExecutionData[]; + }; +} + + +export interface IUserSettings { + encryptionKey?: string; + tunnelSubdomain?: string; +} + +export interface ILoadOptionsFunctions extends ILoadOptionsFunctionsBase { + helpers: { + request?: request.RequestAPI, + }; +} + + +export interface IHookFunctions extends IHookFunctionsBase { + helpers: { + request: request.RequestAPI, + }; +} + + +export interface IWebhookFunctions extends IWebhookFunctionsBase { + helpers: { + prepareBinaryData(binaryData: Buffer, filePath?: string, mimeType?: string): Promise; + request: request.RequestAPI, + returnJsonArray(jsonData: IDataObject | IDataObject[]): INodeExecutionData[]; + }; +} + +export interface IWorkflowSettings extends IWorkflowSettingsWorkflow { + errorWorkflow?: string; + timezone?: string; + saveManualRuns?: boolean; +} + + +// New node definition in file +export interface INodeDefinitionFile { + [key: string]: Constructable; +} + + +// Is identical to TaskDataConnections but does not allow null value to be used as input for nodes +export interface INodeInputDataConnections { + [key: string]: INodeExecutionData[][]; +} diff --git a/packages/core/src/LoadNodeParameterOptions.ts b/packages/core/src/LoadNodeParameterOptions.ts new file mode 100644 index 0000000000..9bce351444 --- /dev/null +++ b/packages/core/src/LoadNodeParameterOptions.ts @@ -0,0 +1,97 @@ +import { + INode, + INodeCredentials, + INodePropertyOptions, + INodeTypes, + IWorkflowExecuteAdditionalData, + Workflow, +} from 'n8n-workflow'; + +import { + NodeExecuteFunctions, +} from './'; + + +const TEMP_NODE_NAME = 'Temp-Node'; +const TEMP_WORKFLOW_NAME = 'Temp-Workflow'; + + +export class LoadNodeParameterOptions { + workflow: Workflow; + + + constructor(nodeTypeName: string, nodeTypes: INodeTypes, credentials?: INodeCredentials) { + const nodeType = nodeTypes.getByName(nodeTypeName); + + if (nodeType === undefined) { + throw new Error(`The node-type "${nodeTypeName}" is not known!`); + } + + const nodeData: INode = { + parameters: { + }, + name: TEMP_NODE_NAME, + type: nodeTypeName, + typeVersion: 1, + position: [ + 0, + 0, + ] + }; + + if (credentials) { + nodeData.credentials = credentials; + } + + const workflowData = { + nodes: [ + nodeData, + ], + connections: {}, + }; + + this.workflow = new Workflow(undefined, workflowData.nodes, workflowData.connections, false, nodeTypes, undefined); + } + + + /** + * Returns data of a fake workflow + * + * @returns + * @memberof LoadNodeParameterOptions + */ + getWorkflowData() { + return { + name: TEMP_WORKFLOW_NAME, + active: false, + connections: {}, + nodes: Object.values(this.workflow.nodes), + createdAt: 0, + updatedAt: 0, + }; + } + + + /** + * Returns the available options + * + * @param {string} methodName The name of the method of which to get the data from + * @param {IWorkflowExecuteAdditionalData} additionalData + * @returns {Promise} + * @memberof LoadNodeParameterOptions + */ + getOptions(methodName: string, additionalData: IWorkflowExecuteAdditionalData): Promise { + const node = this.workflow.getNode(TEMP_NODE_NAME); + + const nodeType = this.workflow.nodeTypes.getByName(node!.type); + + if (nodeType!.methods === undefined || nodeType!.methods.loadOptions === undefined || nodeType!.methods.loadOptions[methodName] === undefined) { + throw new Error(`The node-type "${node!.type}" does not have the method "${methodName}" defined!`); + } + + const thisArgs = NodeExecuteFunctions.getLoadOptionsFunctions(this.workflow, node!, additionalData); + + return nodeType!.methods.loadOptions[methodName].call(thisArgs); + } + +} diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts new file mode 100644 index 0000000000..76e66bbb59 --- /dev/null +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -0,0 +1,639 @@ +import { + Credentials, + IHookFunctions, + ILoadOptionsFunctions, + IWorkflowSettings, + WorkflowExecute, + BINARY_ENCODING, +} from './'; + +import { + IBinaryData, + IContextObject, + ICredentialDataDecryptedObject, + IDataObject, + IExecuteData, + IExecuteFunctions, + IExecuteSingleFunctions, + INode, + INodeExecutionData, + INodeParameters, + INodeType, + IRunExecutionData, + ITaskDataConnections, + ITriggerFunctions, + IWebhookDescription, + IWebhookFunctions, + IWorkflowExecuteAdditionalData, + NodeHelpers, + NodeParameterValue, + Workflow, + WorkflowExecuteMode, +} from 'n8n-workflow'; + +import { get } from 'lodash'; +import * as express from "express"; +import * as path from 'path'; +import * as requestPromise from 'request-promise-native'; + +import { Magic, MAGIC_MIME_TYPE } from 'mmmagic'; + +const magic = new Magic(MAGIC_MIME_TYPE); + + + +/** + * Takes a buffer and converts it into the format n8n uses. It encodes the binary data as + * base64 and adds metadata. + * + * @export + * @param {Buffer} binaryData + * @param {string} [filePath] + * @param {string} [mimeType] + * @returns {Promise} + */ +export async function prepareBinaryData(binaryData: Buffer, filePath?: string, mimeType?: string): Promise { + if (!mimeType) { + // If not mime type is given figure it out + mimeType = await new Promise( + (resolve, reject) => { + magic.detect(binaryData, (err: Error, mimeType: string) => { + if (err) { + return reject(err); + } + + return resolve(mimeType); + }); + } + ); + } + + const returnData: IBinaryData = { + mimeType, + // TODO: Should program it in a way that it does not have to converted to base64 + // It should only convert to and from base64 when saved in database because + // of for example an error or when there is a wait node. + data: binaryData.toString(BINARY_ENCODING) + }; + + if (filePath) { + if (filePath.includes('?')) { + // Remove maybe present query parameters + filePath = filePath.split('?').shift(); + } + + const filePathParts = path.parse(filePath as string); + + returnData.fileName = filePathParts.base; + + // Remove the dot + const fileExtension = filePathParts.ext.slice(1); + if (fileExtension) { + returnData.fileExtension = fileExtension; + } + } + + return returnData; +} + + + +/** + * Takes generic input data and brings it into the json format n8n uses. + * + * @export + * @param {(IDataObject | IDataObject[])} jsonData + * @returns {INodeExecutionData[]} + */ +export function returnJsonArray(jsonData: IDataObject | IDataObject[]): INodeExecutionData[] { + const returnData: INodeExecutionData[] = []; + + if (!Array.isArray(jsonData)) { + jsonData = [jsonData]; + } + + jsonData.forEach((data) => { + returnData.push({ json: data }); + }); + + return returnData; +} + + + +/** + * Returns the requested decrypted credentials if the node has access to them. + * + * @export + * @param {Workflow} workflow Workflow which requests the data + * @param {INode} node Node which request the data + * @param {string} type The credential type to return + * @param {IWorkflowExecuteAdditionalData} additionalData + * @returns {(ICredentialDataDecryptedObject | undefined)} + */ +export function getCredentials(workflow: Workflow, node: INode, type: string, additionalData: IWorkflowExecuteAdditionalData): ICredentialDataDecryptedObject | undefined { + + // Get the NodeType as it has the information if the credentials are required + const nodeType = workflow.nodeTypes.getByName(node.type); + if (nodeType === undefined) { + throw new Error(`Node type "${node.type}" is not known so can not get credentials!`); + } + + if (nodeType.description.credentials === undefined) { + throw new Error(`Node type "${node.type}" does not have any credentials defined!`); + } + + const nodeCredentialDescription = nodeType.description.credentials.find((credentialTypeDescription) => credentialTypeDescription.name === type); + if (nodeCredentialDescription === undefined) { + throw new Error(`Node type "${node.type}" does not have any credentials of type "${type}" defined!`); + } + + if (NodeHelpers.displayParameter(node.parameters, nodeCredentialDescription, node.parameters) === false) { + // Credentials should not be displayed so return undefined even if they would be defined + return undefined; + } + + // Check if node has any credentials defined + if (!node.credentials || !node.credentials[type]) { + // If none are defined check if the credentials are required or not + + if (nodeCredentialDescription.required === true) { + // Credentials are required so error + if (!node.credentials) { + throw new Error('Node does not have any credentials set!'); + } + if (!node.credentials[type]) { + throw new Error(`Node does not have any credentials set for "${type}"!`); + } + } else { + // Credentials are not required so resolve with undefined + return undefined; + } + } + + 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'); + } + + return decryptedDataObject; +} + + + +/** + * Returns the requested resolved (all expressions replaced) node parameters. + * + * @export + * @param {Workflow} workflow + * @param {(IRunExecutionData | null)} runExecutionData + * @param {number} runIndex + * @param {INodeExecutionData[]} connectionInputData + * @param {INode} node + * @param {string} parameterName + * @param {number} itemIndex + * @param {*} [fallbackValue] + * @returns {(NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[] | object)} + */ +export function getNodeParameter(workflow: Workflow, runExecutionData: IRunExecutionData | null, runIndex: number, connectionInputData: INodeExecutionData[], node: INode, parameterName: string, itemIndex: number, fallbackValue?: any): NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[] | object { //tslint:disable-line:no-any + const nodeType = workflow.nodeTypes.getByName(node.type); + if (nodeType === undefined) { + throw new Error(`Node type "${node.type}" is not known so can not return paramter value!`); + } + + const value = get(node.parameters, parameterName, fallbackValue); + + if (value === undefined) { + throw new Error(`Could not get parameter "${parameterName}"!`); + } + + const returnData = workflow.getParameterValue(value, runExecutionData, runIndex, itemIndex, node.name, connectionInputData); + + return returnData; +} + + + +/** + * Returns the timezone for the workflow + * + * @export + * @param {Workflow} workflow + * @param {IWorkflowExecuteAdditionalData} additionalData + * @returns {string} + */ +export function getTimezone(workflow: Workflow, additionalData: IWorkflowExecuteAdditionalData):string { + if (workflow.settings !== undefined && workflow.settings.timezone !== undefined) { + return (workflow.settings as IWorkflowSettings).timezone as string; + } + return additionalData.timezone; +} + + + +/** + * Returns the execute functions the trigger nodes have access to. + * + * @export + * @param {Workflow} workflow + * @param {INode} node + * @param {IWorkflowExecuteAdditionalData} additionalData + * @param {WorkflowExecuteMode} mode + * @returns {ITriggerFunctions} + */ +export function getExecuteTriggerFunctions(workflow: Workflow, node: INode, additionalData: IWorkflowExecuteAdditionalData, mode: WorkflowExecuteMode): ITriggerFunctions { + return ((workflow: Workflow, node: INode) => { + return { + emit: (data: INodeExecutionData[][]): void => { + const workflowExecute = new WorkflowExecute(additionalData, mode); + const nodeExecutionStack: IExecuteData[] = [ + { + node, + data: { + main: data, + } + } + ]; + + const runExecutionData: IRunExecutionData = { + startData: {}, + resultData: { + runData: {}, + }, + executionData: { + contextData: {}, + nodeExecutionStack, + waitingExecution: {}, + }, + }; + + workflowExecute.runExecutionData(workflow, runExecutionData); + }, + getCredentials(type: string): ICredentialDataDecryptedObject | undefined { + return getCredentials(workflow, node, type, additionalData); + }, + getMode: (): WorkflowExecuteMode => { + return mode; + }, + getNodeParameter: (parameterName: string, fallbackValue?: any): NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[] | object => { //tslint:disable-line:no-any + const runExecutionData: IRunExecutionData | null = null; + const itemIndex = 0; + const runIndex = 0; + const connectionInputData: INodeExecutionData[] = []; + + return getNodeParameter(workflow, runExecutionData, runIndex, connectionInputData, node, parameterName, itemIndex, fallbackValue); + }, + getTimezone: (): string => { + return getTimezone(workflow, additionalData); + }, + getWorkflowStaticData(type: string): IDataObject { + return workflow.getStaticData(type, node); + }, + helpers: { + prepareBinaryData, + request: requestPromise, + returnJsonArray, + }, + }; + }) (workflow, node); +} + + + +/** + * Returns the execute functions regular nodes have access to. + * + * @export + * @param {Workflow} workflow + * @param {IRunExecutionData} runExecutionData + * @param {number} runIndex + * @param {INodeExecutionData[]} connectionInputData + * @param {ITaskDataConnections} inputData + * @param {INode} node + * @param {IWorkflowExecuteAdditionalData} additionalData + * @param {WorkflowExecuteMode} mode + * @returns {IExecuteFunctions} + */ +export function getExecuteFunctions(workflow: Workflow, runExecutionData: IRunExecutionData, runIndex: number, connectionInputData: INodeExecutionData[], inputData: ITaskDataConnections, node: INode, additionalData: IWorkflowExecuteAdditionalData, mode: WorkflowExecuteMode): IExecuteFunctions { + return ((workflow, runExecutionData, connectionInputData, inputData, node) => { + return { + getContext(type: string): IContextObject { + return NodeHelpers.getContext(runExecutionData, type, node); + }, + getCredentials(type: string): ICredentialDataDecryptedObject | undefined { + return getCredentials(workflow, node, type, additionalData); + }, + getInputData: (inputIndex = 0, inputName = 'main') => { + + if (!inputData.hasOwnProperty(inputName)) { + // Return empty array because else it would throw error when nothing is connected to input + return []; + } + + // TODO: Check if nodeType has input with that index defined + if (inputData[inputName].length < inputIndex) { + throw new Error(`Could not get input index "${inputIndex}" of input "${inputName}"!`); + } + + + if (inputData[inputName][inputIndex] === null) { + // return []; + throw new Error(`Value "${inputIndex}" of input "${inputName}" did not get set!`); + } + + // TODO: Maybe do clone of data only here so it only clones the data that is really needed + return inputData[inputName][inputIndex] as INodeExecutionData[]; + }, + getNodeParameter: (parameterName: string, itemIndex: number, fallbackValue?: any): NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[] | object => { //tslint:disable-line:no-any + return getNodeParameter(workflow, runExecutionData, runIndex, connectionInputData, node, parameterName, itemIndex, fallbackValue); + }, + getMode: (): WorkflowExecuteMode => { + return mode; + }, + getTimezone: (): string => { + return getTimezone(workflow, additionalData); + }, + getWorkflowStaticData(type: string): IDataObject { + return workflow.getStaticData(type, node); + }, + prepareOutputData: NodeHelpers.prepareOutputData, + helpers: { + prepareBinaryData, + request: requestPromise, + returnJsonArray, + }, + }; + })(workflow, runExecutionData, connectionInputData, inputData, node); +} + + + +/** + * Returns the execute functions regular nodes have access to when single-function is defined. + * + * @export + * @param {Workflow} workflow + * @param {IRunExecutionData} runExecutionData + * @param {number} runIndex + * @param {INodeExecutionData[]} connectionInputData + * @param {ITaskDataConnections} inputData + * @param {INode} node + * @param {number} itemIndex + * @param {IWorkflowExecuteAdditionalData} additionalData + * @param {WorkflowExecuteMode} mode + * @returns {IExecuteSingleFunctions} + */ +export function getExecuteSingleFunctions(workflow: Workflow, runExecutionData: IRunExecutionData, runIndex: number, connectionInputData: INodeExecutionData[], inputData: ITaskDataConnections, node: INode, itemIndex: number, additionalData: IWorkflowExecuteAdditionalData, mode: WorkflowExecuteMode): IExecuteSingleFunctions { + return ((workflow, runExecutionData, connectionInputData, inputData, node, itemIndex) => { + return { + getContext(type: string): IContextObject { + return NodeHelpers.getContext(runExecutionData, type, node); + }, + getCredentials(type: string): ICredentialDataDecryptedObject | undefined { + return getCredentials(workflow, node, type, additionalData); + }, + getInputData: (inputIndex = 0, inputName = 'main') => { + if (!inputData.hasOwnProperty(inputName)) { + // Return empty array because else it would throw error when nothing is connected to input + return {json: {}}; + } + + // TODO: Check if nodeType has input with that index defined + if (inputData[inputName].length < inputIndex) { + throw new Error(`Could not get input index "${inputIndex}" of input "${inputName}"!`); + } + + const allItems = inputData[inputName][inputIndex]; + + if (allItems === null) { + // return []; + throw new Error(`Value "${inputIndex}" of input "${inputName}" did not get set!`); + } + + if (allItems[itemIndex] === null) { + // return []; + throw new Error(`Value "${inputIndex}" of input "${inputName}" with itemIndex "${itemIndex}" did not get set!`); + } + + return allItems[itemIndex] as INodeExecutionData; + }, + getMode: (): WorkflowExecuteMode => { + return mode; + }, + getTimezone: (): string => { + return getTimezone(workflow, additionalData); + }, + getNodeParameter: (parameterName: string, fallbackValue?: any): NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[] | object => { //tslint:disable-line:no-any + return getNodeParameter(workflow, runExecutionData, runIndex, connectionInputData, node, parameterName, itemIndex, fallbackValue); + }, + getWorkflowStaticData(type: string): IDataObject { + return workflow.getStaticData(type, node); + }, + helpers: { + prepareBinaryData, + request: requestPromise, + }, + }; + })(workflow, runExecutionData, connectionInputData, inputData, node, itemIndex); +} + + +/** + * Returns the execute functions regular nodes have access to in load-options-function. + * + * @export + * @param {Workflow} workflow + * @param {INode} node + * @param {IWorkflowExecuteAdditionalData} additionalData + * @returns {ILoadOptionsFunctions} + */ +export function getLoadOptionsFunctions(workflow: Workflow, node: INode, additionalData: IWorkflowExecuteAdditionalData): ILoadOptionsFunctions { + return ((workflow: Workflow, node: INode) => { + const that = { + getCredentials(type: string): ICredentialDataDecryptedObject | undefined { + return getCredentials(workflow, node, type, additionalData); + }, + getNodeParameter: (parameterName: string, fallbackValue?: any): NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[] | object => { //tslint:disable-line:no-any + const runExecutionData: IRunExecutionData | null = null; + const itemIndex = 0; + const runIndex = 0; + const connectionInputData: INodeExecutionData[] = []; + + return getNodeParameter(workflow, runExecutionData, runIndex, connectionInputData, node, parameterName, itemIndex, fallbackValue); + }, + getTimezone: (): string => { + return getTimezone(workflow, additionalData); + }, + helpers: { + request: requestPromise, + }, + }; + return that; + })(workflow, node); + +} + + +/** + * Returns the execute functions regular nodes have access to in hook-function. + * + * @export + * @param {Workflow} workflow + * @param {INode} node + * @param {IWorkflowExecuteAdditionalData} additionalData + * @param {WorkflowExecuteMode} mode + * @returns {IHookFunctions} + */ +export function getExecuteHookFunctions(workflow: Workflow, node: INode, additionalData: IWorkflowExecuteAdditionalData, mode: WorkflowExecuteMode, isTest?: boolean): IHookFunctions { + return ((workflow: Workflow, node: INode) => { + const that = { + getCredentials(type: string): ICredentialDataDecryptedObject | undefined { + return getCredentials(workflow, node, type, additionalData); + }, + getMode: (): WorkflowExecuteMode => { + return mode; + }, + getNodeParameter: (parameterName: string, fallbackValue?: any): NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[] | object => { //tslint:disable-line:no-any + const runExecutionData: IRunExecutionData | null = null; + const itemIndex = 0; + const runIndex = 0; + const connectionInputData: INodeExecutionData[] = []; + + return getNodeParameter(workflow, runExecutionData, runIndex, connectionInputData, node, parameterName, itemIndex, fallbackValue); + }, + getNodeWebhookUrl: (name: string): string | undefined => { + let baseUrl = additionalData.webhookBaseUrl; + if (isTest === true) { + baseUrl = additionalData.webhookTestBaseUrl; + } + + const webhookDescription = that.getWebhookDescription(name); + if (webhookDescription === undefined) { + return undefined; + } + + const path = workflow.getWebhookParameterValue(node, webhookDescription, 'path'); + if (path === undefined) { + return undefined; + } + + return NodeHelpers.getNodeWebhookUrl(baseUrl, workflow.id!, node, path); + }, + getTimezone: (): string => { + return getTimezone(workflow, additionalData); + }, + getWebhookDescription(name: string): IWebhookDescription | undefined { + const nodeType = workflow.nodeTypes.getByName(node.type) as INodeType; + + if (nodeType.description.webhooks === undefined) { + // Node does not have any webhooks so return + return undefined; + } + + for (const webhookDescription of nodeType.description.webhooks) { + if (webhookDescription.name === name) { + return webhookDescription; + } + } + + return undefined; + }, + getWorkflowStaticData(type: string): IDataObject { + return workflow.getStaticData(type, node); + }, + helpers: { + request: requestPromise, + }, + }; + return that; + })(workflow, node); + +} + + +/** + * Returns the execute functions regular nodes have access to when webhook-function is defined. + * + * @export + * @param {Workflow} workflow + * @param {IRunExecutionData} runExecutionData + * @param {INode} node + * @param {IWorkflowExecuteAdditionalData} additionalData + * @param {WorkflowExecuteMode} mode + * @returns {IWebhookFunctions} + */ +export function getExecuteWebhookFunctions(workflow: Workflow, node: INode, additionalData: IWorkflowExecuteAdditionalData, mode: WorkflowExecuteMode): IWebhookFunctions { + return ((workflow: Workflow, node: INode) => { + return { + getBodyData(): IDataObject { + if (additionalData.httpRequest === undefined) { + throw new Error('Request is missing!'); + } + return additionalData.httpRequest.body; + }, + getCredentials(type: string): ICredentialDataDecryptedObject | undefined { + return getCredentials(workflow, node, type, additionalData); + }, + getHeaderData(): object { + if (additionalData.httpRequest === undefined) { + throw new Error('Request is missing!'); + } + return additionalData.httpRequest.headers; + }, + getMode: (): WorkflowExecuteMode => { + return mode; + }, + getNodeParameter: (parameterName: string, fallbackValue?: any): NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[] | object => { //tslint:disable-line:no-any + const runExecutionData: IRunExecutionData | null = null; + const itemIndex = 0; + const runIndex = 0; + const connectionInputData: INodeExecutionData[] = []; + + return getNodeParameter(workflow, runExecutionData, runIndex, connectionInputData, node, parameterName, itemIndex, fallbackValue); + }, + getQueryData(): object { + if (additionalData.httpRequest === undefined) { + throw new Error('Request is missing!'); + } + return additionalData.httpRequest.query; + }, + getRequestObject(): express.Request { + if (additionalData.httpRequest === undefined) { + throw new Error('Request is missing!'); + } + return additionalData.httpRequest; + }, + getResponseObject(): express.Response { + if (additionalData.httpResponse === undefined) { + throw new Error('Response is missing!'); + } + return additionalData.httpResponse; + }, + getTimezone: (): string => { + return getTimezone(workflow, additionalData); + }, + getWorkflowStaticData(type: string): IDataObject { + return workflow.getStaticData(type, node); + }, + prepareOutputData: NodeHelpers.prepareOutputData, + helpers: { + prepareBinaryData, + request: requestPromise, + returnJsonArray, + }, + }; + })(workflow, node); + +} diff --git a/packages/core/src/UserSettings.ts b/packages/core/src/UserSettings.ts new file mode 100644 index 0000000000..dd97b9f406 --- /dev/null +++ b/packages/core/src/UserSettings.ts @@ -0,0 +1,234 @@ +import { + ENCRYPTION_KEY_ENV_OVERWRITE, + EXTENSIONS_SUBDIRECTORY, + USER_FOLDER_ENV_OVERWRITE, + USER_SETTINGS_FILE_NAME, + USER_SETTINGS_SUBFOLDER, + IUserSettings, +} from '.'; + + +import * as fs from 'fs'; +import * as path from 'path'; +import { randomBytes } from 'crypto'; +const { promisify } = require('util'); +const fsAccess = promisify(fs.access); +const fsReadFile = promisify(fs.readFile); +const fsMkdir = promisify(fs.mkdir); +const fsWriteFile = promisify(fs.writeFile); + + + +let settingsCache: IUserSettings | undefined = undefined; + + +/** + * Creates the user settings if they do not exist yet + * + * @export + */ +export async function prepareUserSettings(): Promise { + const settingsPath = getUserSettingsPath(); + + let userSettings = await getUserSettings(settingsPath); + if (userSettings !== undefined) { + // Settings already exist, check if they contain the encryptionKey + if (userSettings.encryptionKey !== undefined) { + // Key already exists so return + return userSettings; + } + } else { + userSettings = {}; + } + + // Settings and/or key do not exist. So generate a new encryption key + userSettings.encryptionKey = randomBytes(24).toString('base64'); + + console.log(`UserSettings got generated and saved to: ${settingsPath}`); + + return writeUserSettings(userSettings, settingsPath); +} + + +/** + * Returns the encryption key which is used to encrypt + * the credentials. + * + * @export + * @returns + */ +export async function getEncryptionKey() { + if (process.env[ENCRYPTION_KEY_ENV_OVERWRITE] !== undefined) { + return process.env[ENCRYPTION_KEY_ENV_OVERWRITE]; + } + + const userSettings = await getUserSettings(); + + if (userSettings === undefined) { + return undefined; + } + + if (userSettings.encryptionKey === undefined) { + return undefined; + } + + return userSettings.encryptionKey; +} + + +/** + * Adds/Overwrite the given settings in the currently + * saved user settings + * + * @export + * @param {IUserSettings} addSettings The settings to add/overwrite + * @param {string} [settingsPath] Optional settings file path + * @returns {Promise} + */ +export async function addToUserSettings(addSettings: IUserSettings, settingsPath?: string): Promise { + if (settingsPath === undefined) { + settingsPath = getUserSettingsPath(); + } + + let userSettings = await getUserSettings(settingsPath); + + if (userSettings === undefined) { + userSettings = {}; + } + + // Add the settings + Object.assign(userSettings, addSettings); + + return writeUserSettings(userSettings, settingsPath); +} + + +/** + * Writes a user settings file + * + * @export + * @param {IUserSettings} userSettings The settings to write + * @param {string} [settingsPath] Optional settings file path + * @returns {Promise} + */ +export async function writeUserSettings(userSettings: IUserSettings, settingsPath?: string): Promise { + if (settingsPath === undefined) { + settingsPath = getUserSettingsPath(); + } + + if (userSettings === undefined) { + userSettings = {}; + } + + // Check if parent folder exists if not create it. + try { + await fsAccess(path.dirname(settingsPath)); + } catch (error) { + // Parent folder does not exist so create + await fsMkdir(path.dirname(settingsPath)); + } + + await fsWriteFile(settingsPath, JSON.stringify(userSettings, null, '\t')); + settingsCache = JSON.parse(JSON.stringify(userSettings)); + + return userSettings; +} + + +/** + * Returns the content of the user settings + * + * @export + * @returns {UserSettings} + */ +export async function getUserSettings(settingsPath?: string, ignoreCache?: boolean): Promise { + if (settingsCache !== undefined && ignoreCache !== true) { + + return settingsCache; + } + + if (settingsPath === undefined) { + settingsPath = getUserSettingsPath(); + } + + try { + await fsAccess(settingsPath); + } catch (error) { + // The file does not exist + return undefined; + } + + const settingsFile = await fsReadFile(settingsPath, 'utf8'); + settingsCache = JSON.parse(settingsFile); + + return JSON.parse(settingsFile) as IUserSettings; +} + + +/** + * Returns the path to the user settings + * + * @export + * @returns {string} + */ +export function getUserSettingsPath(): string { + const n8nFolder = getUserN8nFolderPath(); + + return path.join(n8nFolder, USER_SETTINGS_FILE_NAME); +} + + + +/** + * Retruns the path to the n8n folder in which all n8n + * related data gets saved + * + * @export + * @returns {string} + */ +export function getUserN8nFolderPath(): string { + let userFolder; + if (process.env[USER_FOLDER_ENV_OVERWRITE] !== undefined) { + userFolder = process.env[USER_FOLDER_ENV_OVERWRITE] as string; + } else { + userFolder = getUserHome(); + } + + return path.join(userFolder, USER_SETTINGS_SUBFOLDER); +} + + +/** + * Returns the path to the n8n user folder with the custom + * extensions like nodes and credentials + * + * @export + * @returns {string} + */ +export function getUserN8nFolderCustomExtensionPath(): string { + return path.join(getUserN8nFolderPath(), EXTENSIONS_SUBDIRECTORY); +} + + +/** + * Returns the home folder path of the user if + * none can be found it falls back to the current + * working directory + * + * @export + * @returns {string} + */ +export function getUserHome(): string { + let variableName = 'HOME'; + if (process.platform === 'win32') { + variableName = 'USERPROFILE'; + } + + if (process.env[variableName] === undefined) { + // If for some reason the variable does not exist + // fall back to current folder + return process.cwd(); + } + + return process.env[variableName] as string; +} diff --git a/packages/core/src/WorkflowExecute.ts b/packages/core/src/WorkflowExecute.ts new file mode 100644 index 0000000000..9f5074b231 --- /dev/null +++ b/packages/core/src/WorkflowExecute.ts @@ -0,0 +1,579 @@ +import { + IConnection, + IExecuteData, + IExecutionError, + INode, + INodeConnections, + INodeExecutionData, + IRun, + IRunData, + IRunExecutionData, + ITaskData, + ITaskDataConnections, + IWaitingForExecution, + IWorkflowExecuteAdditionalData, + WorkflowExecuteMode, + Workflow, +} from 'n8n-workflow'; +import { + ActiveExecutions, + NodeExecuteFunctions, +} from './'; + + +export class WorkflowExecute { + private additionalData: IWorkflowExecuteAdditionalData; + private mode: WorkflowExecuteMode; + private activeExecutions: ActiveExecutions.ActiveExecutions; + private executionId: string | null = null; + + + constructor(additionalData: IWorkflowExecuteAdditionalData, mode: WorkflowExecuteMode) { + this.additionalData = additionalData; + this.activeExecutions = ActiveExecutions.getInstance(); + this.mode = mode; + } + + + + /** + * Executes the given workflow. + * + * @param {Workflow} workflow The workflow to execute + * @param {INode[]} [startNodes] Node to start execution from + * @param {string} [destinationNode] Node to stop execution at + * @returns {(Promise)} + * @memberof WorkflowExecute + */ + async run(workflow: Workflow, startNodes?: INode[], destinationNode?: string): Promise { + // Get the nodes to start workflow execution from + startNodes = startNodes || workflow.getStartNodes(destinationNode); + + // If a destination node is given we only run the direct parent nodes and no others + let runNodeFilter: string[] | undefined = undefined; + if (destinationNode) { + // TODO: Combine that later with getStartNodes which does more or less the same tree iteration + runNodeFilter = workflow.getParentNodes(destinationNode); + runNodeFilter.push(destinationNode); + } + + // Initialize the data of the start nodes + const nodeExecutionStack: IExecuteData[] = []; + startNodes.forEach((node) => { + nodeExecutionStack.push( + { + node, + data: { + main: [ + [ + { + json: {}, + }, + ], + ], + }, + }, + ); + }); + + const runExecutionData: IRunExecutionData = { + startData: { + destinationNode, + runNodeFilter, + }, + resultData: { + runData: {}, + }, + executionData: { + contextData: {}, + nodeExecutionStack, + waitingExecution: {}, + }, + }; + + return this.runExecutionData(workflow, runExecutionData); + } + + + + /** + * Executes the given workflow but only + * + * @param {Workflow} workflow The workflow to execute + * @param {IRunData} runData + * @param {string[]} startNodes Nodes to start execution from + * @param {string} destinationNode Node to stop execution at + * @returns {(Promise)} + * @memberof WorkflowExecute + */ + async runPartialWorkflow(workflow: Workflow, runData: IRunData, startNodes: string[], destinationNode: string): Promise { + + let incomingNodeConnections: INodeConnections | undefined; + let connection: IConnection; + + const runIndex = 0; + + // Initialize the nodeExecutionStack and waitingExecution with + // the data from runData + const nodeExecutionStack: IExecuteData[] = []; + const waitingExecution: IWaitingForExecution = {}; + for (const startNode of startNodes) { + incomingNodeConnections = workflow.connectionsByDestinationNode[startNode]; + + const incomingData: INodeExecutionData[][] = []; + + if (incomingNodeConnections === undefined) { + // If it has no incoming data add the default empty data + incomingData.push([ + { + json: {} + } + ]); + } else { + // Get the data of the incoming connections + for (const connections of incomingNodeConnections.main) { + for (let inputIndex = 0; inputIndex < connections.length; inputIndex++) { + connection = connections[inputIndex]; + incomingData.push( + runData[connection.node!][runIndex].data![connection.type][connection.index]!, + ); + } + } + } + + const executeData: IExecuteData = { + node: workflow.getNode(startNode) as INode, + data: { + main: incomingData, + } + }; + + nodeExecutionStack.push(executeData); + + // Check if the destinationNode has to be added as waiting + // because some input data is already fully available + incomingNodeConnections = workflow.connectionsByDestinationNode[destinationNode]; + if (incomingNodeConnections !== undefined) { + for (const connections of incomingNodeConnections.main) { + for (let inputIndex = 0; inputIndex < connections.length; inputIndex++) { + connection = connections[inputIndex]; + + if (waitingExecution[destinationNode] === undefined) { + waitingExecution[destinationNode] = {}; + } + if (waitingExecution[destinationNode][runIndex] === undefined) { + waitingExecution[destinationNode][runIndex] = {}; + } + if (waitingExecution[destinationNode][runIndex][connection.type] === undefined) { + waitingExecution[destinationNode][runIndex][connection.type] = []; + } + + + if (runData[connection.node!] !== undefined) { + // Input data exists so add as waiting + // incomingDataDestination.push(runData[connection.node!][runIndex].data![connection.type][connection.index]); + waitingExecution[destinationNode][runIndex][connection.type].push(runData[connection.node!][runIndex].data![connection.type][connection.index]); + } else { + waitingExecution[destinationNode][runIndex][connection.type].push(null); + } + } + } + } + } + + // Only run the parent nodes and no others + let runNodeFilter: string[] | undefined = undefined; + runNodeFilter = workflow.getParentNodes(destinationNode); + runNodeFilter.push(destinationNode); + + + const runExecutionData: IRunExecutionData = { + startData: { + destinationNode, + runNodeFilter, + }, + resultData: { + runData, + }, + executionData: { + contextData: {}, + nodeExecutionStack, + waitingExecution, + }, + }; + + return await this.runExecutionData(workflow, runExecutionData); + } + + + + /** + * Executes the hook with the given name + * + * @param {string} hookName + * @param {any[]} parameters + * @returns {Promise} + * @memberof WorkflowExecute + */ + async executeHook(hookName: string, parameters: any[]): Promise { // tslint:disable-line:no-any + if (this.additionalData.hooks === undefined) { + return parameters[0]; + } + if (this.additionalData.hooks[hookName] === undefined || this.additionalData.hooks[hookName]!.length === 0) { + return parameters[0]; + } + + for (const hookFunction of this.additionalData.hooks[hookName]!) { + await hookFunction.apply(this, parameters as [IRun, IWaitingForExecution]) + .catch((error) => { + // Catch all errors here because when "executeHook" gets called + // we have the most time no "await" and so the errors would so + // not be uncaught by anything. + + // TODO: Add proper logging + console.error(`There was a problem executing hook: "${hookName}"`); + console.error('Parameters:'); + console.error(parameters); + console.error('Error:'); + console.error(error); + }); + } + } + + + + /** + * Runs the given execution data. + * + * @param {Workflow} workflow + * @param {IRunExecutionData} runExecutionData + * @returns {Promise} + * @memberof WorkflowExecute + */ + async runExecutionData(workflow: Workflow, runExecutionData: IRunExecutionData): Promise { + const startedAt = new Date().getTime(); + + const workflowIssues = workflow.checkReadyForExecution(); + if (workflowIssues !== null) { + throw new Error('The workflow has issues and can for that reason not be executed. Please fix them first.'); + } + + // Variables which hold temporary data for each node-execution + let executionData: IExecuteData; + let executionError: IExecutionError | undefined; + let executionNode: INode; + let nodeSuccessData: INodeExecutionData[][] | null; + let runIndex: number; + let startTime: number; + let taskData: ITaskData; + + if (runExecutionData.startData === undefined) { + runExecutionData.startData = {}; + } + + this.executionId = this.activeExecutions.add(workflow, runExecutionData, this.mode); + + this.executeHook('workflowExecuteBefore', [this.executionId]); + + let currentExecutionTry = ''; + let lastExecutionTry = ''; + + // Wait for the next tick so that the executionId gets already returned. + // So it can directly be send to the editor-ui and is so aware of the + // executionId when the first push messages arrive. + process.nextTick(() => (async () => { + executionLoop: + while (runExecutionData.executionData!.nodeExecutionStack.length !== 0) { + if (this.activeExecutions.shouldBeStopped(this.executionId!) === true) { + // The execution should be stopped + break; + } + + nodeSuccessData = null; + executionError = undefined; + executionData = runExecutionData.executionData!.nodeExecutionStack.shift() as IExecuteData; + executionNode = executionData.node; + + this.executeHook('nodeExecuteBefore', [this.executionId, executionNode.name]); + + // Get the index of the current run + runIndex = 0; + if (runExecutionData.resultData.runData.hasOwnProperty(executionNode.name)) { + runIndex = runExecutionData.resultData.runData[executionNode.name].length; + } + + currentExecutionTry = `${executionNode.name}:${runIndex}`; + + if (currentExecutionTry === lastExecutionTry) { + throw new Error('Did stop execution because execution seems to be in endless loop.'); + } + + if (runExecutionData.startData!.runNodeFilter !== undefined && runExecutionData.startData!.runNodeFilter!.indexOf(executionNode.name) === -1) { + // If filter is set and node is not on filter skip it, that avoids the problem that it executes + // leafs that are parallel to a selected destinationNode. Normally it would execute them because + // they have the same parent and it executes all child nodes. + continue; + } + + // Check if all the data which is needed to run the node is available + if (workflow.connectionsByDestinationNode.hasOwnProperty(executionNode.name)) { + // Check if the node has incoming connections + if (workflow.connectionsByDestinationNode[executionNode.name].hasOwnProperty('main')) { + let inputConnections: IConnection[][]; + let connectionIndex: number; + + inputConnections = workflow.connectionsByDestinationNode[executionNode.name]['main']; + + for (connectionIndex = 0; connectionIndex < inputConnections.length; connectionIndex++) { + if (workflow.getHighestNode(executionNode.name, 'main', connectionIndex).length === 0) { + // If there is no valid incoming node (if all are disabled) + // then ignore that it has inputs and simply execute it as it is without + // any data + continue; + } + + if (!executionData.data!.hasOwnProperty('main')) { + // ExecutionData does not even have the connection set up so can + // not have that data, so add it again to be executed later + runExecutionData.executionData!.nodeExecutionStack.push(executionData); + lastExecutionTry = currentExecutionTry; + continue executionLoop; + } + + // Check if it has the data for all the inputs + // The most nodes just have one but merge node for example has two and data + // of both inputs has to be available to be able to process the node. + if (executionData.data!.main!.length < connectionIndex || executionData.data!.main![connectionIndex] === null) { + // Does not have the data of the connections so add back to stack + runExecutionData.executionData!.nodeExecutionStack.push(executionData); + lastExecutionTry = currentExecutionTry; + continue executionLoop; + } + } + } + } + + // TODO Has to check if node is disabled + + // Clone input data that nodes can not mess up data of parallel nodes which receive the same data + // TODO: Should only clone if multiple nodes get the same data or when it gets returned to frontned + // is very slow so only do if needed + startTime = new Date().getTime(); + + try { + runExecutionData.resultData.lastNodeExecuted = executionData.node.name; + nodeSuccessData = await workflow.runNode(executionData.node, JSON.parse(JSON.stringify(executionData.data)), runExecutionData, runIndex, this.additionalData, NodeExecuteFunctions, this.mode); + + if (nodeSuccessData === null) { + // If null gets returned it means that the node did succeed + // but did not have any data. So the branch should end + // (meaning the nodes afterwards should not be processed) + continue; + } + + } catch (error) { + executionError = { + message: error.message, + stack: error.stack, + }; + } + + // Add the data to return to the user + // (currently does not get cloned as data does not get changed, maybe later we should do that?!?!) + + if (!runExecutionData.resultData.runData.hasOwnProperty(executionNode.name)) { + runExecutionData.resultData.runData[executionNode.name] = []; + } + taskData = { + startTime, + executionTime: (new Date().getTime()) - startTime + }; + + if (executionError !== undefined) { + taskData.error = executionError; + + if (executionData.node.continueOnFail === true) { + // Workflow should continue running even if node errors + if (executionData.data.hasOwnProperty('main') && executionData.data.main.length > 0) { + // Simply get the input data of the node if it has any and pass it through + // to the next node + if (executionData.data.main[0] !== null) { + nodeSuccessData = [(JSON.parse(JSON.stringify(executionData.data.main[0])) as INodeExecutionData[])]; + } + } + } else { + // Node execution did fail so add error and stop execution + runExecutionData.resultData.runData[executionNode.name].push(taskData); + + // Add the execution data again so that it can get restarted + runExecutionData.executionData!.nodeExecutionStack.unshift(executionData); + + this.executeHook('nodeExecuteAfter', [this.executionId, executionNode.name, taskData]); + + break; + } + } + + // Node executed successfully. So add data and go on. + taskData.data = ({ + 'main': nodeSuccessData + } as ITaskDataConnections); + + this.executeHook('nodeExecuteAfter', [this.executionId, executionNode.name, taskData]); + + runExecutionData.resultData.runData[executionNode.name].push(taskData); + + if (runExecutionData.startData && runExecutionData.startData.destinationNode && runExecutionData.startData.destinationNode === executionNode.name) { + // If destination node is defined and got executed stop execution + continue; + } + + // 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)) { + if (workflow.connectionsBySourceNode[executionNode.name].hasOwnProperty('main')) { + let outputIndex: string, connectionData: IConnection; + // Go over all the different + for (outputIndex in workflow.connectionsBySourceNode[executionNode.name]['main']) { + if (!workflow.connectionsBySourceNode[executionNode.name]['main'].hasOwnProperty(outputIndex)) { + continue; + } + // Go through all the different outputs of this connection + for (connectionData of workflow.connectionsBySourceNode[executionNode.name]['main'][outputIndex]) { + if (!workflow.nodes.hasOwnProperty(connectionData.node)) { + return Promise.reject(new Error(`The node "${executionNode.name}" connects to not found node "${connectionData.node}"`)); + } + + let stillDataMissing = false; + + // Check if node has multiple inputs as then we have to wait for all input data + // to be present before we can add it to the node-execution-stack + if (workflow.connectionsByDestinationNode[connectionData.node]['main'].length > 1) { + // Node has multiple inputs + + // Check if there is already data for the node + if (runExecutionData.executionData!.waitingExecution.hasOwnProperty(connectionData.node) && runExecutionData.executionData!.waitingExecution[connectionData.node][runIndex] !== undefined) { + // There is already data for the node and the current run so + // add the new data + if (nodeSuccessData === null) { + runExecutionData.executionData!.waitingExecution[connectionData.node][runIndex].main[connectionData.index] = null; + } else { + runExecutionData.executionData!.waitingExecution[connectionData.node][runIndex].main[connectionData.index] = nodeSuccessData[outputIndex]; + } + + // Check if all data exists now + let thisExecutionData: INodeExecutionData[] | null; + let allDataFound = true; + for (let i = 0; i < runExecutionData.executionData!.waitingExecution[connectionData.node][runIndex].main.length; i++) { + thisExecutionData = runExecutionData.executionData!.waitingExecution[connectionData.node][runIndex].main[i]; + if (thisExecutionData === null) { + allDataFound = false; + break; + } + } + + if (allDataFound === true) { + // All data exists for node to be executed + // So add it to the execution stack + runExecutionData.executionData!.nodeExecutionStack.push({ + node: workflow.nodes[connectionData.node], + data: runExecutionData.executionData!.waitingExecution[connectionData.node][runIndex] + }); + + // Remove the data from waiting + delete runExecutionData.executionData!.waitingExecution[connectionData.node][runIndex]; + + if (Object.keys(runExecutionData.executionData!.waitingExecution[connectionData.node]).length === 0) { + // No more data left for the node so also delete that one + delete runExecutionData.executionData!.waitingExecution[connectionData.node]; + } + continue; + } else { + stillDataMissing = true; + } + } else { + stillDataMissing = true; + } + } + + // Make sure the array has all the values + const connectionDataArray: Array = []; + for (let i: number = connectionData.index; i >= 0; i--) { + connectionDataArray[i] = null; + } + + // Add the data of the current execution + if (nodeSuccessData === null) { + connectionDataArray[connectionData.index] = null; + } else { + connectionDataArray[connectionData.index] = nodeSuccessData[outputIndex]; + } + + if (stillDataMissing === true) { + // Additional data is needed to run node so add it to waiting + if (!runExecutionData.executionData!.waitingExecution.hasOwnProperty(connectionData.node)) { + runExecutionData.executionData!.waitingExecution[connectionData.node] = {}; + } + runExecutionData.executionData!.waitingExecution[connectionData.node][runIndex] = { + main: connectionDataArray + }; + } else { + // All data is there so add it directly to stack + runExecutionData.executionData!.nodeExecutionStack.push({ + node: workflow.nodes[connectionData.node], + data: { + main: connectionDataArray + } + }); + } + + } + } + } + } + } + return Promise.resolve(); + })() + .then(async () => { + const fullRunData: IRun = { + data: runExecutionData, + mode: this.mode, + startedAt, + stoppedAt: new Date().getTime(), + }; + + if (executionError !== undefined) { + fullRunData.data.resultData.error = executionError; + } else { + fullRunData.finished = true; + } + + this.activeExecutions.remove(this.executionId!, fullRunData); + + await this.executeHook('workflowExecuteAfter', [fullRunData, this.executionId!]); + + return fullRunData; + }) + .catch(async (error) => { + const fullRunData: IRun = { + data: runExecutionData, + mode: this.mode, + startedAt, + stoppedAt: new Date().getTime(), + }; + + fullRunData.data.resultData.error = { + message: error.message, + stack: error.stack, + }; + + this.activeExecutions.remove(this.executionId!, fullRunData); + + await this.executeHook('workflowExecuteAfter', [fullRunData, this.executionId!]); + + return fullRunData; + })); + + return this.executionId; + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000000..a5663f2fcb --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,24 @@ +try { + require('source-map-support').install(); +} catch (error) { + +} + +export * from './ActiveWorkflows'; +export * from './ActiveWebhooks'; +export * from './Constants'; +export * from './Credentials'; +export * from './DeferredPromise'; +export * from './Interfaces'; +export * from './LoadNodeParameterOptions'; +export * from './NodeExecuteFunctions'; +export * from './WorkflowExecute'; + +import * as ActiveExecutions from './ActiveExecutions'; +import * as NodeExecuteFunctions from './NodeExecuteFunctions'; +import * as UserSettings from './UserSettings'; +export { + ActiveExecutions, + NodeExecuteFunctions, + UserSettings, +}; diff --git a/packages/core/test/Credentials.test.ts b/packages/core/test/Credentials.test.ts new file mode 100644 index 0000000000..57c9057c9a --- /dev/null +++ b/packages/core/test/Credentials.test.ts @@ -0,0 +1,88 @@ + +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 key = 'key1'; + const password = 'password'; + // const nodeType = 'base.noOp'; + const newData = 1234; + + credentials.setDataKey(key, newData, password); + + expect(credentials.getDataKey(key, password)).toEqual(newData); + }); + + test('should be able to set and read key data with initial data set', () => { + + const key = 'key2'; + const password = 'password'; + + // Saved under "key1" + const initialData = 4321; + const initialDataEncoded = 'U2FsdGVkX1+0baznXt+Ag/ub8A2kHLyoLxn/rR9h4XQ='; + + const credentials = new Credentials('testName', 'testType', [], initialDataEncoded); + + const newData = 1234; + + // Set and read new data + credentials.setDataKey(key, newData, password); + expect(credentials.getDataKey(key, password)).toEqual(newData); + + // Read the data which got provided encrypted on init + expect(credentials.getDataKey('key1', password)).toEqual(initialData); + }); + + }); + + describe('with nodeType set', () => { + + test('should be able to set and read key data without initial data set', () => { + + const nodeAccess = [ + { + nodeType: 'base.noOp', + user: 'userName', + date: 1234, + } + ]; + + const credentials = new Credentials('testName', 'testType', nodeAccess); + + const key = 'key1'; + const password = 'password'; + const nodeType = 'base.noOp'; + const newData = 1234; + + credentials.setDataKey(key, newData, password); + + // Should be able to read with nodeType which has access + expect(credentials.getDataKey(key, password, nodeType)).toEqual(newData); + + // Should not be able to read with nodeType which does NOT have access + // expect(credentials.getDataKey(key, password, 'base.otherNode')).toThrowError(Error); + try { + credentials.getDataKey(key, password, 'base.otherNode'); + expect(true).toBe(false); + } catch (e) { + expect(e.message).toBe('The node of type "base.otherNode" does not have access to credentials "testName" of type "testType".'); + } + + // Get the data which will be saved in database + const dbData = credentials.getDataToSave(); + expect(dbData.name).toEqual('testName'); + expect(dbData.type).toEqual('testType'); + expect(dbData.nodesAccess).toEqual(nodeAccess); + // Compare only the first 6 characters as the rest seems to change with each execution + expect(dbData.data!.slice(0, 6)).toEqual('U2FsdGVkX1+wpQWkj+YTzaPSNTFATjnlmFKIsUTZdhk='.slice(0, 6)); + }); + }); + +}); diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 0000000000..853c88b9c9 --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "lib": [ + "es2017" + ], + "types": [ + "node", + "jest" + ], + "module": "commonjs", + "noImplicitAny": true, + "removeComments": true, + "strictNullChecks": true, + "forceConsistentCasingInFileNames": true, + "noImplicitReturns": true, + "strict": true, + "noUnusedLocals": true, + "preserveConstEnums": true, + "declaration": true, + "outDir": "./dist/", + "target": "es2017", + "sourceMap": true + }, + "include": [ + "**/*.d.ts", + "src/**/*", + "test/**/*", + ], + "exclude": [ + "dist/**/*", + "node_modules/**/*", + "**/*.spec.ts" + ] +} diff --git a/packages/core/tslint.json b/packages/core/tslint.json new file mode 100644 index 0000000000..7eb9d0110e --- /dev/null +++ b/packages/core/tslint.json @@ -0,0 +1,103 @@ +{ + "linterOptions": { + "exclude": [ + "node_modules/**/*" + ] + }, + "defaultSeverity": "error", + "jsRules": {}, + "rules": { + "array-type": [ + true, + "array-simple" + ], + "arrow-return-shorthand": true, + "ban": [ + true, + { + "name": "Array", + "message": "tsstyle#array-constructor" + } + ], + "ban-types": [ + true, + [ + "Object", + "Use {} instead." + ], + [ + "String", + "Use 'string' instead." + ], + [ + "Number", + "Use 'number' instead." + ], + [ + "Boolean", + "Use 'boolean' instead." + ] + ], + "class-name": true, + "curly": [ + true, + "ignore-same-line" + ], + "forin": true, + "jsdoc-format": true, + "label-position": true, + "member-access": [ + true, + "no-public" + ], + "new-parens": true, + "no-angle-bracket-type-assertion": true, + "no-any": true, + "no-arg": true, + "no-conditional-assignment": true, + "no-construct": true, + "no-debugger": true, + "no-default-export": true, + "no-duplicate-variable": true, + "no-inferrable-types": true, + "no-namespace": [ + true, + "allow-declarations" + ], + "no-reference": true, + "no-string-throw": true, + "no-unused-expression": true, + "no-var-keyword": true, + "object-literal-shorthand": true, + "only-arrow-functions": [ + true, + "allow-declarations", + "allow-named-functions" + ], + "prefer-const": true, + "radix": true, + "semicolon": [ + true, + "always", + "ignore-bound-class-methods" + ], + "switch-default": true, + "triple-equals": [ + true, + "allow-null-check" + ], + "use-isnan": true, + "quotes": [ + "error", + "single" + ], + "variable-name": [ + true, + "check-format", + "ban-keywords", + "allow-leading-underscore", + "allow-trailing-underscore" + ] + }, + "rulesDirectory": [] +} diff --git a/packages/editor-ui/.browserslistrc b/packages/editor-ui/.browserslistrc new file mode 100644 index 0000000000..9dee646463 --- /dev/null +++ b/packages/editor-ui/.browserslistrc @@ -0,0 +1,3 @@ +> 1% +last 2 versions +not ie <= 8 diff --git a/packages/editor-ui/.editorconfig b/packages/editor-ui/.editorconfig new file mode 100644 index 0000000000..bec7553240 --- /dev/null +++ b/packages/editor-ui/.editorconfig @@ -0,0 +1,8 @@ +root = true + +[*] +charset = utf-8 +indent_style = tab +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true diff --git a/packages/editor-ui/.eslintrc.js b/packages/editor-ui/.eslintrc.js new file mode 100644 index 0000000000..25493bb780 --- /dev/null +++ b/packages/editor-ui/.eslintrc.js @@ -0,0 +1,23 @@ +module.exports = { + root: true, + env: { + node: true, + }, + 'extends': [ + 'plugin:vue/essential', + '@vue/standard', + '@vue/typescript', + ], + rules: { + 'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off', + 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off', + 'semi': [2, 'always'], + 'indent': ['error', 'tab'], + 'comma-dangle': ['error', 'always-multiline'], + 'no-tabs': 0, + 'no-labels': 0, + }, + parserOptions: { + parser: 'typescript-eslint-parser', + }, +}; diff --git a/packages/editor-ui/.gitignore b/packages/editor-ui/.gitignore new file mode 100644 index 0000000000..f188498740 --- /dev/null +++ b/packages/editor-ui/.gitignore @@ -0,0 +1,24 @@ +.DS_Store +node_modules +/dist + +/tests/e2e/videos/ +/tests/e2e/screenshots/ + +# local env files +.env.local +.env.*.local + +# Log files +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Editor directories and files +.idea +.vscode +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw* diff --git a/packages/editor-ui/.npmignore b/packages/editor-ui/.npmignore new file mode 100644 index 0000000000..23b1e8fc10 --- /dev/null +++ b/packages/editor-ui/.npmignore @@ -0,0 +1,33 @@ +/tests/ +/src + +# local env files +.env.local +.env.*.local + +# Log files +yarn-debug.log* +yarn-error.log* + +# Editor directories and files +.idea +.vscode +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw* + +.browserslistrc +.editorconfig +.eslintrc.js +babel.config.js +cypress.json +jest.config.js +postcss.config.js +tsconfig.json +tslint.json +vue.config.js +dist/report.html +dist/**/*.map +public/ diff --git a/packages/editor-ui/LICENSE b/packages/editor-ui/LICENSE new file mode 100644 index 0000000000..b3aadc2a0f --- /dev/null +++ b/packages/editor-ui/LICENSE @@ -0,0 +1,230 @@ +“Commons Clause” License Condition v1.0 + +The Software is provided to you by the Licensor under the +License, as defined below, subject to the following condition. + +Without limiting other conditions in the License, the grant +of rights under the License will not include, and the License +does not grant to you, the right to Sell the Software. + +For purposes of the foregoing, “Sell” means practicing any or +all of the rights granted to you under the License to provide +to third parties, for a fee or other consideration (including +without limitation fees for hosting or consulting/ support +services related to the Software), a product or service whose +value derives, entirely or substantially, from the functionality +of the Software. Any license notice or attribution required by +the License must also include this Commons Clause License +Condition notice. + +Software: n8n + +License: Apache 2.0 + +Licensor: Jan Oberhauser + + +--------------------------------------------------------------------- + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/editor-ui/README.md b/packages/editor-ui/README.md new file mode 100644 index 0000000000..22718e0840 --- /dev/null +++ b/packages/editor-ui/README.md @@ -0,0 +1,52 @@ +# n8n-editor-ui + +![n8n.io - Workflow Automation](https://n8n.io/n8n-logo.png) + +The UI to create and update n8n workflows + +``` +npm install n8n -g +``` + +## Project setup +``` +npm install +``` + +### Compiles and hot-reloads for development +``` +npm run serve +``` + +### Compiles and minifies for production +``` +npm run build +``` + +### Run your tests +``` +npm run test +``` + +### Lints and fixes files +``` +npm run lint +``` + +### Run your end-to-end tests +``` +npm run test:e2e +``` + +### Run your unit tests +``` +npm run test:unit +``` + +### Customize configuration +See [Configuration Reference](https://cli.vuejs.org/config/). + + +## License + +[Apache 2.0 with Commons Clause](LICENSE) diff --git a/packages/editor-ui/babel.config.js b/packages/editor-ui/babel.config.js new file mode 100644 index 0000000000..b78ce80044 --- /dev/null +++ b/packages/editor-ui/babel.config.js @@ -0,0 +1,10 @@ +module.exports = { + // TODO: Find proper solution. Deactivated as it causes problems with quill. Error occurs when clicking in property field which has expression. + // presets: [ + // '@vue/app' + // ] + // transpileDependencies: [ + // /\/node_modules\/quill/ + // ] +}; +// // https://stackoverflow.com/questions/44625868/es6-babel-class-constructor-cannot-be-invoked-without-new diff --git a/packages/editor-ui/cypress.json b/packages/editor-ui/cypress.json new file mode 100644 index 0000000000..69662149af --- /dev/null +++ b/packages/editor-ui/cypress.json @@ -0,0 +1,3 @@ +{ + "pluginsFile": "tests/e2e/plugins/index.js" +} diff --git a/packages/editor-ui/jest.config.js b/packages/editor-ui/jest.config.js new file mode 100644 index 0000000000..54b1b1d84a --- /dev/null +++ b/packages/editor-ui/jest.config.js @@ -0,0 +1,25 @@ +module.exports = { + moduleFileExtensions: [ + 'js', + 'jsx', + 'json', + 'vue', + 'ts', + 'tsx', + ], + transform: { + '^.+\\.vue$': 'vue-jest', + '.+\\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2)$': 'jest-transform-stub', + '^.+\\.tsx?$': 'ts-jest', + }, + moduleNameMapper: { + '^@/(.*)$': '/src/$1', + }, + snapshotSerializers: [ + 'jest-serializer-vue', + ], + testMatch: [ + '**/tests/unit/**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)', + ], + testURL: 'http://localhost/', +}; diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json new file mode 100644 index 0000000000..2f95b7dea6 --- /dev/null +++ b/packages/editor-ui/package.json @@ -0,0 +1,70 @@ +{ + "name": "n8n-editor-ui", + "version": "0.1.1", + "description": "Workflow Editor UI for n8n", + "license": "SEE LICENSE IN LICENSE", + "author": { + "name": "Jan Oberhauser", + "email": "jan@n8n.io" + }, + "scripts": { + "serve": "VUE_APP_URL_BASE_API=http://localhost:5678/ vue-cli-service serve", + "build": "vue-cli-service build", + "lint": "vue-cli-service lint", + "tslint": "tslint -p tsconfig.json -c tslint.json", + "test:e2e": "vue-cli-service test:e2e", + "test:unit": "vue-cli-service test:unit" + }, + "dependencies": { + }, + "devDependencies": { + "@beyonk/google-fonts-webpack-plugin": "^1.2.3", + "@fortawesome/fontawesome-svg-core": "^1.2.19", + "@fortawesome/free-solid-svg-icons": "^5.9.0", + "@fortawesome/vue-fontawesome": "^0.1.6", + "@types/dateformat": "^3.0.0", + "@types/file-saver": "^2.0.1", + "@types/jest": "^23.3.2", + "@types/lodash.get": "^4.4.5", + "@types/quill": "^2.0.1", + "@vue/cli-plugin-babel": "^3.8.0", + "@vue/cli-plugin-e2e-cypress": "^3.8.0", + "@vue/cli-plugin-eslint": "^3.8.0", + "@vue/cli-plugin-typescript": "~3.2.0", + "@vue/cli-plugin-unit-jest": "^3.8.0", + "@vue/cli-service": "^3.8.0", + "@vue/eslint-config-standard": "^4.0.0", + "@vue/eslint-config-typescript": "~3.2.0", + "@vue/test-utils": "^1.0.0-beta.20", + "axios": "^0.18.1", + "babel-core": "7.0.0-bridge.0", + "babel-eslint": "^10.0.1", + "dateformat": "^3.0.3", + "element-ui": "~2.4.11", + "eslint": "^5.8.0", + "eslint-plugin-vue": "^5.0.0-0", + "file-saver": "^2.0.2", + "flatted": "^2.0.0", + "jquery": "^3.4.1", + "jshint": "^2.9.7", + "jsplumb": "^2.10.0", + "lodash.debounce": "^4.0.8", + "lodash.get": "^4.4.2", + "n8n-workflow": "^0.1.0", + "node-sass": "^4.12.0", + "quill": "^2.0.0-dev.3", + "quill-autoformat": "^0.1.1", + "sass-loader": "^7.0.1", + "string-template-parser": "^1.2.6", + "ts-jest": "^23.10.1", + "tslint": "^5.17.0", + "typescript": "~3.3.0", + "vue": "^2.6.9", + "vue-cli-plugin-webpack-bundle-analyzer": "^1.3.0", + "vue-json-pretty": "^1.4.1", + "vue-router": "^3.0.6", + "vue-typed-mixins": "^0.1.0", + "vuex": "^3.1.1", + "vue-template-compiler": "^2.5.17" + } +} diff --git a/packages/editor-ui/postcss.config.js b/packages/editor-ui/postcss.config.js new file mode 100644 index 0000000000..38e0dc85ce --- /dev/null +++ b/packages/editor-ui/postcss.config.js @@ -0,0 +1,5 @@ +module.exports = { + plugins: { + autoprefixer: {}, + }, +}; diff --git a/packages/editor-ui/public/favicon-16x16.png b/packages/editor-ui/public/favicon-16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..a6e6de975cfb3c5fefa00a4e4c42a5e5898c737e GIT binary patch literal 342 zcmV-c0jd6pP)Bi_@%07*qoM6N<$g7@r`q5uE@ literal 0 HcmV?d00001 diff --git a/packages/editor-ui/public/favicon-32x32.png b/packages/editor-ui/public/favicon-32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..4e5162cdf0ea31dd081044ec5ec867833b57c6de GIT binary patch literal 806 zcmV+>1KIqEP)uM5r;ACprGfR==y4r~KIG&pJ+}QWqbQK-N zQ>kgsE?Tz(AK@?#Vhdiu>oKUSiJ*@?5tJh1wOq_h^xHfPunsMzhCY|DFE#DEL;IBQ zxuQx`!6qSd0Uh!l6>Uh@a5Ub+vgg zT}_};z>iCHuGh4Akn=7)T#A>J@>nV5k<_#oBG=V7*jTu=*R=Dwu5QF{m^0k7;ntiO zl=9%3{%p>5wJF38Mfn{$xTSD7HSIG?CGc+;9l|txjJ+3dY0Ni`NoJySC1(ta|8Iy~ zS9?SBi}Dpd#q)RwPo}2*402t)p*u9Q_!ckYPTXIL6A?bdbGSD(?f0YL!)`u~wfH(U z?XQayNV$~vit-K~jfg%rV`d>Q92q$kW^^Zbdqf=6DPebN+T*=`yMR^%oz_R>_u-U? zHX`1~sa#h(|JH%A1?w@1(~D&^<}*1YKaraD!r1pKWH`v>Trb6U!+6s;s~f|UxP4sa z%QgvfT^%qQM9c@*7v4@yyKiXU8hlb*Db@53@va(!Qf71m<`9mQE|Cp`z0Zy~Hk7X1 zi{tCA*=?BX>SJuf5#1K-(8>Dl)U`K&>Hq)$ literal 0 HcmV?d00001 diff --git a/packages/editor-ui/public/favicon.ico b/packages/editor-ui/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..0b4949e8fda9502820e15173974133a76dc48bc6 GIT binary patch literal 5430 zcmeH~K}eKA6vrnK2@#Ws2$9^Ah(tt0NQVdy5g`#_^sR_QhcI{Z5aKBaN{S>TDnueG zC?q^ZbcjGOm57px=nx$W3PL(mbcod5oqqpsXSUB3(iT>N_Jg1E=FNNaX5PMUzR{Rf zrp(mT7}}WWDmSLV7!!+S`*+X}qH9Elh(%UUQ+miiJegh{B^Af{+j8VB5c)dZWBPmG9-M_?xC#MiEaxwKJ?N@oQ;-Pv(ugddqwz#adsB=) zxCFJ3Tfg(&fbJD2-cUH<5`VDs`BM(x!hp-u>m^(l4dbh|Pr+;GU1YEf1+n!z<#MwYaw^N%19THGn`eu!&Y2kO0-Z;tr<8$G2L8Vca2{4cO`+e` zLNn&hbA6-cz=FqP3a!>qt zn1|M4#Ap6~P@Wa{M?7gI5BgZh2b?bOx@gO|?gTqvT~r^H8H+ia>OB2M^olzU>w@ku z7rIM)hfdHv=nd#MsEzstiBvhdi;!wR9}0$p-qDJqWx}(C&GK7>RdjXy^mV@ zH4wlNh(*V|j9xkW?C&keYe8#8mzj3(duFD|m9M4E^cC-uL-MGf^KV-G6n}>J74k#Y znic;G=q@G{|EX%+*)+MF;iol(zgwa4d))7HkGYhm3DADp{63IXAJSciqworp|80ND zzxP*rC|i@w_;kT;?#SW)3G7?-Hw-qOtv}7%uRF8%r_UK0-iy7B^3fF;Z*}i>uKxU( zT4LyIxCx=Zt5xkY_Z}g~pamAZ9}DNnG&-BLdHMzKUvx||J{=I&2<=Yr^*oSoTWFs- z^Uj~JW|SwPobq0$t2Dp9MfBgm%R=Mj`$f-R@;efRII3aoKm7h^9msmWCi3SgMxQv_ zK{@yUs%6!$*0bn5Dn@+GNvt+de8ttf-5VU6&DD6Z;zsAqz6a^`wBcD39V^MD43sjk z@)@v8KAC!JQ){gK_SM>Pn0|3?@?HJq^EYgBU44k;Gn4NhS^MG%Hg(v7rEfnjHT^GE C-e)!d literal 0 HcmV?d00001 diff --git a/packages/editor-ui/public/index.html b/packages/editor-ui/public/index.html new file mode 100644 index 0000000000..2f2450023d --- /dev/null +++ b/packages/editor-ui/public/index.html @@ -0,0 +1,17 @@ + + + + + + + + n8n.io - Workflow Automation + + + +
+ + + diff --git a/packages/editor-ui/public/n8n-icon-small.png b/packages/editor-ui/public/n8n-icon-small.png new file mode 100644 index 0000000000000000000000000000000000000000..e709f773d164908a623e111f00df86a88ac71e43 GIT binary patch literal 1161 zcmV;41a|w0P)nZtuO{`~CFG8>c??-o5AC_wKvro^$T+7YGJ}G5>)ig0ZA0kV)MD z`QQYo{H-OEt>UDEYe8o@3hIh(po*_-9dHSBRc*400bZg_60j5W2TLMaL+KWLss1D-%GTD{JL<_(*P-X-g?Zy&s#)JBx4d@Tf7<$7&EnYu& zawiGZ$sjI5m#hvNfSMk3n}YYsB{zrh+^x#)1DF63$s|yu`m?p6D9i;{`S6e6Hdq5v zJU+Z5*a;qiLhuqC10zAe!DhI6p=Y387$3C_c&F?u`OH_mw&bqtY0IcFxa1N5Bxv*x z@!+YT*kz2pAi>6NhSE6`ffo%1pIn~%5OnxmPta{o8wy{+XQNHNU|WX=4h7{(x163) zVvLb%W4m7U=SR40T7iw=F+X@Qy=yIKXz9h!w=F3H%Rv+F+39>~OZGVIab2$60DVCc zL;qo8{2t17Ao!;GXa8mTN@V$%&g(z=Wgkf?HIfl)HCSOpv?1UeuZx%>N*N6$TBd?& z?EeUQfv3too>6iT@6WTuYtPEYik1&)TNJgJuabBL=;E+)cRVKdLk?ejdpj7o|d34DH7CzO&abwHyicMa9X(VQ;MmjqTG{&%(;`<5rqL!UD zw%b+zt`INW4eSOF_>T8EW>>G16X>36$fSQLh*?&Q< zu|j9T3a|+5SM85=`9~Vb!CdLg^7~{ZNS^diw5)Tm-=K8XyA>6FkWwDR^Z2WS+iy>y z+5=9;e*7u5HS6f+{0QB^IQBha{rp+!Hw4!~OWw}`+nkCDzmvuv0xY?}lamC&4C5SL z$bjbSJsXOp#@;RC#LA5JoN!Z*U&*$~tCmQyu`JXWRqgMO4F32nhm$1m6 +
+ + +
+ +
+
+ + + + + diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts new file mode 100644 index 0000000000..e43c39ac73 --- /dev/null +++ b/packages/editor-ui/src/Interface.ts @@ -0,0 +1,365 @@ + +import { + IConnections, + ICredentialsDecrypted, + ICredentialsEncrypted, + ICredentialType, + IDataObject, + GenericValue, + IWorkflowSettings as IWorkflowSettingsWorkflow, + INode, + INodeCredentials, + INodeIssues, + INodePropertyOptions, + INodeTypeDescription, + IRunExecutionData, + IRun, + IRunData, + ITaskData, + WorkflowExecuteMode, +} from 'n8n-workflow'; + +import { + PaintStyle, +} from 'jsplumb'; + +declare module 'jsplumb' { + interface Anchor { + lastReturnValue: number[]; + } + + interface Connection { + // bind(event: string, (connection: Connection): void;): void; // tslint:disable-line:no-any + bind(event: string, callback: Function): void; // tslint:disable-line:no-any + removeOverlay(name: string): void; + setParameter(name: string, value: any): void; // tslint:disable-line:no-any + setPaintStyle(arg0: PaintStyle): void; + addOverlay(arg0: any[]): void; // tslint:disable-line:no-any + setConnector(arg0: any[]): void; // tslint:disable-line:no-any + } + + interface Overlay { + setVisible(visible: boolean): void; + } +} + + + +// EndpointOptions from jsplumb seems incomplete and wrong so we define an own one +export interface IEndpointOptions { + anchor?: any; // tslint:disable-line:no-any + createEndpoint?: boolean; + dragAllowedWhenFull?: boolean; + dropOptions?: any; // tslint:disable-line:no-any + dragProxy?: any; // tslint:disable-line:no-any + endpoint?: string; + endpointStyle?: object; + isSource?: boolean; + isTarget?: boolean; + maxConnections?: number; + overlays?: any; // tslint:disable-line:no-any + parameters?: any; // tslint:disable-line:no-any + uuid?: string; +} + +export interface IConnectionsUi { + [key: string]: { + [key: string]: IEndpointOptions; + }; +} + +export interface IUpdateInformation { + name: string; + key: string; + value: string | number; // with null makes problems in NodeSettings.vue + node?: string; + oldValue?: string | number; +} + +export interface INodeUpdatePropertiesInformation { + name: string; // Node-Name + properties: { + [key: string]: IDataObject; + }; +} + +export type XYPositon = [number, number]; + +export type MessageType = 'success' | 'warning' | 'info' | 'error'; + +export interface INodeUi extends INode { + position: XYPositon; + color?: string; + notes?: string; + issues?: INodeIssues; + _jsPlumb?: { + endpoints?: { + [key: string]: IEndpointOptions[]; + }; + }; +} + +export interface INodeTypesMaxCount { + [key: string]: { + exist: number; + max: number; + nodeNames: string[]; + }; +} + +export interface IRestApi { + getActiveWorkflows(): Promise; + getActivationError(id: string): Promise; + getCurrentExecutions(filter: object): Promise; + getPastExecutions(filter: object, limit: number, lastStartedAt?: number): Promise; + stopCurrentExecution(executionId: string): Promise; + makeRestApiRequest(method: string, endpoint: string, data?: any): Promise; // tslint:disable-line:no-any + getSettings(): Promise; + getNodeTypes(): Promise; + getNodeParameterOptions(nodeType: string, methodName: string, credentials?: INodeCredentials): Promise; + removeTestWebhook(workflowId: string): Promise; + runWorkflow(runData: IStartRunData): Promise; + createNewWorkflow(sendData: IWorkflowData): Promise; + updateWorkflow(id: string, data: IWorkflowDataUpdate): Promise; + deleteWorkflow(name: string): Promise; + getWorkflow(id: string): Promise; + getWorkflows(filter?: object): Promise; + getWorkflowFromUrl(url: string): Promise; + createNewCredentials(sendData: ICredentialsDecrypted): Promise; + deleteCredentials(id: string): Promise; + updateCredentials(id: string, data: ICredentialsDecrypted): Promise; + getAllCredentials(filter?: object): Promise; + getCredentials(id: string, includeData?: boolean): Promise; + getCredentialTypes(): Promise; + getExecution(id: string): Promise; + deleteExecutions(sendData: IExecutionDeleteFilter): Promise; + retryExecution(id: string): Promise; + getTimezones(): Promise; +} + +export interface IBinaryDisplayData { + index: number; + key: string; + node: string; + outputIndex: number; + runIndex: number; +} + +export interface IStartRunData { + workflowData: IWorkflowData; + startNodes?: string[]; + destinationNode?: string; + runData?: IRunData; +} + +export interface IRunDataUi { + node?: string; + workflowData: IWorkflowData; +} + +export interface ITableData { + columns: string[]; + data: GenericValue[][]; +} + +export interface IVariableItemSelected { + variable: string; +} + +export interface IVariableSelectorOption { + name: string; + key?: string; + value?: string; + options?: IVariableSelectorOption[] | null; + allowParentSelect?: boolean; + dataType?: string; +} + +// Simple version of n8n-workflow.Workflow +export interface IWorkflowData { + id?: string; + name?: string; + active?: boolean; + nodes: INode[]; + connections: IConnections; + settings?: IWorkflowSettings; +} + +export interface IWorkflowDataUpdate { + name?: string; + nodes?: INode[]; + connections?: IConnections; + settings?: IWorkflowSettings; + active?: boolean; +} + +// Almost identical to cli.Interfaces.ts +export interface IWorkflowDb { + id: string; + name: string; + active: boolean; + createdAt: number | string; + updatedAt: number | string; + nodes: INodeUi[]; + connections: IConnections; + settings?: IWorkflowSettings; +} + +// Identical to cli.Interfaces.ts +export interface IWorkflowShortResponse { + id: string; + name: string; + active: boolean; + createdAt: number | string; + updatedAt: number | string; +} + + +// Identical or almost identical to cli.Interfaces.ts + +export interface IActivationError { + time: number; + error: { + message: string; + }; +} + +export interface ICredentialsResponse extends ICredentialsEncrypted { + id?: string; + createdAt: number | string; + updatedAt: number | string; +} + +export interface ICredentialsBase { + createdAt: number | string; + updatedAt: number | string; +} + +export interface ICredentialsDecryptedResponse extends ICredentialsBase, ICredentialsDecrypted{ + id: string; +} + +export interface IExecutionBase { + id?: number | string; + finished: boolean; + mode: WorkflowExecuteMode; + retryOf?: string; + retrySuccessId?: string; + startedAt: number; + stoppedAt: number; + workflowId?: string; // To be able to filter executions easily // +} + +export interface IExecutionFlatted extends IExecutionBase { + data: string; + workflowData: IWorkflowDb; +} + +export interface IExecutionFlattedResponse extends IExecutionFlatted { + id: string; +} + +export interface IExecutionPushResponse { + executionId?: string; + waitingForWebhook?: boolean; +} + +export interface IExecutionResponse extends IExecutionBase { + id: string; + data: IRunExecutionData; + workflowData: IWorkflowDb; +} + +export interface IExecutionShortResponse { + id: string; + workflowData: { + id: string; + name: string; + }; + mode: WorkflowExecuteMode; + finished: boolean; + startedAt: number | string; + stoppedAt: number | string; + executionTime?: number; +} + +export interface IExecutionsListResponse { + count: number; + results: IExecutionsSummary[]; +} + +export interface IExecutionsCurrentSummaryExtended { + id: string; + finished?: boolean; + mode: WorkflowExecuteMode; + startedAt: number | string; + stoppedAt?: number | string; + workflowId: string; + workflowName?: string; +} + +export interface IExecutionsStopData { + finished?: boolean; + mode: WorkflowExecuteMode; + startedAt: number | string; + stoppedAt: number | string; +} + +export interface IExecutionsSummary { + id: string; + mode: WorkflowExecuteMode; + finished?: boolean; + retryOf?: string; + retrySuccessId?: string; + startedAt: number | string; + stoppedAt?: number | string; + workflowId: string; + workflowName?: string; +} + +export interface IExecutionDeleteFilter { + deleteBefore?: number; + filters?: IDataObject; + ids?: string[]; +} + +export interface IPushData { + data: IPushDataExecutionFinished | IPushDataNodeExecuteAfter | IPushDataNodeExecuteBefore | IPushDataTestWebhook; + type: IPushDataType; +} + +export type IPushDataType = 'executionFinished' | 'nodeExecuteAfter' | 'nodeExecuteBefore' | 'testWebhookDeleted' | 'testWebhookReceived'; + +export interface IPushDataExecutionFinished { + data: IRun; + executionId: string; +} + +export interface IPushDataNodeExecuteAfter { + data: ITaskData; + executionId: string; + nodeName: string; +} + +export interface IPushDataNodeExecuteBefore { + executionId: string; + nodeName: string; +} + +export interface IPushDataTestWebhook { + workflowId: string; +} + +export interface IN8nUISettings { + endpointWebhook: string; + endpointWebhookTest: string; + saveManualRuns: boolean; + timezone: string; + urlBaseWebhook: string; +} + +export interface IWorkflowSettings extends IWorkflowSettingsWorkflow { + errorWorkflow?: string; + saveManualRuns?: boolean; + timezone?: string; +} diff --git a/packages/editor-ui/src/assets/logo.png b/packages/editor-ui/src/assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..f3d2503fc2a44b5053b0837ebea6e87a2d339a43 GIT binary patch literal 6849 zcmaKRcUV(fvo}bjDT-7nLI_nlK}sT_69H+`qzVWDA|yaU?}j417wLi^B1KB1SLsC& zL0ag7$U(XW5YR7p&Ux?sP$d4lvMt8C^+TcQu4F zQqv!UF!I+kw)c0jhd6+g6oCr9P?7)?!qX1ui*iL{p}sKCAGuJ{{W)0z1pLF|=>h}& zt(2Lr0Z`2ig8<5i%Zk}cO5Fm=LByqGWaS`oqChZdEFmc`0hSb#gg|Aap^{+WKOYcj zHjINK)KDG%&s?Mt4CL(T=?;~U@bU2x_mLKN!#GJuK_CzbNw5SMEJorG!}_5;?R>@1 zSl)jns3WlU7^J%=(hUtfmuUCU&C3%8B5C^f5>W2Cy8jW3#{Od{lF1}|?c61##3dzA zsPlFG;l_FzBK}8>|H_Ru_H#!_7$UH4UKo3lKOA}g1(R&|e@}GINYVzX?q=_WLZCgh z)L|eJMce`D0EIwgRaNETDsr+?vQknSGAi=7H00r`QnI%oQnFxm`G2umXso9l+8*&Q z7WqF|$p49js$mdzo^BXpH#gURy=UO;=IMrYc5?@+sR4y_?d*~0^YP7d+y0{}0)zBM zIKVM(DBvICK#~7N0a+PY6)7;u=dutmNqK3AlsrUU9U`d;msiucB_|8|2kY=(7XA;G zwDA8AR)VCA#JOkxm#6oHNS^YVuOU;8p$N)2{`;oF|rQ?B~K$%rHDxXs+_G zF5|-uqHZvSzq}L;5Kcy_P+x0${33}Ofb6+TX&=y;;PkEOpz%+_bCw_{<&~ zeLV|!bP%l1qxywfVr9Z9JI+++EO^x>ZuCK);=$VIG1`kxK8F2M8AdC$iOe3cj1fo(ce4l-9 z7*zKy3={MixvUk=enQE;ED~7tv%qh&3lR<0m??@w{ILF|e#QOyPkFYK!&Up7xWNtL zOW%1QMC<3o;G9_S1;NkPB6bqbCOjeztEc6TsBM<(q9((JKiH{01+Ud=uw9B@{;(JJ z-DxI2*{pMq`q1RQc;V8@gYAY44Z!%#W~M9pRxI(R?SJ7sy7em=Z5DbuDlr@*q|25V)($-f}9c#?D%dU^RS<(wz?{P zFFHtCab*!rl(~j@0(Nadvwg8q|4!}L^>d?0al6}Rrv9$0M#^&@zjbfJy_n!%mVHK4 z6pLRIQ^Uq~dnyy$`ay51Us6WaP%&O;@49m&{G3z7xV3dLtt1VTOMYl3UW~Rm{Eq4m zF?Zl_v;?7EFx1_+#WFUXxcK78IV)FO>42@cm@}2I%pVbZqQ}3;p;sDIm&knay03a^ zn$5}Q$G!@fTwD$e(x-~aWP0h+4NRz$KlnO_H2c< z(XX#lPuW_%H#Q+c&(nRyX1-IadKR-%$4FYC0fsCmL9ky3 zKpxyjd^JFR+vg2!=HWf}2Z?@Td`0EG`kU?{8zKrvtsm)|7>pPk9nu@2^z96aU2<#` z2QhvH5w&V;wER?mopu+nqu*n8p~(%QkwSs&*0eJwa zMXR05`OSFpfyRb!Y_+H@O%Y z0=K^y6B8Gcbl?SA)qMP3Z+=C(?8zL@=74R=EVnE?vY!1BQy2@q*RUgRx4yJ$k}MnL zs!?74QciNb-LcG*&o<9=DSL>1n}ZNd)w1z3-0Pd^4ED1{qd=9|!!N?xnXjM!EuylY z5=!H>&hSofh8V?Jofyd!h`xDI1fYAuV(sZwwN~{$a}MX^=+0TH*SFp$vyxmUv7C*W zv^3Gl0+eTFgBi3FVD;$nhcp)ka*4gSskYIqQ&+M}xP9yLAkWzBI^I%zR^l1e?bW_6 zIn{mo{dD=)9@V?s^fa55jh78rP*Ze<3`tRCN4*mpO$@7a^*2B*7N_|A(Ve2VB|)_o z$=#_=aBkhe(ifX}MLT()@5?OV+~7cXC3r!%{QJxriXo9I%*3q4KT4Xxzyd{ z9;_%=W%q!Vw$Z7F3lUnY+1HZ*lO;4;VR2+i4+D(m#01OYq|L_fbnT;KN<^dkkCwtd zF7n+O7KvAw8c`JUh6LmeIrk4`F3o|AagKSMK3))_5Cv~y2Bb2!Ibg9BO7Vkz?pAYX zoI=B}+$R22&IL`NCYUYjrdhwjnMx_v=-Qcx-jmtN>!Zqf|n1^SWrHy zK|MwJ?Z#^>)rfT5YSY{qjZ&`Fjd;^vv&gF-Yj6$9-Dy$<6zeP4s+78gS2|t%Z309b z0^fp~ue_}i`U9j!<|qF92_3oB09NqgAoehQ`)<)dSfKoJl_A6Ec#*Mx9Cpd-p#$Ez z={AM*r-bQs6*z$!*VA4|QE7bf@-4vb?Q+pPKLkY2{yKsw{&udv_2v8{Dbd zm~8VAv!G~s)`O3|Q6vFUV%8%+?ZSVUa(;fhPNg#vab@J*9XE4#D%)$UU-T5`fwjz! z6&gA^`OGu6aUk{l*h9eB?opVdrHK>Q@U>&JQ_2pR%}TyOXGq_6s56_`U(WoOaAb+K zXQr#6H}>a-GYs9^bGP2Y&hSP5gEtW+GVC4=wy0wQk=~%CSXj=GH6q z-T#s!BV`xZVxm{~jr_ezYRpqqIcXC=Oq`b{lu`Rt(IYr4B91hhVC?yg{ol4WUr3v9 zOAk2LG>CIECZ-WIs0$N}F#eoIUEtZudc7DPYIjzGqDLWk_A4#(LgacooD z2K4IWs@N`Bddm-{%oy}!k0^i6Yh)uJ1S*90>|bm3TOZxcV|ywHUb(+CeX-o1|LTZM zwU>dY3R&U)T(}5#Neh?-CWT~@{6Ke@sI)uSuzoah8COy)w)B)aslJmp`WUcjdia-0 zl2Y}&L~XfA`uYQboAJ1;J{XLhYjH){cObH3FDva+^8ioOQy%Z=xyjGLmWMrzfFoH; zEi3AG`_v+%)&lDJE;iJWJDI@-X9K5O)LD~j*PBe(wu+|%ar~C+LK1+-+lK=t# z+Xc+J7qp~5q=B~rD!x78)?1+KUIbYr^5rcl&tB-cTtj+e%{gpZZ4G~6r15+d|J(ky zjg@@UzMW0k9@S#W(1H{u;Nq(7llJbq;;4t$awM;l&(2s+$l!Ay9^Ge|34CVhr7|BG z?dAR83smef^frq9V(OH+a+ki#q&-7TkWfFM=5bsGbU(8mC;>QTCWL5ydz9s6k@?+V zcjiH`VI=59P-(-DWXZ~5DH>B^_H~;4$)KUhnmGo*G!Tq8^LjfUDO)lASN*=#AY_yS zqW9UX(VOCO&p@kHdUUgsBO0KhXxn1sprK5h8}+>IhX(nSXZKwlNsjk^M|RAaqmCZB zHBolOHYBas@&{PT=R+?d8pZu zUHfyucQ`(umXSW7o?HQ3H21M`ZJal+%*)SH1B1j6rxTlG3hx1IGJN^M7{$j(9V;MZ zRKybgVuxKo#XVM+?*yTy{W+XHaU5Jbt-UG33x{u(N-2wmw;zzPH&4DE103HV@ER86 z|FZEmQb|&1s5#`$4!Cm}&`^{(4V}OP$bk`}v6q6rm;P!H)W|2i^e{7lTk2W@jo_9q z*aw|U7#+g59Fv(5qI`#O-qPj#@_P>PC#I(GSp3DLv7x-dmYK=C7lPF8a)bxb=@)B1 zUZ`EqpXV2dR}B&r`uM}N(TS99ZT0UB%IN|0H%DcVO#T%L_chrgn#m6%x4KE*IMfjX zJ%4veCEqbXZ`H`F_+fELMC@wuy_ch%t*+Z+1I}wN#C+dRrf2X{1C8=yZ_%Pt6wL_~ zZ2NN-hXOT4P4n$QFO7yYHS-4wF1Xfr-meG9Pn;uK51?hfel`d38k{W)F*|gJLT2#T z<~>spMu4(mul-8Q3*pf=N4DcI)zzjqAgbE2eOT7~&f1W3VsdD44Ffe;3mJp-V@8UC z)|qnPc12o~$X-+U@L_lWqv-RtvB~%hLF($%Ew5w>^NR82qC_0FB z)=hP1-OEx?lLi#jnLzH}a;Nvr@JDO-zQWd}#k^an$Kwml;MrD&)sC5b`s0ZkVyPkb zt}-jOq^%_9>YZe7Y}PhW{a)c39G`kg(P4@kxjcYfgB4XOOcmezdUI7j-!gs7oAo2o zx(Ph{G+YZ`a%~kzK!HTAA5NXE-7vOFRr5oqY$rH>WI6SFvWmahFav!CfRMM3%8J&c z*p+%|-fNS_@QrFr(at!JY9jCg9F-%5{nb5Bo~z@Y9m&SHYV`49GAJjA5h~h4(G!Se zZmK{Bo7ivCfvl}@A-ptkFGcWXAzj3xfl{evi-OG(TaCn1FAHxRc{}B|x+Ua1D=I6M z!C^ZIvK6aS_c&(=OQDZfm>O`Nxsw{ta&yiYPA~@e#c%N>>#rq)k6Aru-qD4(D^v)y z*>Rs;YUbD1S8^D(ps6Jbj0K3wJw>L4m)0e(6Pee3Y?gy9i0^bZO?$*sv+xKV?WBlh zAp*;v6w!a8;A7sLB*g-^<$Z4L7|5jXxxP1}hQZ<55f9<^KJ>^mKlWSGaLcO0=$jem zWyZkRwe~u{{tU63DlCaS9$Y4CP4f?+wwa(&1ou)b>72ydrFvm`Rj-0`kBJgK@nd(*Eh!(NC{F-@=FnF&Y!q`7){YsLLHf0_B6aHc# z>WIuHTyJwIH{BJ4)2RtEauC7Yq7Cytc|S)4^*t8Va3HR zg=~sN^tp9re@w=GTx$;zOWMjcg-7X3Wk^N$n;&Kf1RgVG2}2L-(0o)54C509C&77i zrjSi{X*WV=%C17((N^6R4Ya*4#6s_L99RtQ>m(%#nQ#wrRC8Y%yxkH;d!MdY+Tw@r zjpSnK`;C-U{ATcgaxoEpP0Gf+tx);buOMlK=01D|J+ROu37qc*rD(w`#O=3*O*w9?biwNoq3WN1`&Wp8TvKj3C z3HR9ssH7a&Vr<6waJrU zdLg!ieYz%U^bmpn%;(V%%ugMk92&?_XX1K@mwnVSE6!&%P%Wdi7_h`CpScvspMx?N zQUR>oadnG17#hNc$pkTp+9lW+MBKHRZ~74XWUryd)4yd zj98$%XmIL4(9OnoeO5Fnyn&fpQ9b0h4e6EHHw*l68j;>(ya`g^S&y2{O8U>1*>4zR zq*WSI_2o$CHQ?x0!wl9bpx|Cm2+kFMR)oMud1%n2=qn5nE&t@Fgr#=Zv2?}wtEz^T z9rrj=?IH*qI5{G@Rn&}^Z{+TW}mQeb9=8b<_a`&Cm#n%n~ zU47MvCBsdXFB1+adOO)03+nczfWa#vwk#r{o{dF)QWya9v2nv43Zp3%Ps}($lA02*_g25t;|T{A5snSY?3A zrRQ~(Ygh_ebltHo1VCbJb*eOAr;4cnlXLvI>*$-#AVsGg6B1r7@;g^L zFlJ_th0vxO7;-opU@WAFe;<}?!2q?RBrFK5U{*ai@NLKZ^};Ul}beukveh?TQn;$%9=R+DX07m82gP$=}Uo_%&ngV`}Hyv8g{u z3SWzTGV|cwQuFIs7ZDOqO_fGf8Q`8MwL}eUp>q?4eqCmOTcwQuXtQckPy|4F1on8l zP*h>d+cH#XQf|+6c|S{7SF(Lg>bR~l(0uY?O{OEVlaxa5@e%T&xju=o1`=OD#qc16 zSvyH*my(dcp6~VqR;o(#@m44Lug@~_qw+HA=mS#Z^4reBy8iV?H~I;{LQWk3aKK8$bLRyt$g?- +
+ + Back to list + + +
+
+ Data to display did not get found +
+ +
+ +
+ + + + + diff --git a/packages/editor-ui/src/components/CollectionParameter.vue b/packages/editor-ui/src/components/CollectionParameter.vue new file mode 100644 index 0000000000..21bf339906 --- /dev/null +++ b/packages/editor-ui/src/components/CollectionParameter.vue @@ -0,0 +1,193 @@ + + + + + diff --git a/packages/editor-ui/src/components/CredentialsEdit.vue b/packages/editor-ui/src/components/CredentialsEdit.vue new file mode 100644 index 0000000000..7be7a1326a --- /dev/null +++ b/packages/editor-ui/src/components/CredentialsEdit.vue @@ -0,0 +1,217 @@ + + + + + diff --git a/packages/editor-ui/src/components/CredentialsInput.vue b/packages/editor-ui/src/components/CredentialsInput.vue new file mode 100644 index 0000000000..4150d18722 --- /dev/null +++ b/packages/editor-ui/src/components/CredentialsInput.vue @@ -0,0 +1,270 @@ + + + + + diff --git a/packages/editor-ui/src/components/CredentialsList.vue b/packages/editor-ui/src/components/CredentialsList.vue new file mode 100644 index 0000000000..68503b8191 --- /dev/null +++ b/packages/editor-ui/src/components/CredentialsList.vue @@ -0,0 +1,184 @@ + + + + + diff --git a/packages/editor-ui/src/components/DataDisplay.vue b/packages/editor-ui/src/components/DataDisplay.vue new file mode 100644 index 0000000000..56e5d65992 --- /dev/null +++ b/packages/editor-ui/src/components/DataDisplay.vue @@ -0,0 +1,103 @@ + + + + + diff --git a/packages/editor-ui/src/components/DisplayWithChange.vue b/packages/editor-ui/src/components/DisplayWithChange.vue new file mode 100644 index 0000000000..2f8264d8d0 --- /dev/null +++ b/packages/editor-ui/src/components/DisplayWithChange.vue @@ -0,0 +1,121 @@ + + + + + diff --git a/packages/editor-ui/src/components/ExecutionsList.vue b/packages/editor-ui/src/components/ExecutionsList.vue new file mode 100644 index 0000000000..05b54a43d6 --- /dev/null +++ b/packages/editor-ui/src/components/ExecutionsList.vue @@ -0,0 +1,624 @@ + + + + + + + diff --git a/packages/editor-ui/src/components/ExpressionEdit.vue b/packages/editor-ui/src/components/ExpressionEdit.vue new file mode 100644 index 0000000000..46bf5d9a41 --- /dev/null +++ b/packages/editor-ui/src/components/ExpressionEdit.vue @@ -0,0 +1,147 @@ + + + + + diff --git a/packages/editor-ui/src/components/ExpressionInput.vue b/packages/editor-ui/src/components/ExpressionInput.vue new file mode 100644 index 0000000000..0673991804 --- /dev/null +++ b/packages/editor-ui/src/components/ExpressionInput.vue @@ -0,0 +1,360 @@ + + + + + diff --git a/packages/editor-ui/src/components/FixedCollectionParameter.vue b/packages/editor-ui/src/components/FixedCollectionParameter.vue new file mode 100644 index 0000000000..91980dbdf0 --- /dev/null +++ b/packages/editor-ui/src/components/FixedCollectionParameter.vue @@ -0,0 +1,235 @@ + + + + + diff --git a/packages/editor-ui/src/components/MainHeader.vue b/packages/editor-ui/src/components/MainHeader.vue new file mode 100644 index 0000000000..2502380929 --- /dev/null +++ b/packages/editor-ui/src/components/MainHeader.vue @@ -0,0 +1,314 @@ + + + + + + + diff --git a/packages/editor-ui/src/components/MainSidebar.vue b/packages/editor-ui/src/components/MainSidebar.vue new file mode 100644 index 0000000000..97317d9d6d --- /dev/null +++ b/packages/editor-ui/src/components/MainSidebar.vue @@ -0,0 +1,459 @@ + + + + + diff --git a/packages/editor-ui/src/components/MultipleParameter.vue b/packages/editor-ui/src/components/MultipleParameter.vue new file mode 100644 index 0000000000..362329cdb6 --- /dev/null +++ b/packages/editor-ui/src/components/MultipleParameter.vue @@ -0,0 +1,171 @@ + + + + + + + diff --git a/packages/editor-ui/src/components/Node.vue b/packages/editor-ui/src/components/Node.vue new file mode 100644 index 0000000000..00b5c01f21 --- /dev/null +++ b/packages/editor-ui/src/components/Node.vue @@ -0,0 +1,406 @@ + + + + + + + diff --git a/packages/editor-ui/src/components/NodeCreateItem.vue b/packages/editor-ui/src/components/NodeCreateItem.vue new file mode 100644 index 0000000000..311d5b52b0 --- /dev/null +++ b/packages/editor-ui/src/components/NodeCreateItem.vue @@ -0,0 +1,79 @@ + + + + + diff --git a/packages/editor-ui/src/components/NodeCreateList.vue b/packages/editor-ui/src/components/NodeCreateList.vue new file mode 100644 index 0000000000..6a260ea845 --- /dev/null +++ b/packages/editor-ui/src/components/NodeCreateList.vue @@ -0,0 +1,160 @@ + + + + + diff --git a/packages/editor-ui/src/components/NodeCreator.vue b/packages/editor-ui/src/components/NodeCreator.vue new file mode 100644 index 0000000000..7090d5749c --- /dev/null +++ b/packages/editor-ui/src/components/NodeCreator.vue @@ -0,0 +1,104 @@ + + + + + diff --git a/packages/editor-ui/src/components/NodeCredentials.vue b/packages/editor-ui/src/components/NodeCredentials.vue new file mode 100644 index 0000000000..213f142840 --- /dev/null +++ b/packages/editor-ui/src/components/NodeCredentials.vue @@ -0,0 +1,316 @@ + + + + + diff --git a/packages/editor-ui/src/components/NodeIcon.vue b/packages/editor-ui/src/components/NodeIcon.vue new file mode 100644 index 0000000000..eb11809ed5 --- /dev/null +++ b/packages/editor-ui/src/components/NodeIcon.vue @@ -0,0 +1,80 @@ + + + + + diff --git a/packages/editor-ui/src/components/NodeSettings.vue b/packages/editor-ui/src/components/NodeSettings.vue new file mode 100644 index 0000000000..cecbab36f5 --- /dev/null +++ b/packages/editor-ui/src/components/NodeSettings.vue @@ -0,0 +1,540 @@ + + + + + diff --git a/packages/editor-ui/src/components/NodeWebhooks.vue b/packages/editor-ui/src/components/NodeWebhooks.vue new file mode 100644 index 0000000000..f7d538895e --- /dev/null +++ b/packages/editor-ui/src/components/NodeWebhooks.vue @@ -0,0 +1,222 @@ + + + + + diff --git a/packages/editor-ui/src/components/PageContentWrapper.vue b/packages/editor-ui/src/components/PageContentWrapper.vue new file mode 100644 index 0000000000..87fe952849 --- /dev/null +++ b/packages/editor-ui/src/components/PageContentWrapper.vue @@ -0,0 +1,39 @@ + + + + + diff --git a/packages/editor-ui/src/components/ParameterInput.vue b/packages/editor-ui/src/components/ParameterInput.vue new file mode 100644 index 0000000000..6fc07f4b13 --- /dev/null +++ b/packages/editor-ui/src/components/ParameterInput.vue @@ -0,0 +1,633 @@ + + + + + + + diff --git a/packages/editor-ui/src/components/ParameterInputFull.vue b/packages/editor-ui/src/components/ParameterInputFull.vue new file mode 100644 index 0000000000..c173fefb3b --- /dev/null +++ b/packages/editor-ui/src/components/ParameterInputFull.vue @@ -0,0 +1,96 @@ + + + + + diff --git a/packages/editor-ui/src/components/ParameterInputList.vue b/packages/editor-ui/src/components/ParameterInputList.vue new file mode 100644 index 0000000000..1b19909fd4 --- /dev/null +++ b/packages/editor-ui/src/components/ParameterInputList.vue @@ -0,0 +1,228 @@ + + + + + diff --git a/packages/editor-ui/src/components/RunData.vue b/packages/editor-ui/src/components/RunData.vue new file mode 100644 index 0000000000..9d97addf8f --- /dev/null +++ b/packages/editor-ui/src/components/RunData.vue @@ -0,0 +1,628 @@ + + + + + diff --git a/packages/editor-ui/src/components/TextEdit.vue b/packages/editor-ui/src/components/TextEdit.vue new file mode 100644 index 0000000000..e861e301a1 --- /dev/null +++ b/packages/editor-ui/src/components/TextEdit.vue @@ -0,0 +1,66 @@ + + + + + diff --git a/packages/editor-ui/src/components/VariableSelector.vue b/packages/editor-ui/src/components/VariableSelector.vue new file mode 100644 index 0000000000..0c94f22bb0 --- /dev/null +++ b/packages/editor-ui/src/components/VariableSelector.vue @@ -0,0 +1,590 @@ + + + + + diff --git a/packages/editor-ui/src/components/VariableSelectorItem.vue b/packages/editor-ui/src/components/VariableSelectorItem.vue new file mode 100644 index 0000000000..3c52494878 --- /dev/null +++ b/packages/editor-ui/src/components/VariableSelectorItem.vue @@ -0,0 +1,153 @@ + + + + + diff --git a/packages/editor-ui/src/components/WorkflowActivator.vue b/packages/editor-ui/src/components/WorkflowActivator.vue new file mode 100644 index 0000000000..de01907894 --- /dev/null +++ b/packages/editor-ui/src/components/WorkflowActivator.vue @@ -0,0 +1,179 @@ + + + + + diff --git a/packages/editor-ui/src/components/WorkflowOpen.vue b/packages/editor-ui/src/components/WorkflowOpen.vue new file mode 100644 index 0000000000..6fe447cfb9 --- /dev/null +++ b/packages/editor-ui/src/components/WorkflowOpen.vue @@ -0,0 +1,140 @@ + + + + + diff --git a/packages/editor-ui/src/components/WorkflowSettings.vue b/packages/editor-ui/src/components/WorkflowSettings.vue new file mode 100644 index 0000000000..2bf5b4fdca --- /dev/null +++ b/packages/editor-ui/src/components/WorkflowSettings.vue @@ -0,0 +1,283 @@ + + + + + diff --git a/packages/editor-ui/src/components/mixins/copyPaste.ts b/packages/editor-ui/src/components/mixins/copyPaste.ts new file mode 100644 index 0000000000..557ff9f201 --- /dev/null +++ b/packages/editor-ui/src/components/mixins/copyPaste.ts @@ -0,0 +1,201 @@ +/** + * Captures any pasted data and sends it to method "receivedCopyPasteData" which has to be + * defined on the component which uses this mixin + */ +import Vue from 'vue'; + +// export const copyPaste = { +export const copyPaste = Vue.extend({ + data () { + return { + copyPasteElementsGotCreated: false, + }; + }, + mounted () { + if (this.copyPasteElementsGotCreated === true) { + return; + } + + this.copyPasteElementsGotCreated = true; + // Define the style of the html elements that get created to make + // sure that they are not visible + const style = document.createElement('style'); + style.type = 'text/css'; + style.innerHTML = ` + .hidden-copy-paste { + position: absolute; + bottom: 0; + left: 0; + width: 10px; + height: 10px; + display: block; + font-size: 1px; + z-index: -1; + color: transparent; + background: transparent; + overflow: hidden; + border: none; + padding: 0; + resize: none; + outline: none; + -webkit-user-select: text; + user-select: text; + } + `; + document.getElementsByTagName('head')[0].appendChild(style); + + // Code is mainly from + // https://www.lucidchart.com/techblog/2014/12/02/definitive-guide-copying-pasting-javascript/ + const isSafari = navigator.appVersion.search('Safari') !== -1 && navigator.appVersion.search('Chrome') === -1 && navigator.appVersion.search('CrMo') === -1 && navigator.appVersion.search('CriOS') === -1; + const isIe = (navigator.userAgent.toLowerCase().indexOf('msie') !== -1 || navigator.userAgent.toLowerCase().indexOf('trident') !== -1); + + const hiddenInput = document.createElement('input'); + hiddenInput.setAttribute('type', 'text'); + hiddenInput.setAttribute('id', 'hidden-input-copy-paste'); + hiddenInput.setAttribute('class', 'hidden-copy-paste'); + + document.body.append(hiddenInput); + + let ieClipboardDiv: HTMLDivElement | null = null; + if (isIe) { + ieClipboardDiv = document.createElement('div'); + ieClipboardDiv.setAttribute('id', 'hidden-ie-clipboard-copy-paste'); + ieClipboardDiv.setAttribute('class', 'hidden-copy-paste'); + ieClipboardDiv.setAttribute('contenteditable', 'true'); + document.body.append(ieClipboardDiv); + + document.addEventListener('beforepaste', () => { + // @ts-ignore + if (hiddenInput.is(':focus')) { + this.focusIeClipboardDiv(ieClipboardDiv as HTMLDivElement); + } + }, true); + } + + let userInput = ''; + const hiddenInputListener = (text: string) => { }; + + hiddenInput.addEventListener('input', (e) => { + const value = hiddenInput.value; + userInput += value; + hiddenInputListener(userInput); + + // There is a bug (sometimes) with Safari and the input area can't be updated during + // the input event, so we update the input area after the event is done being processed + if (isSafari) { + hiddenInput.focus(); + setTimeout(() => { this.focusHiddenArea(hiddenInput); }, 0); + } else { + this.focusHiddenArea(hiddenInput); + } + }); + + // Set clipboard event listeners on the document. + ['paste'].forEach((event) => { + document.addEventListener(event, (e) => { + // Check if the event got emitted from a message box or from something + // else which should ignore the copy/paste + // @ts-ignore + const path = e.path || (e.composedPath && e.composedPath()); + for (let index = 0; index < path.length; index++) { + if (path[index].className && typeof path[index].className === 'string' && ( + path[index].className.includes('el-message-box') || path[index].className.includes('ignore-key-press') + )) { + return; + } + } + + if (ieClipboardDiv !== null) { + this.ieClipboardEvent(event, ieClipboardDiv); + } else { + this.standardClipboardEvent(event, e as ClipboardEvent); + // @ts-ignore + if (!document.activeElement || (document.activeElement && ['textarea', 'text', 'email', 'password'].indexOf(document.activeElement.type) === -1)) { + // That it still allows to paste into text, email, password & textarea-fiels we + // check if we can identify the active element and if so only + // run it if something else is selected. + this.focusHiddenArea(hiddenInput); + e.preventDefault(); + } + } + }); + }); + }, + methods: { + receivedCopyPasteData (plainTextData: string, event?: ClipboardEvent): void { + // THIS HAS TO BE DEFINED IN COMPONENT! + }, + + // For every browser except IE, we can easily get and set data on the clipboard + standardClipboardEvent (clipboardEventName: string, event: ClipboardEvent) { + const clipboardData = event.clipboardData; + if (clipboardData !== null && clipboardEventName === 'paste') { + const clipboardText = clipboardData.getData('text/plain'); + this.receivedCopyPasteData(clipboardText, event); + } + }, + + // For IE, we can get/set Text or URL just as we normally would + ieClipboardEvent (clipboardEventName: string, ieClipboardDiv: HTMLDivElement) { + // @ts-ignore + const clipboardData = window.clipboardData; + if (clipboardEventName === 'paste') { + const clipboardText = clipboardData.getData('Text'); + // @ts-ignore + ieClipboardDiv.empty(); + this.receivedCopyPasteData(clipboardText); + } + }, + + // Focuses an element to be ready for copy/paste (used exclusively for IE) + focusIeClipboardDiv (ieClipboardDiv: HTMLDivElement) { + ieClipboardDiv.focus(); + const range = document.createRange(); + // @ts-ignore + range.selectNodeContents((ieClipboardDiv.get(0))); + const selection = window.getSelection(); + if (selection !== null) { + selection.removeAllRanges(); + selection.addRange(range); + } + }, + + focusHiddenArea (hiddenInput: HTMLInputElement) { + // In order to ensure that the browser will fire clipboard events, we always need to have something selected + hiddenInput.value = ' '; + hiddenInput.focus(); + hiddenInput.select(); + }, + + /** + * Copies given data to clipboard + */ + copyToClipboard (value: string): void { + // FROM: https://hackernoon.com/copying-text-to-clipboard-with-javascript-df4d4988697f + const element = document.createElement('textarea'); // Create a